Files
sub2api/backend/internal/service/auth_service_invitation_race_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

237 lines
6.7 KiB
Go

//go:build unit
package service
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// raceSafeUserRepo 是仅覆盖注册路径的并发安全用户仓储桩。
// 未用到的方法走嵌入接口(调用即 panic,注册路径不会触发)。
type raceSafeUserRepo struct {
UserRepository
mu sync.Mutex
nextID int64
byEmail map[string]*User
byID map[int64]*User
}
func newRaceSafeUserRepo() *raceSafeUserRepo {
return &raceSafeUserRepo{nextID: 1, byEmail: map[string]*User{}, byID: map[int64]*User{}}
}
func (s *raceSafeUserRepo) ExistsByEmail(_ context.Context, email string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.byEmail[email]
return ok, nil
}
func (s *raceSafeUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
return s.ExistsByEmail(ctx, email)
}
func (s *raceSafeUserRepo) CreateWithEmailAliasGuard(_ context.Context, user *User) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byEmail[user.Email]; ok {
return ErrEmailExists
}
user.ID = s.nextID
s.nextID++
clone := *user
s.byEmail[user.Email] = &clone
s.byID[user.ID] = &clone
return nil
}
func (s *raceSafeUserRepo) GetByEmail(_ context.Context, email string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byEmail[email]
if !ok {
return nil, ErrUserNotFound
}
clone := *u
return &clone, nil
}
func (s *raceSafeUserRepo) GetByID(_ context.Context, id int64) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.byID[id]
if !ok {
return nil, ErrUserNotFound
}
clone := *u
return &clone, nil
}
func (s *raceSafeUserRepo) Update(context.Context, *User, UserUpdateFields) error {
return nil
}
// raceSafeRedeemRepo 是并发安全的兑换码仓储桩:Use 以互斥锁 + 状态条件
// 模拟数据库的条件更新(WHERE status='unused'),语义与线上实现一致。
type raceSafeRedeemRepo struct {
RedeemCodeRepository
mu sync.Mutex
codes map[string]*RedeemCode
}
func (s *raceSafeRedeemRepo) GetByCode(_ context.Context, code string) (*RedeemCode, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.codes[code]
if !ok {
return nil, ErrRedeemCodeNotFound
}
clone := *c
return &clone, nil
}
func (s *raceSafeRedeemRepo) Use(_ context.Context, id, userID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.codes {
if c.ID != id {
continue
}
if c.Status != StatusUnused {
return ErrRedeemCodeUsed
}
now := time.Now().UTC()
c.Status = StatusUsed
c.UsedBy = &userID
c.UsedAt = &now
return nil
}
return ErrRedeemCodeNotFound
}
// TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency 回归测试:
// 同一邀请码并发注册必须恰好成功 1 次,其余请求以 INVITATION_CODE_INVALID 拒绝。
//
// 修复前:邀请码“检查(CanUse) 与 标记已用(Use)”分离且不在同一事务,Use 失败被吞,
// 并发请求全部注册成功(一个邀请码可创建任意数量账号)。此测试在该实现下必然失败。
// 修复后:用户创建与邀请码占用在同一事务内原子完成(或退化路径下由 Use 条件更新
// 兜底),并发下仅最先占码的注册成功。
func TestAuthService_Register_InvitationCodeSingleUseUnderConcurrency(t *testing.T) {
const code = "INV-RACE-001"
userRepo := newRaceSafeUserRepo()
redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{
code: {ID: 1, Code: code, Type: RedeemTypeInvitation, Status: StatusUnused},
}}
settings := map[string]string{
"registration_enabled": "true",
"invitation_code_enabled": "true",
}
svc := newOAuthEmailFlowAuthService(
userRepo,
redeemRepo,
&refreshTokenCacheStub{},
settings,
nil, // emailCache:注册不要求邮箱验证,保持关闭
&userPlatformQuotaRepoStub{},
)
const n = 8
ctx := context.Background()
start := make(chan struct{})
results := make(chan error, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start
email := fmt.Sprintf("race-%d@example.com", i)
_, _, err := svc.RegisterWithVerification(ctx, email, "Password123!", "", "", code, "")
results <- err
}(i)
}
close(start)
wg.Wait()
close(results)
successes := 0
rejected := 0
for err := range results {
switch {
case err == nil:
successes++
case errors.Is(err, ErrInvitationCodeInvalid):
rejected++
default:
t.Fatalf("unexpected registration error: %v", err)
}
}
require.Equal(t, 1, successes, "同一邀请码并发注册必须恰好成功 1 次")
require.Equal(t, n-1, rejected, "其余并发请求必须以 INVITATION_CODE_INVALID 拒绝")
claimed, err := redeemRepo.GetByCode(ctx, code)
require.NoError(t, err)
require.Equal(t, StatusUsed, claimed.Status, "邀请码最终必须处于 used 状态")
require.NotNil(t, claimed.UsedBy, "used_by 必须记录实际注册用户")
}
// TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed 顺序路径回归:
// 已使用过的邀请码再次注册(即便换邮箱)必须被拒绝。
func TestAuthService_Register_InvitationCodeRejectedWhenAlreadyUsed(t *testing.T) {
const code = "INV-RACE-002"
userRepo := newRaceSafeUserRepo()
redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{
code: {ID: 2, Code: code, Type: RedeemTypeInvitation, Status: StatusUsed},
}}
settings := map[string]string{
"registration_enabled": "true",
"invitation_code_enabled": "true",
}
svc := newOAuthEmailFlowAuthService(
userRepo,
redeemRepo,
&refreshTokenCacheStub{},
settings,
nil,
&userPlatformQuotaRepoStub{},
)
_, _, err := svc.RegisterWithVerification(context.Background(), "later@example.com", "Password123!", "", "", code, "")
require.ErrorIs(t, err, ErrInvitationCodeInvalid)
}
// TestAuthService_Register_InvitationCodeMissingWhenEnabled 门控回归:
// 邀请码开启时,不带邀请码的注册必须被拒绝(不产生用户)。
func TestAuthService_Register_InvitationCodeMissingWhenEnabled(t *testing.T) {
userRepo := newRaceSafeUserRepo()
redeemRepo := &raceSafeRedeemRepo{codes: map[string]*RedeemCode{}}
settings := map[string]string{
"registration_enabled": "true",
"invitation_code_enabled": "true",
}
svc := newOAuthEmailFlowAuthService(
userRepo,
redeemRepo,
&refreshTokenCacheStub{},
settings,
nil,
&userPlatformQuotaRepoStub{},
)
_, _, err := svc.RegisterWithVerification(context.Background(), "no-invite@example.com", "Password123!", "", "", "", "")
require.ErrorIs(t, err, ErrInvitationCodeRequired)
ok, err := userRepo.ExistsByEmail(context.Background(), "no-invite@example.com")
require.NoError(t, err)
require.False(t, ok, "被拒绝的注册不应产生用户")
}