user_hierarchy_database_test.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. //go:build protectionintegration
  2. package repository
  3. import (
  4. "context"
  5. "database/sql"
  6. "testing"
  7. "time"
  8. dbent "github.com/Wei-Shaw/sub2api/ent"
  9. dbuser "github.com/Wei-Shaw/sub2api/ent/user"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func hierarchyTestDatabase(t *testing.T) (*dbent.Client, *sql.DB, *userRepository) {
  15. t.Helper()
  16. client, db := protectionTestDatabase(t)
  17. _, err := db.Exec(`CREATE TABLE IF NOT EXISTS user_allowed_groups(user_id BIGINT, group_id BIGINT,UNIQUE(user_id,group_id));
  18. 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));
  19. 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));
  20. CREATE TABLE IF NOT EXISTS identity_adoption_decisions(id BIGSERIAL PRIMARY KEY,identity_id BIGINT);`)
  21. require.NoError(t, err)
  22. return client, db, newUserRepositoryWithSQL(client, db)
  23. }
  24. func createHierarchyUser(t *testing.T, client *dbent.Client, email, role string) *dbent.User {
  25. t.Helper()
  26. user, err := client.User.Create().SetEmail(email).SetPasswordHash("preserve-test-hash").SetRole(role).SetStatus(service.StatusActive).SetBalance(12.5).Save(context.Background())
  27. require.NoError(t, err)
  28. return user
  29. }
  30. func waitHierarchyLockWaiters(t *testing.T, db *sql.DB, count int) {
  31. t.Helper()
  32. key := uint64(advisoryLockHash(userHierarchyLockKey))
  33. require.Eventually(t, func() bool {
  34. var waiting int
  35. 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)
  36. return err == nil && waiting >= count
  37. }, 10*time.Second, 10*time.Millisecond, "both role mutations must reach the database lock before it is released")
  38. }
  39. func TestUserHierarchyConcurrentMutualDemotionKeepsAdministrator(t *testing.T) {
  40. client, db, repo := hierarchyTestDatabase(t)
  41. a := createHierarchyUser(t, client, "a@example.invalid", service.RoleSuperAdmin)
  42. b := createHierarchyUser(t, client, "b@example.invalid", service.RoleSuperAdmin)
  43. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  44. defer cancel()
  45. gate, err := db.BeginTx(ctx, nil)
  46. require.NoError(t, err)
  47. defer gate.Rollback()
  48. _, err = gate.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryLockHash(userHierarchyLockKey))
  49. require.NoError(t, err)
  50. results := make(chan error, 2)
  51. for _, operation := range []struct{ actor, target int64 }{{a.ID, b.ID}, {b.ID, a.ID}} {
  52. go func(actor, target int64) {
  53. results <- repo.Update(service.WithUserManagementActor(ctx, actor), &service.User{ID: target, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true})
  54. }(operation.actor, operation.target)
  55. }
  56. waitHierarchyLockWaiters(t, db, 2)
  57. require.NoError(t, gate.Commit())
  58. success := 0
  59. for range 2 {
  60. err := <-results
  61. if err == nil {
  62. success++
  63. } else {
  64. require.Equal(t, 403, infraerrors.Code(err), err)
  65. }
  66. }
  67. require.Equal(t, 1, success, "the late mutation must re-read its now-demoted actor")
  68. remaining, err := client.User.Query().Where(dbuser.RoleEQ(service.RoleSuperAdmin), dbuser.StatusEQ(service.StatusActive)).Count(ctx)
  69. require.NoError(t, err)
  70. require.Equal(t, 1, remaining)
  71. for _, id := range []int64{a.ID, b.ID} {
  72. current, err := client.User.Get(ctx, id)
  73. require.NoError(t, err)
  74. require.Equal(t, 12.5, current.Balance)
  75. require.Equal(t, "preserve-test-hash", current.PasswordHash)
  76. }
  77. }
  78. func TestUserHierarchyOuterTransactionRetainsLockAndRollback(t *testing.T) {
  79. client, db, repo := hierarchyTestDatabase(t)
  80. a := createHierarchyUser(t, client, "owner@example.invalid", service.RoleSuperAdmin)
  81. b := createHierarchyUser(t, client, "peer@example.invalid", service.RoleSuperAdmin)
  82. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  83. defer cancel()
  84. outer, err := client.Tx(ctx)
  85. require.NoError(t, err)
  86. defer outer.Rollback()
  87. txCtx := service.WithUserManagementActor(dbent.NewTxContext(ctx, outer), a.ID)
  88. require.NoError(t, repo.Update(txCtx, &service.User{ID: b.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true}))
  89. outside, err := client.User.Get(ctx, b.ID)
  90. require.NoError(t, err)
  91. require.Equal(t, service.RoleSuperAdmin, outside.Role, "Update must not commit its caller's transaction")
  92. done := make(chan error, 1)
  93. go func() {
  94. done <- repo.Update(service.WithUserManagementActor(ctx, b.ID), &service.User{ID: a.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true})
  95. }()
  96. waitHierarchyLockWaiters(t, db, 1)
  97. require.NoError(t, outer.Rollback())
  98. require.NoError(t, <-done, "rollback keeps the actor's original super-admin role")
  99. current, err := client.User.Get(ctx, b.ID)
  100. require.NoError(t, err)
  101. require.Equal(t, service.RoleSuperAdmin, current.Role)
  102. }
  103. func TestUserHierarchyFinalWriteChecksActorAndProtectsAdminState(t *testing.T) {
  104. client, _, repo := hierarchyTestDatabase(t)
  105. super := createHierarchyUser(t, client, "super@example.invalid", service.RoleSuperAdmin)
  106. admin := createHierarchyUser(t, client, "admin@example.invalid", service.RoleAdmin)
  107. user := createHierarchyUser(t, client, "user@example.invalid", service.RoleUser)
  108. ctx := context.Background()
  109. adminCtx := service.WithUserManagementActor(ctx, admin.ID)
  110. require.NoError(t, repo.Update(adminCtx, &service.User{ID: user.ID, Role: service.RoleUser, Notes: "normal edit"}, service.UserUpdateFields{Role: true, Notes: true}))
  111. err := repo.Update(adminCtx, &service.User{ID: super.ID, PasswordHash: "changed"}, service.UserUpdateFields{PasswordHash: true})
  112. require.Equal(t, 403, infraerrors.Code(err))
  113. err = repo.Update(service.WithUserManagementActor(ctx, super.ID), &service.User{ID: super.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true})
  114. require.Equal(t, "SELF_DEMOTION_FORBIDDEN", infraerrors.Reason(err))
  115. err = repo.Update(ctx, &service.User{ID: super.ID, Status: service.StatusDisabled}, service.UserUpdateFields{Status: true})
  116. require.Equal(t, "ADMIN_PROTECTED", infraerrors.Reason(err))
  117. err = repo.Delete(ctx, super.ID)
  118. require.Equal(t, "ADMIN_PROTECTED", infraerrors.Reason(err))
  119. err = repo.Create(adminCtx, &service.User{Email: "blocked@example.invalid", Role: service.RoleSuperAdmin, Status: service.StatusActive})
  120. require.Equal(t, 403, infraerrors.Code(err))
  121. // A role grant that was authorized before a concurrent demotion must not
  122. // apply with the actor's old permissions once it reaches the repository.
  123. newSuper := createHierarchyUser(t, client, "second@example.invalid", service.RoleSuperAdmin)
  124. staleActorCtx := service.WithUserManagementActor(ctx, newSuper.ID)
  125. require.NoError(t, repo.Update(service.WithUserManagementActor(ctx, super.ID), &service.User{ID: newSuper.ID, Role: service.RoleAdmin}, service.UserUpdateFields{Role: true}))
  126. err = repo.Update(staleActorCtx, &service.User{ID: user.ID, Role: service.RoleSuperAdmin}, service.UserUpdateFields{Role: true})
  127. require.Equal(t, 403, infraerrors.Code(err))
  128. err = repo.Create(staleActorCtx, &service.User{Email: "stale-grant@example.invalid", Role: service.RoleSuperAdmin, Status: service.StatusActive})
  129. require.Equal(t, 403, infraerrors.Code(err))
  130. // The same stale request context must not retain write authority after its
  131. // actor is demoted all the way to an ordinary user.
  132. require.NoError(t, repo.Update(service.WithUserManagementActor(ctx, super.ID), &service.User{ID: newSuper.ID, Role: service.RoleUser}, service.UserUpdateFields{Role: true}))
  133. err = repo.Delete(staleActorCtx, user.ID)
  134. require.Equal(t, 403, infraerrors.Code(err))
  135. current, err := client.User.Get(ctx, user.ID)
  136. require.NoError(t, err)
  137. require.Equal(t, service.RoleUser, current.Role)
  138. require.Equal(t, "normal edit", current.Notes)
  139. }