295 lines
14 KiB
Go

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"))
}