- 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>
117 lines
4.0 KiB
Go
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")
|
|
}
|
|
}
|