package controller import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "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 TestShowServiceFiltersCycleTransferStatsLikeServerList(t *testing.T) { newMemberValidationContext(t) assert.NoError(t, singleton.DB.AutoMigrate(&model.Service{}, &model.ServiceHistory{})) assert.NoError(t, singleton.DB.Create(&model.Server{Common: model.Common{ID: 1, UserID: 1}, Name: "public server", UUID: "public-server"}).Error) assert.NoError(t, singleton.DB.Create(&model.Server{Common: model.Common{ID: 2, UserID: 1}, Name: "hidden admin server", UUID: "hidden-admin-server", HideForGuest: true}).Error) assert.NoError(t, singleton.DB.Create(&model.Server{Common: model.Common{ID: 3, UserID: 200}, Name: "hidden member server", UUID: "hidden-member-server", HideForGuest: true}).Error) singleton.ServerShared = singleton.NewServerClass() assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 10, UserID: 1}, Name: "shown service", EnableShowInService: true}).Error) assert.NoError(t, singleton.DB.Create(&model.Service{Common: model.Common{ID: 11, UserID: 1}, Name: "hidden service"}).Error) originalServiceSentinel := singleton.ServiceSentinelShared serviceSentinel, err := singleton.NewServiceSentinel(make(chan *model.Service, 2)) assert.NoError(t, err) singleton.ServiceSentinelShared = serviceSentinel t.Cleanup(func() { singleton.ServiceSentinelShared = originalServiceSentinel }) singleton.AlertsLock.Lock() originalCycleTransferStats := singleton.AlertsCycleTransferStatsStore singleton.AlertsCycleTransferStatsStore = map[uint64]*model.CycleTransferStats{ 7: { Name: "transfer alert", ServerName: map[uint64]string{1: "public server", 2: "hidden admin server", 3: "hidden member server"}, Transfer: map[uint64]uint64{1: 100, 2: 200, 3: 300}, NextUpdate: map[uint64]time.Time{1: time.Unix(1, 0), 2: time.Unix(2, 0), 3: time.Unix(3, 0)}, }, } singleton.AlertsLock.Unlock() t.Cleanup(func() { singleton.AlertsLock.Lock() singleton.AlertsCycleTransferStatsStore = originalCycleTransferStats singleton.AlertsLock.Unlock() }) tests := []struct { name string viewer *model.User wantNames map[uint64]string }{ { name: "guest sees public servers only", wantNames: map[uint64]string{1: "public server"}, }, { name: "member sees public and owned hidden servers", viewer: &model.User{Common: model.Common{ID: 200}, Role: model.RoleMember}, wantNames: map[uint64]string{1: "public server", 3: "hidden member server"}, }, { name: "admin sees every server", viewer: &model.User{Common: model.Common{ID: 1}, Role: model.RoleAdmin}, wantNames: map[uint64]string{1: "public server", 2: "hidden admin server", 3: "hidden member server"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) if tc.viewer != nil { ctx.Set(model.CtxKeyAuthorizedUser, tc.viewer) } got, err := showService(ctx) assert.NoError(t, err) assert.Contains(t, got.Services, uint64(10)) assert.NotContains(t, got.Services, uint64(11)) if assert.Contains(t, got.CycleTransferStats, uint64(7)) { cycleStats := got.CycleTransferStats[7] assert.Equal(t, tc.wantNames, cycleStats.ServerName) assert.Len(t, cycleStats.Transfer, len(tc.wantNames)) assert.Len(t, cycleStats.NextUpdate, len(tc.wantNames)) for serverID := range cycleStats.Transfer { assert.Contains(t, tc.wantNames, serverID) } for serverID := range cycleStats.NextUpdate { assert.Contains(t, tc.wantNames, serverID) } } }) } } 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) }