topfans/backend/services/userService/service/auth_service_test.go
zerosaturation e1326acaf9 fix(backend): service stability — bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate (batch 3)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前,消除连接池占用。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential
  /ErrTooManyLoginAttempts; 用户不存在/密码错/密码空 三路径统一返回同一错误;
  mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级); user-not-found 走
  dummy bcrypt 抹平 ~100ms 时序差,完全消除枚举侧信道;空密码分支已核实无时序 leak。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, 新 stub EventProducer.Publish no-op;
  pkg/mq/mq.go Init 不再装配 streams; 全仓 grep 验证 11 处硬编码
  'gallery'/'default' 集中到 pkg/queue/consts (值不变, 仅消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value (见上一个 commit 293c7b1)。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误
  记 WARN 不静默; Dify err 映射稳定用户文案,原始 err 仅服务端日志。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop
  worker; 失败 ERROR 日志带字段; drop 记 WARN; Close 可重复调用。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 5+ 处 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go
  风险,见报告 §5 proto 风险复盘); 铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 18:50:40 +08:00

117 lines
4.0 KiB
Go

package service
import (
"context"
"os"
"testing"
"time"
"github.com/topfans/backend/pkg/jwt"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/user"
"github.com/topfans/backend/services/userService/repository"
"go.uber.org/zap"
"gorm.io/gorm"
)
// TestMain 初始化包内测试共享前置:
// 测试进程不会调用 logger.Init,而 Register/Login 等成功/告警路径会调用 logger.Logger.*,
// 若全局 logger 为 nil 会直接 panic。这里注入 no-op logger(不产生输出),
// 让整个 service 包的 live-DB 用例可稳定运行。不涉及 DB DSN,不改动既有 setup。
//
// 同时初始化 JWT secret(32+ 字节合法密钥):Login 流程会调 jwt.GenerateToken,
// pkg/jwt 在 3.5 治理后已禁止静默弱默认值,运行期若未 MustInit 直接 panic。
func TestMain(m *testing.M) {
if logger.Logger == nil {
logger.Logger = zap.NewNop()
}
if err := jwt.MustInit("test-secret-key-must-be-at-least-32-bytes"); err != nil {
panic("jwt.MustInit failed in TestMain: " + err.Error())
}
os.Exit(m.Run())
}
// createTestStar 插入一条 is_active 的测试明星,返回其自增 star_id 与清理函数。
// star_id / created_at / updated_at 交给 GORM 自增与 BeforeCreate 钩子,避免手动指定 ID
// 破坏 PostgreSQL 序列(见 CLAUDE.md 序列同步规则)。
func createTestStar(t *testing.T, db *gorm.DB) (int64, func()) {
t.Helper()
star := &models.Star{
Name: "bcrypt-tx-test-star",
IdentityID: "bcrypt_tx_test_" + time.Now().Format("150405.000000"),
IsActive: true,
}
if err := db.Create(star).Error; err != nil {
t.Fatalf("create test star: %v", err)
}
return star.StarID, func() {
db.Exec("DELETE FROM stars WHERE star_id = ?", star.StarID)
}
}
// TestRegister_Success_PasswordHashedAndVerifiable 覆盖 bcrypt 移出事务后的行为等价性:
//
// 注册仍然成功、返回 access_token,并且写入库的 password_hash 能被 bcrypt 正确校验。
//
// 结构性保证(哈希在事务外)由 Register 中「HashPassword 位于 s.db.Transaction(...) 之前」
// 的代码位置 + 注释锁定;本用例负责保证前移不破坏原有注册行为。
func TestRegister_Success_PasswordHashedAndVerifiable(t *testing.T) {
skipIfNoTestEnv(t)
db := setupTestDB(t)
defer cleanupTestDB(t, db)
userRepo := repository.NewUserRepository()
fanProfileRepo := repository.NewFanProfileRepository()
starRepo := repository.NewStarRepository()
starID, cleanupStar := createTestStar(t, db)
defer cleanupStar()
const mobile = "13800001990"
const password = "Passw0rd123"
// 预清理,避免上次残留导致 mobile 冲突
db.Exec("DELETE FROM fan_profiles WHERE user_id IN (SELECT id FROM users WHERE mobile = ?)", mobile)
db.Exec("DELETE FROM users WHERE mobile = ?", mobile)
svc := NewAuthService(userRepo, fanProfileRepo, starRepo, db)
resp, err := svc.Register(context.Background(), &pb.RegisterRequest{
Mobile: mobile,
Password: password,
Nickname: "bcryptTester",
StarId: starID,
// VerifyToken 为空 → 跳过 Redis verify_token 校验,测试无需 Redis
})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if resp == nil || resp.Base == nil {
t.Fatal("expected non-nil response with base")
}
if resp.AccessToken == "" {
t.Fatal("expected non-empty access_token in RegisterResponse")
}
// 落库后 password_hash 必须能被 bcrypt 正确校验(证明事务外算出的哈希被正确写入)
saved, err := userRepo.GetByMobile(mobile)
if err != nil {
t.Fatalf("GetByMobile after register: %v", err)
}
defer func() {
db.Exec("DELETE FROM fan_profiles WHERE user_id = ?", saved.ID)
deleteTestUser(t, db, userRepo, saved.ID)
}()
if saved.PasswordHash == "" {
t.Fatal("password_hash should be persisted")
}
if saved.PasswordHash == password {
t.Fatal("password_hash must not be the plaintext password")
}
if !userRepo.VerifyPassword(saved, password) {
t.Fatal("stored password_hash should verify against the original password")
}
}