auth_service_invitation_race_test.go 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "errors"
  6. "fmt"
  7. "sync"
  8. "testing"
  9. "time"
  10. "github.com/stretchr/testify/require"
  11. )
  12. // raceSafeUserRepo 是仅覆盖注册路径的并发安全用户仓储桩。
  13. // 未用到的方法走嵌入接口(调用即 panic,注册路径不会触发)。
  14. type raceSafeUserRepo struct {
  15. UserRepository
  16. mu sync.Mutex
  17. nextID int64
  18. byEmail map[string]*User
  19. byID map[int64]*User
  20. }
  21. func newRaceSafeUserRepo() *raceSafeUserRepo {
  22. return &raceSafeUserRepo{nextID: 1, byEmail: map[string]*User{}, byID: map[int64]*User{}}
  23. }
  24. func (s *raceSafeUserRepo) ExistsByEmail(_ context.Context, email string) (bool, error) {
  25. s.mu.Lock()
  26. defer s.mu.Unlock()
  27. _, ok := s.byEmail[email]
  28. return ok, nil
  29. }
  30. func (s *raceSafeUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
  31. return s.ExistsByEmail(ctx, email)
  32. }
  33. func (s *raceSafeUserRepo) CreateWithEmailAliasGuard(_ context.Context, user *User) error {
  34. s.mu.Lock()
  35. defer s.mu.Unlock()
  36. if _, ok := s.byEmail[user.Email]; ok {
  37. return ErrEmailExists
  38. }
  39. user.ID = s.nextID
  40. s.nextID++
  41. clone := *user
  42. s.byEmail[user.Email] = &clone
  43. s.byID[user.ID] = &clone
  44. return nil
  45. }
  46. func (s *raceSafeUserRepo) GetByEmail(_ context.Context, email string) (*User, error) {
  47. s.mu.Lock()
  48. defer s.mu.Unlock()
  49. u, ok := s.byEmail[email]
  50. if !ok {
  51. return nil, ErrUserNotFound
  52. }
  53. clone := *u
  54. return &clone, nil
  55. }
  56. func (s *raceSafeUserRepo) GetByID(_ context.Context, id int64) (*User, error) {
  57. s.mu.Lock()
  58. defer s.mu.Unlock()
  59. u, ok := s.byID[id]
  60. if !ok {
  61. return nil, ErrUserNotFound
  62. }
  63. clone := *u
  64. return &clone, nil
  65. }
  66. func (s *raceSafeUserRepo) Update(context.Context, *User, UserUpdateFields) error {
  67. return nil
  68. }
  69. // raceSafeRedeemRepo 是并发安全的兑换码仓储桩:Use 以互斥锁 + 状态条件
  70. // 模拟数据库的条件更新(WHERE status='unused'),语义与线上实现一致。
  71. type raceSafeRedeemRepo struct {
  72. RedeemCodeRepository
  73. mu sync.Mutex
  74. codes map[string]*RedeemCode
  75. }
  76. func (s *raceSafeRedeemRepo) GetByCode(_ context.Context, code string) (*RedeemCode, error) {
  77. s.mu.Lock()
  78. defer s.mu.Unlock()
  79. c, ok := s.codes[code]
  80. if !ok {
  81. return nil, ErrRedeemCodeNotFound
  82. }
  83. clone := *c
  84. return &clone, nil
  85. }
  86. func (s *raceSafeRedeemRepo) Use(_ context.Context, id, userID int64) error {
  87. s.mu.Lock()
  88. defer s.mu.Unlock()
  89. for _, c := range s.codes {
  90. if c.ID != id {
  91. continue
  92. }
  93. if c.Status != StatusUnused {
  94. return ErrRedeemCodeUsed
  95. }
  96. now := time.Now().UTC()
  97. c.Status = StatusUsed
  98. c.UsedBy = &userID
  99. c.UsedAt = &now
  100. return nil
  101. }
  102. return ErrRedeemCodeNotFound
  103. }
  104. // TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency 回归测试:
  105. // 同一邀请码并发注册必须恰好成功 1 次,其余请求以 INVITATION_CODE_INVALID 拒绝。
  106. //
  107. // 修复前:邀请码“检查(CanUse) 与 标记已用(Use)”分离且不在同一事务,Use 失败被吞,
  108. // 并发请求全部注册成功(一个邀请码可创建任意数量账号)。此测试在该实现下必然失败。
  109. // 修复后:用户创建与邀请码占用在同一事务内原子完成(或退化路径下由 Use 条件更新
  110. // 兜底),并发下仅最先占码的注册成功。
  111. func TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency(t *testing.T) {
  112. const code = "INV-RACE-001"
  113. userRepo := newRaceSafeUserRepo()
  114. redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{
  115. code: {ID: 1, Code: code, Type: RedeemTypeInvitation, Status: StatusUnused},
  116. }}
  117. settings := map[string]string{
  118. "registration_enabled": "true",
  119. "invitation_code_enabled": "true",
  120. }
  121. svc := newOAuthEmailFlowAuthService(
  122. userRepo,
  123. redeemRepo,
  124. &refreshTokenCacheStub{},
  125. settings,
  126. nil, // emailCache:注册不要求邮箱验证,保持关闭
  127. &userPlatformQuotaRepoStub{},
  128. )
  129. const n = 8
  130. ctx := context.Background()
  131. start := make(chan struct{})
  132. results := make(chan error, n)
  133. var wg sync.WaitGroup
  134. for i := 0; i < n; i++ {
  135. wg.Add(1)
  136. go func(i int) {
  137. defer wg.Done()
  138. <-start
  139. email := fmt.Sprintf("race-%d@example.com", i)
  140. _, _, err := svc.RegisterWithVerification(ctx, email, "Password123!", "", "", code, "")
  141. results <- err
  142. }(i)
  143. }
  144. close(start)
  145. wg.Wait()
  146. close(results)
  147. successes := 0
  148. rejected := 0
  149. for err := range results {
  150. switch {
  151. case err == nil:
  152. successes++
  153. case errors.Is(err, ErrInvitationCodeInvalid):
  154. rejected++
  155. default:
  156. t.Fatalf("unexpected registration error: %v", err)
  157. }
  158. }
  159. require.Equal(t, 1, successes, "同一邀请码并发注册必须恰好成功 1 次")
  160. require.Equal(t, n-1, rejected, "其余并发请求必须以 INVITATION_CODE_INVALID 拒绝")
  161. claimed, err := redeemRepo.GetByCode(ctx, code)
  162. require.NoError(t, err)
  163. require.Equal(t, StatusUsed, claimed.Status, "邀请码最终必须处于 used 状态")
  164. require.NotNil(t, claimed.UsedBy, "used_by 必须记录实际注册用户")
  165. }
  166. // TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed 顺序路径回归:
  167. // 已使用过的邀请码再次注册(即便换邮箱)必须被拒绝。
  168. func TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed(t *testing.T) {
  169. const code = "INV-RACE-002"
  170. userRepo := newRaceSafeUserRepo()
  171. redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{
  172. code: {ID: 2, Code: code, Type: RedeemTypeInvitation, Status: StatusUsed},
  173. }}
  174. settings := map[string]string{
  175. "registration_enabled": "true",
  176. "invitation_code_enabled": "true",
  177. }
  178. svc := newOAuthEmailFlowAuthService(
  179. userRepo,
  180. redeemRepo,
  181. &refreshTokenCacheStub{},
  182. settings,
  183. nil,
  184. &userPlatformQuotaRepoStub{},
  185. )
  186. _, _, err := svc.RegisterWithVerification(context.Background(), "later@example.com", "Password123!", "", "", code, "")
  187. require.ErrorIs(t, err, ErrInvitationCodeInvalid)
  188. }
  189. // TestAuthService_Register_InvitationCodeMissingWhenEnabled 门控回归:
  190. // 邀请码开启时,不带邀请码的注册必须被拒绝(不产生用户)。
  191. func TestAuthService_Register_InvitationCodeMissingWhenEnabled(t *testing.T) {
  192. userRepo := newRaceSafeUserRepo()
  193. redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{}}
  194. settings := map[string]string{
  195. "registration_enabled": "true",
  196. "invitation_code_enabled": "true",
  197. }
  198. svc := newOAuthEmailFlowAuthService(
  199. userRepo,
  200. redeemRepo,
  201. &refreshTokenCacheStub{},
  202. settings,
  203. nil,
  204. &userPlatformQuotaRepoStub{},
  205. )
  206. _, _, err := svc.RegisterWithVerification(context.Background(), "no-invite@example.com", "Password123!", "", "", "", "")
  207. require.ErrorIs(t, err, ErrInvitationCodeRequired)
  208. ok, err := userRepo.ExistsByEmail(context.Background(), "no-invite@example.com")
  209. require.NoError(t, err)
  210. require.False(t, ok, "被拒绝的注册不应产生用户")
  211. }