| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149 |
- //go:build protectionintegration
- package repository
- import (
- "context"
- "database/sql"
- "testing"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- dbuser "github.com/Wei-Shaw/sub2api/ent/user"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/stretchr/testify/require"
- )
- func hierarchyTestDatabase(t *testing.T) (*dbent.Client, *sql.DB, *userRepository) {
- t.Helper()
- client, db := protectionTestDatabase(t)
- _, err := db.Exec(`CREATE TABLE IF NOT EXISTS user_allowed_groups(user_id BIGINT, group_id BIGINT,UNIQUE(user_id,group_id));
- CREATE TABLE IF NOT EXISTS auth_identities(id BIGSERIAL PRIMARY KEY,user_id BIGINT,provider_type TEXT,provider_key TEXT,provider_subject TEXT,metadata JSONB,issuer TEXT,verified_at TIMESTAMPTZ,created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW(),UNIQUE(provider_type,provider_key,provider_subject));
- CREATE TABLE IF NOT EXISTS auth_identity_channels(id BIGSERIAL PRIMARY KEY,identity_id BIGINT,provider_type TEXT,provider_key TEXT,channel TEXT,channel_app_id TEXT,channel_subject TEXT,metadata JSONB,created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW(),UNIQUE(provider_type,provider_key,channel,channel_app_id,channel_subject));
- CREATE TABLE IF NOT EXISTS identity_adoption_decisions(id BIGSERIAL PRIMARY KEY,identity_id BIGINT);`)
- require.NoError(t, err)
- return client, db, newUserRepositoryWithSQL(client, db)
- }
- func createHierarchyUser(t *testing.T, client *dbent.Client, email, role string) *dbent.User {
- t.Helper()
- user, err := client.User.Create().SetEmail(email).SetPasswordHash("preserve-test-hash").SetRole(role).SetStatus(service.StatusActive).SetBalance(12.5).Save(context.Background())
- require.NoError(t, err)
- return user
- }
- func waitHierarchyLockWaiters(t *testing.T, db *sql.DB, count int) {
- t.Helper()
- key := uint64(advisoryLockHash(userHierarchyLockKey))
- require.Eventually(t, func() bool {
- var waiting int
- err := db.QueryRow(`SELECT COUNT(*) FROM pg_locks WHERE locktype='advisory' AND classid=$1 AND objid=$2 AND NOT granted`, int64(key>>32), int64(key&0xffffffff)).Scan(&waiting)
- return err == nil && waiting >= count
- }, 10*time.Second, 10*time.Millisecond, "both role mutations must reach the database lock before it is released")
- }
- func TestUserHierarchyConcurrentMutualDemotionKeepsAdministrator(t *testing.T) {
- client, db, repo := hierarchyTestDatabase(t)
- a := createHierarchyUser(t, client, "a@example.invalid", service.RoleSuperAdmin)
- b := createHierarchyUser(t, client, "b@example.invalid", service.RoleSuperAdmin)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- gate, err := db.BeginTx(ctx, nil)
- require.NoError(t, err)
- defer gate.Rollback()
- _, err = gate.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryLockHash(userHierarchyLockKey))
- require.NoError(t, err)
- results := make(chan error, 2)
- for _, operation := range []struct{ actor, target int64 }{{a.ID, b.ID}, {b.ID, a.ID}} {
- go func(actor, target int64) {
- results <- repo.Update(service.WithUserManagementActor(ctx, actor), &service.User{ID: target, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true})
- }(operation.actor, operation.target)
- }
- waitHierarchyLockWaiters(t, db, 2)
- require.NoError(t, gate.Commit())
- success := 0
- for range 2 {
- err := <-results
- if err == nil {
- success++
- } else {
- require.Equal(t, 403, infraerrors.Code(err), err)
- }
- }
- require.Equal(t, 1, success, "the late mutation must re-read its now-demoted actor")
- remaining, err := client.User.Query().Where(dbuser.RoleEQ(service.RoleSuperAdmin), dbuser.StatusEQ(service.StatusActive)).Count(ctx)
- require.NoError(t, err)
- require.Equal(t, 1, remaining)
- for _, id := range []int64{a.ID, b.ID} {
- current, err := client.User.Get(ctx, id)
- require.NoError(t, err)
- require.Equal(t, 12.5, current.Balance)
- require.Equal(t, "preserve-test-hash", current.PasswordHash)
- }
- }
- func TestUserHierarchyOuterTransactionRetainsLockAndRollback(t *testing.T) {
- client, db, repo := hierarchyTestDatabase(t)
- a := createHierarchyUser(t, client, "owner@example.invalid", service.RoleSuperAdmin)
- b := createHierarchyUser(t, client, "peer@example.invalid", service.RoleSuperAdmin)
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
- outer, err := client.Tx(ctx)
- require.NoError(t, err)
- defer outer.Rollback()
- txCtx := service.WithUserManagementActor(dbent.NewTxContext(ctx, outer), a.ID)
- require.NoError(t, repo.Update(txCtx, &service.User{ID: b.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true}))
- outside, err := client.User.Get(ctx, b.ID)
- require.NoError(t, err)
- require.Equal(t, service.RoleSuperAdmin, outside.Role, "Update must not commit its caller's transaction")
- done := make(chan error, 1)
- go func() {
- done <- repo.Update(service.WithUserManagementActor(ctx, b.ID), &service.User{ID: a.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true})
- }()
- waitHierarchyLockWaiters(t, db, 1)
- require.NoError(t, outer.Rollback())
- require.NoError(t, <-done, "rollback keeps the actor's original super-admin role")
- current, err := client.User.Get(ctx, b.ID)
- require.NoError(t, err)
- require.Equal(t, service.RoleSuperAdmin, current.Role)
- }
- func TestUserHierarchyFinalWriteChecksActorAndProtectsAdminState(t *testing.T) {
- client, _, repo := hierarchyTestDatabase(t)
- super := createHierarchyUser(t, client, "super@example.invalid", service.RoleSuperAdmin)
- admin := createHierarchyUser(t, client, "admin@example.invalid", service.RoleAdmin)
- user := createHierarchyUser(t, client, "user@example.invalid", service.RoleUser)
- ctx := context.Background()
- adminCtx := service.WithUserManagementActor(ctx, admin.ID)
- require.NoError(t, repo.Update(adminCtx, &service.User{ID: user.ID, Role: service.RoleUser, Notes: "normal edit"}, service.UserUpdateFields{Role: true, Notes: true}))
- err := repo.Update(adminCtx, &service.User{ID: super.ID, PasswordHash: "changed"}, service.UserUpdateFields{PasswordHash: true})
- require.Equal(t, 403, infraerrors.Code(err))
- err = repo.Update(service.WithUserManagementActor(ctx, super.ID), &service.User{ID: super.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true})
- require.Equal(t, "SELF_DEMOTION_FORBIDDEN", infraerrors.Reason(err))
- err = repo.Update(ctx, &service.User{ID: super.ID, Status: service.StatusDisabled}, service.UserUpdateFields{Status: true})
- require.Equal(t, "ADMIN_PROTECTED", infraerrors.Reason(err))
- err = repo.Delete(ctx, super.ID)
- require.Equal(t, "ADMIN_PROTECTED", infraerrors.Reason(err))
- err = repo.Create(adminCtx, &service.User{Email: "blocked@example.invalid", Role: service.RoleSuperAdmin, Status: service.StatusActive})
- require.Equal(t, 403, infraerrors.Code(err))
- // A role grant that was authorized before a concurrent demotion must not
- // apply with the actor's old permissions once it reaches the repository.
- newSuper := createHierarchyUser(t, client, "second@example.invalid", service.RoleSuperAdmin)
- staleActorCtx := service.WithUserManagementActor(ctx, newSuper.ID)
- require.NoError(t, repo.Update(service.WithUserManagementActor(ctx, super.ID), &service.User{ID: newSuper.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true}))
- err = repo.Update(staleActorCtx, &service.User{ID: user.ID, Role: service.RoleSuperAdmin}, service.UserUpdateFields{Role: true})
- require.Equal(t, 403, infraerrors.Code(err))
- err = repo.Create(staleActorCtx, &service.User{Email: "stale-grant@example.invalid", Role: service.RoleSuperAdmin, Status: service.StatusActive})
- require.Equal(t, 403, infraerrors.Code(err))
- // The same stale request context must not retain write authority after its
- // actor is demoted all the way to an ordinary user.
- require.NoError(t, repo.Update(service.WithUserManagementActor(ctx, super.ID), &service.User{ID: newSuper.ID, Role: service.RoleUser}, service.UserUpdateFields{Role: true}))
- err = repo.Delete(staleActorCtx, user.ID)
- require.Equal(t, 403, infraerrors.Code(err))
- current, err := client.User.Get(ctx, user.ID)
- require.NoError(t, err)
- require.Equal(t, service.RoleUser, current.Role)
- require.Equal(t, "normal edit", current.Notes)
- }
|