295 lines
14 KiB
Go
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"))
|
|
}
|