package controller import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/service/singleton" "github.com/stretchr/testify/assert" ) func TestValidateRuleAcceptsMemberSelfTriggerTasks(t *testing.T) { ctx := newMemberValidationContext(t) rule := &model.AlertRule{ Common: model.Common{UserID: 200}, Name: "member alert", Rules: []*model.Rule{{Type: "offline", Duration: 3}}, FailTriggerTasks: []uint64{43}, RecoverTriggerTasks: []uint64{43}, } assert.NoError(t, validateRule(ctx, rule)) } func TestValidateRuleAcceptsAdminCrossUserTriggerTasks(t *testing.T) { ctx := newAdminValidationContext(t) rule := &model.AlertRule{ Common: model.Common{UserID: 1}, Name: "admin alert", Rules: []*model.Rule{{Type: "offline", Duration: 3}}, FailTriggerTasks: []uint64{43}, RecoverTriggerTasks: []uint64{43}, } assert.NoError(t, validateRule(ctx, rule)) } func TestValidateRuleAcceptsEmptyTriggerTasks(t *testing.T) { ctx := newMemberValidationContext(t) rule := &model.AlertRule{ Common: model.Common{UserID: 200}, Name: "member alert", Rules: []*model.Rule{{Type: "offline", Duration: 3}}, } assert.NoError(t, validateRule(ctx, rule)) } func TestValidateRuleAcceptsUnknownTriggerTaskID(t *testing.T) { ctx := newMemberValidationContext(t) rule := &model.AlertRule{ Common: model.Common{UserID: 200}, Name: "member alert", Rules: []*model.Rule{{Type: "offline", Duration: 3}}, FailTriggerTasks: []uint64{9999}, } assert.NoError(t, validateRule(ctx, rule)) } func TestValidateRuleRejectsForeignNotificationGroup(t *testing.T) { ctx := newMemberValidationContext(t) rule := &model.AlertRule{ Common: model.Common{UserID: 200}, Name: "member alert", Rules: []*model.Rule{{Type: "offline", Duration: 3}}, NotificationGroupID: 7, } assert.Error(t, validateRule(ctx, rule)) } func TestValidateRuleAcceptsMemberOwnedNotificationGroup(t *testing.T) { ctx := newMemberValidationContext(t) rule := &model.AlertRule{ Common: model.Common{UserID: 200}, Name: "member alert", Rules: []*model.Rule{{Type: "offline", Duration: 3}}, NotificationGroupID: 8, } assert.NoError(t, validateRule(ctx, rule)) } func TestValidateRuleAdminCanReferenceAnyNotificationGroup(t *testing.T) { ctx := newAdminValidationContext(t) rule := &model.AlertRule{ Common: model.Common{UserID: 1}, Name: "admin alert", Rules: []*model.Rule{{Type: "offline", Duration: 3}}, NotificationGroupID: 8, } assert.NoError(t, validateRule(ctx, rule)) } func TestValidateServersRejectsForeignNotificationGroup(t *testing.T) { ctx := newMemberValidationContext(t) service := &model.Service{ Common: model.Common{UserID: 200}, Name: "member service", SkipServers: map[uint64]bool{}, NotificationGroupID: 7, } assert.Error(t, validateServers(ctx, service)) } func TestValidateServersAcceptsMemberOwnedNotificationGroup(t *testing.T) { ctx := newMemberValidationContext(t) service := &model.Service{ Common: model.Common{UserID: 200}, Name: "member service", SkipServers: map[uint64]bool{}, NotificationGroupID: 8, } assert.NoError(t, validateServers(ctx, service)) } func TestUserCanViewServer(t *testing.T) { memberServer := &model.Server{Common: model.Common{ID: 1, UserID: 200}} adminServer := &model.Server{Common: model.Common{ID: 2, UserID: 1}} hiddenAdminServer := &model.Server{Common: model.Common{ID: 3, UserID: 1}, HideForGuest: true} publicAdminServer := &model.Server{Common: model.Common{ID: 4, UserID: 1}, HideForGuest: false} cases := []struct { name string setup func(c *gin.Context) server *model.Server wantAllow bool }{ { name: "guest sees public", setup: func(c *gin.Context) {}, server: publicAdminServer, wantAllow: true, }, { name: "guest blocked by HideForGuest", setup: func(c *gin.Context) {}, server: hiddenAdminServer, wantAllow: false, }, { name: "member sees own", setup: func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 200}, Role: model.RoleMember}) }, server: memberServer, wantAllow: true, }, { name: "member can still see public foreign", setup: func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 200}, Role: model.RoleMember}) }, server: publicAdminServer, wantAllow: true, }, { name: "member blocked by HideForGuest foreign", setup: func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 200}, Role: model.RoleMember}) }, server: hiddenAdminServer, wantAllow: false, }, { name: "admin sees hidden foreign", setup: func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}) }, server: hiddenAdminServer, wantAllow: true, }, { name: "admin sees other admin server", setup: func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}) }, server: adminServer, wantAllow: true, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) tc.setup(ctx) if got := userCanViewServer(ctx, tc.server); got != tc.wantAllow { t.Fatalf("userCanViewServer = %v, want %v", got, tc.wantAllow) } }) } } type permissionTestResource struct { ID uint64 `json:"id"` UserID uint64 `json:"user_id"` } func (r *permissionTestResource) GetID() uint64 { return r.ID } func (r *permissionTestResource) GetUserID() uint64 { return r.UserID } func (r *permissionTestResource) HasPermission(c *gin.Context) bool { auth, ok := c.Get(model.CtxKeyAuthorizedUser) if !ok { return false } user := *auth.(*model.User) if user.Role == model.RoleAdmin { return true } return user.ID == r.UserID } func TestListHandlerFiltersByOwnership(t *testing.T) { gin.SetMode(gin.TestMode) data := []*permissionTestResource{ {ID: 1, UserID: 100}, {ID: 2, UserID: 200}, {ID: 3, UserID: 200}, } handler := listHandler(func(c *gin.Context) ([]*permissionTestResource, error) { return append([]*permissionTestResource{}, data...), nil }) t.Run("member only sees own", func(t *testing.T) { r := gin.New() r.Use(func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 200}, Role: model.RoleMember}) c.Next() }) r.GET("/test", handler) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) ids := decodeIDs[uint64](t, w.Body.Bytes()) assert.ElementsMatch(t, []uint64{2, 3}, ids) }) t.Run("admin sees all", func(t *testing.T) { r := gin.New() r.Use(func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}) c.Next() }) r.GET("/test", handler) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) ids := decodeIDs[uint64](t, w.Body.Bytes()) assert.ElementsMatch(t, []uint64{1, 2, 3}, ids) }) } func decodeIDs[T ~uint64](t *testing.T, body []byte) []T { t.Helper() var resp struct { Data []map[string]any `json:"data"` } if err := json.Unmarshal(body, &resp); err != nil { t.Fatalf("decode response: %v body=%s", err, string(body)) } ids := make([]T, 0, len(resp.Data)) for _, item := range resp.Data { switch v := item["id"].(type) { case float64: ids = append(ids, T(v)) case json.Number: n, _ := v.Int64() ids = append(ids, T(n)) } } return ids } func TestCallerIsAdmin(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string setup func(c *gin.Context) want bool }{ {name: "unauth", setup: func(c *gin.Context) {}, want: false}, { name: "member", setup: func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Role: model.RoleMember}) }, want: false, }, { name: "admin", setup: func(c *gin.Context) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Role: model.RoleAdmin}) }, want: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) tc.setup(ctx) if got := callerIsAdmin(ctx); got != tc.want { t.Fatalf("callerIsAdmin = %v, want %v", got, tc.want) } }) } } func TestAssertOwnsNotificationGroup(t *testing.T) { memberCtx := newMemberValidationContext(t) assert.NoError(t, assertOwnsNotificationGroup(memberCtx, 0)) assert.NoError(t, assertOwnsNotificationGroup(memberCtx, 8)) assert.Error(t, assertOwnsNotificationGroup(memberCtx, 7)) assert.ErrorContains(t, assertOwnsNotificationGroup(memberCtx, 9999), "does not exist") adminCtx := newAdminValidationContext(t) assert.NoError(t, assertOwnsNotificationGroup(adminCtx, 7)) assert.NoError(t, assertOwnsNotificationGroup(adminCtx, 8)) } func TestListServerGroupFiltersByOwnership(t *testing.T) { ctx := newMemberValidationContext(t) assert.NoError(t, singleton.DB.Create(&model.ServerGroup{Common: model.Common{ID: 1, UserID: 200}, Name: "member group"}).Error) assert.NoError(t, singleton.DB.Create(&model.ServerGroup{Common: model.Common{ID: 2, UserID: 1}, Name: "admin group"}).Error) got, err := listServerGroup(ctx) assert.NoError(t, err) var names []string for _, g := range got { names = append(names, g.Group.Name) } assert.ElementsMatch(t, []string{"member group"}, names) } func TestListServerGroupAdminSeesAll(t *testing.T) { ctx := newAdminValidationContext(t) assert.NoError(t, singleton.DB.Create(&model.ServerGroup{Common: model.Common{ID: 1, UserID: 200}, Name: "member group"}).Error) assert.NoError(t, singleton.DB.Create(&model.ServerGroup{Common: model.Common{ID: 2, UserID: 1}, Name: "admin group"}).Error) got, err := listServerGroup(ctx) assert.NoError(t, err) var names []string for _, g := range got { names = append(names, g.Group.Name) } assert.ElementsMatch(t, []string{"member group", "admin group"}, names) } func TestListNotificationGroupFiltersByOwnership(t *testing.T) { ctx := newMemberValidationContext(t) got, err := listNotificationGroup(ctx) assert.NoError(t, err) var names []string for _, g := range got { names = append(names, g.Group.Name) } assert.ElementsMatch(t, []string{"member group"}, names) } func TestListNotificationGroupAdminSeesAll(t *testing.T) { ctx := newAdminValidationContext(t) got, err := listNotificationGroup(ctx) assert.NoError(t, err) var names []string for _, g := range got { names = append(names, g.Group.Name) } assert.ElementsMatch(t, []string{"admin group", "member group"}, names) } func TestBatchMoveServerRejectsNonAdminCrossUser(t *testing.T) { ctx := newMemberValidationContext(t) ctx.Request = httptest.NewRequest(http.MethodPost, "/batch-move/server", strings.NewReader(`{"ids":[],"to_user":1}`)) ctx.Request.Header.Set("Content-Type", "application/json") _, err := batchMoveServer(ctx) assert.Error(t, err) } func TestBatchMoveServerAllowsMemberSelfMove(t *testing.T) { ctx := newMemberValidationContext(t) ctx.Request = httptest.NewRequest(http.MethodPost, "/batch-move/server", strings.NewReader(`{"ids":[],"to_user":200}`)) ctx.Request.Header.Set("Content-Type", "application/json") _, err := batchMoveServer(ctx) assert.NoError(t, err) } func TestBatchMoveServerAllowsAdminCrossUser(t *testing.T) { ctx := newAdminValidationContext(t) ctx.Request = httptest.NewRequest(http.MethodPost, "/batch-move/server", strings.NewReader(`{"ids":[],"to_user":200}`)) ctx.Request.Header.Set("Content-Type", "application/json") _, err := batchMoveServer(ctx) assert.NoError(t, err) } func TestNATRejectsUnknownServerID(t *testing.T) { ctx := newMemberValidationContext(t) ctx.Request = httptest.NewRequest(http.MethodPost, "/nat", strings.NewReader(`{"name":"x","domain":"x.example","host":"127.0.0.1:80","server_id":9999}`)) ctx.Request.Header.Set("Content-Type", "application/json") _, err := createNAT(ctx) assert.Error(t, err) }