package externaladmin import ( "encoding/json" "errors" "net/http" "net/http/httptest" "reflect" "strings" "testing" "hyapp-admin-server/internal/appctx" adminmiddleware "hyapp-admin-server/internal/middleware" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" ) func TestPermissionCatalogMatchesTwentyProductCapabilities(t *testing.T) { items := PermissionCatalog() want := []string{ "user:list", "user:update", "user-ban:list", "user:ban", "user:unban", "host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view", "room:list", "room:update", "privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "banner:create", } if len(items) != len(want) { t.Fatalf("catalog count = %d, want %d", len(items), len(want)) } for index, item := range items { if item.Code != want[index] || item.Label == "" || item.Group == "" || !item.DefaultGranted { t.Fatalf("catalog[%d] = %+v, want code %s with display metadata/default", index, item, want[index]) } if !reflect.DeepEqual(item.Capabilities, []string{item.Code}) { t.Fatalf("catalog capability must be independently addressable: %+v", item) } } } func TestPermissionSelectionAllowlistDependenciesAndNormalization(t *testing.T) { if got, err := validatePermissionSelection(nil); err != nil || len(got) != 0 { t.Fatalf("explicit empty permissions must be valid: got=%v err=%v", got, err) } all := append(DefaultPermissionSnapshot(), " user:list ", "user:list") got, err := validatePermissionSelection(all) if err != nil { t.Fatalf("default permission validation: %v", err) } if !reflect.DeepEqual(got, DefaultPermissionSnapshot()) { t.Fatalf("permissions not deduplicated/sorted: got=%v", got) } for _, invalid := range [][]string{ {"unknown:permission"}, {"*"}, {"external-admin:*"}, {"privilege:grant"}, {"user-title:grant", "room-background:grant"}, } { if _, err := validatePermissionSelection(invalid); !errors.Is(err, ErrInvalidPermissions) { t.Fatalf("invalid selection %v error = %v", invalid, err) } } } func TestPermissionValidationMessagesUseCatalogLabelsWithoutReflectingCodes(t *testing.T) { _, dependencyErr := validatePermissionSelection([]string{"privilege:grant"}) dependencyMessage := permissionValidationMessage(dependencyErr) if !strings.Contains(dependencyMessage, "特权道具发放") || !strings.Contains(dependencyMessage, "用户称号下发") || strings.Contains(dependencyMessage, "privilege:grant") { t.Fatalf("dependency message leaked internal code: %q", dependencyMessage) } _, unknownErr := validatePermissionSelection([]string{"attacker:controlled"}) unknownMessage := permissionValidationMessage(unknownErr) if unknownMessage != "外管权限中包含不支持的权限" || strings.Contains(unknownMessage, "attacker") { t.Fatalf("unknown message reflected input: %q", unknownMessage) } } func TestEffectivePermissionsSafelyUpgradesLegacyAndRejectsCorruptSnapshots(t *testing.T) { legacy := []string{ "app-user:view", "app-user:update", "app-user:status", "app-user:level", "host:view", "agency:view", "bd:view", "room:view", "room:update", "resource:view", "resource-grant:view", "resource-grant:create", "pretty-id:view", "pretty-id:grant", "app-config:view", "app-config:update", "vip-config:grant", "upload:create", } if got := effectivePermissions(legacy); !reflect.DeepEqual(got, DefaultPermissionSnapshot()) { t.Fatalf("legacy default did not expand to full 20: got=%v want=%v", got, DefaultPermissionSnapshot()) } got := effectivePermissions([]string{"user:list", "privilege:grant", "unknown:permission", "*", "external-admin:*"}) if !reflect.DeepEqual(got, []string{"user:list"}) { t.Fatalf("corrupt new snapshot must remove incomplete bundle/unknowns: %v", got) } got = effectivePermissions([]string{"app-config:view", "app-config:update", "app-user:level", "resource:view"}) if len(got) != 0 { t.Fatalf("incomplete legacy closures must fail closed: %v", got) } } func TestCreatePermissionFieldDistinguishesOmittedEmptyAndNull(t *testing.T) { var omitted createAccountRequest if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password"}`), &omitted); err != nil { t.Fatalf("decode omitted: %v", err) } if omitted.Permissions.Set { t.Fatal("omitted permissions must keep legacy default") } var empty createAccountRequest if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password","permissions":[]}`), &empty); err != nil { t.Fatalf("decode empty: %v", err) } if !empty.Permissions.Set || len(empty.Permissions.Value) != 0 { t.Fatalf("explicit [] was not preserved: %+v", empty.Permissions) } var nullValue createAccountRequest if err := json.Unmarshal([]byte(`{"targetUserId":"1","username":"operator","password":"password","permissions":null}`), &nullValue); err == nil { t.Fatal("permissions:null must be rejected as ambiguous") } } func TestCreatePermissionSnapshotKeepsOmittedAndExplicitEmptyDistinct(t *testing.T) { omitted, err := createPermissionSnapshot(CreateInput{}) if err != nil || !reflect.DeepEqual(omitted, DefaultPermissionSnapshot()) { t.Fatalf("omitted snapshot=%v err=%v", omitted, err) } empty, err := createPermissionSnapshot(CreateInput{PermissionsSet: true, Permissions: []string{}}) if err != nil || len(empty) != 0 { t.Fatalf("explicit empty snapshot=%v err=%v", empty, err) } if _, err := createPermissionSnapshot(CreateInput{PermissionsSet: true, Permissions: []string{"*"}}); !errors.Is(err, ErrInvalidPermissions) { t.Fatalf("explicit wildcard error=%v", err) } } func TestCreateAccountRequiresCredentialAndPermissionDelegationCapabilities(t *testing.T) { gin.SetMode(gin.TestMode) tests := []struct { name string permissions []string wantStatus int }{ {name: "create alone cannot delegate default full snapshot", permissions: []string{"external-admin-user:create"}, wantStatus: http.StatusForbidden}, {name: "delegation alone cannot create credential", permissions: []string{"external-admin-user:permissions"}, wantStatus: http.StatusForbidden}, {name: "both capabilities reach request validation", permissions: []string{"external-admin-user:create", "external-admin-user:permissions"}, wantStatus: http.StatusBadRequest}, } for _, testCase := range tests { t.Run(testCase.name, func(t *testing.T) { engine := gin.New() engine.Use(func(c *gin.Context) { c.Set(adminmiddleware.ContextPermissions, testCase.permissions) c.Next() }) RegisterAdminRoutes(engine.Group(""), &Handler{service: NewService(nil, nil, Config{})}) request := httptest.NewRequest(http.MethodPost, "/admin/external-admin-users", strings.NewReader(`{}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) if recorder.Code != testCase.wantStatus { t.Fatalf("permissions=%v status=%d body=%s", testCase.permissions, recorder.Code, recorder.Body.String()) } }) } // Handler-level defense must also reject omitted permissions when a caller // bypasses the standard route middleware during a future refactor. engine := gin.New() engine.POST("/direct", (&Handler{service: NewService(nil, nil, Config{})}).CreateAccount) recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/direct", strings.NewReader(`{"targetUserId":"1","username":"operator","password":"password1"}`)) request.Header.Set("Content-Type", "application/json") engine.ServeHTTP(recorder, request) if recorder.Code != http.StatusForbidden { t.Fatalf("direct omitted-permission create status=%d body=%s", recorder.Code, recorder.Body.String()) } } func TestGetAccountPermissionsIsScopedToAppAndReturnsRevision(t *testing.T) { service, adminMock, userMock, cleanup := newAccountMutationFixture(t) defer cleanup() adminMock.ExpectQuery("SELECT `id`,`permissions_json`,`permission_revision` FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\? LIMIT \\?"). WithArgs(uint64(7), "fami", 1). WillReturnRows(sqlmock.NewRows([]string{"id", "permissions_json", "permission_revision"}).AddRow(7, `["user:update","user:list"]`, 9)) view, err := service.GetAccountPermissions(t.Context(), " FAMI ", 7) if err != nil { t.Fatalf("get permissions: %v", err) } if view.AccountID != 7 || view.Revision != 9 || !reflect.DeepEqual(view.Permissions, []string{"user:list", "user:update"}) { t.Fatalf("permission view=%+v", view) } assertAccountMutationExpectations(t, adminMock, userMock) } func TestUpdatePermissionsUsesRevisionAndRevokesSessionsAtomically(t *testing.T) { service, adminMock, userMock, cleanup := newAccountMutationFixture(t) defer cleanup() adminMock.ExpectBegin() adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(4, `["user:list"]`)) adminMock.ExpectExec("UPDATE `external_admin_accounts` SET .*permission_revision.*permissions_json.* WHERE `id` = \\?"). WillReturnResult(sqlmock.NewResult(0, 1)) adminMock.ExpectExec("UPDATE `external_admin_sessions` SET .*revoke_reason.*revoked_at_ms.* WHERE account_id = \\? AND revoked_at_ms = 0"). WithArgs("permissions_changed", sqlmock.AnyArg(), uint64(7)).WillReturnResult(sqlmock.NewResult(0, 2)) adminMock.ExpectCommit() adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(5, `["user:list","user:update"]`)) expectPermissionLinkedUser(userMock) result, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{ Permissions: []string{"user:update", "user:list", "user:update"}, ExpectedRevision: 4, }) if err != nil { t.Fatalf("update permissions: %v", err) } if !result.Changed || !result.SessionsRevoked || result.Account.PermissionRevision != 5 || !reflect.DeepEqual(result.PreviousPermissions, []string{"user:list"}) { t.Fatalf("unexpected update result: %+v", result) } assertAccountMutationExpectations(t, adminMock, userMock) } func TestUpdatePermissionsRejectsStaleRevisionWithoutWriting(t *testing.T) { service, adminMock, userMock, cleanup := newAccountMutationFixture(t) defer cleanup() adminMock.ExpectBegin() adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(5, `["user:list"]`)) adminMock.ExpectRollback() _, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{ Permissions: []string{"user:list", "user:update"}, ExpectedRevision: 4, }) if !errors.Is(err, ErrPermissionConflict) { t.Fatalf("stale revision error = %v", err) } assertAccountMutationExpectations(t, adminMock, userMock) } func TestUpdatePermissionsNoopIsIdempotentWithoutRevisionOrSessionChange(t *testing.T) { service, adminMock, userMock, cleanup := newAccountMutationFixture(t) defer cleanup() adminMock.ExpectBegin() adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(6, `["user:list"]`)) adminMock.ExpectCommit() adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(6, `["user:list"]`)) expectPermissionLinkedUser(userMock) result, err := service.UpdateAccountPermissions(t.Context(), "fami", 7, UpdatePermissionsInput{ Permissions: []string{" user:list ", "user:list"}, ExpectedRevision: 6, }) if err != nil || result.Changed || result.SessionsRevoked || result.Account.PermissionRevision != 6 { t.Fatalf("noop result=%+v err=%v", result, err) } assertAccountMutationExpectations(t, adminMock, userMock) } func TestUpdatePermissionsHandlerMapsRevisionConflictTo409(t *testing.T) { service, adminMock, userMock, cleanup := newAccountMutationFixture(t) defer cleanup() adminMock.ExpectBegin() adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\?"). WithArgs(uint64(7), "fami", 1).WillReturnRows(permissionAccountRows(3, `["user:list"]`)) adminMock.ExpectRollback() gin.SetMode(gin.TestMode) engine := gin.New() engine.PUT("/accounts/:id", func(c *gin.Context) { c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami")) c.Next() }, (&Handler{service: service}).UpdateAccountPermissions) request := httptest.NewRequest(http.MethodPut, "/accounts/7", strings.NewReader(`{"permissions":["user:list"],"expectedRevision":2}`)) request.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) if recorder.Code != http.StatusConflict { t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) } assertAccountMutationExpectations(t, adminMock, userMock) } func permissionAccountRows(revision uint64, permissions string) *sqlmock.Rows { return sqlmock.NewRows([]string{ "id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "permission_revision", "status", "password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms", }).AddRow(7, "fami", 9001, "operator", "hash", permissions, revision, "active", false, 0, 0, 1, 1, 1) } func expectPermissionLinkedUser(userMock sqlmock.Sqlmock) { userMock.ExpectQuery("SELECT u.user_id,"). WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "fami", int64(9001)). WillReturnRows(sqlmock.NewRows([]string{ "user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status", }).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active")) }