Files
pangolin/server/internal/auth/service_test.go
T
wangjia a5e25b444f feat(auth): 验证码/注册/登录/JWT 鉴权模块 (tsk_2PFfyviECIXh)
实现 server/internal/auth 控制面鉴权底座,对应 doc/02 §4.1、doc/06 §3:

- POST /v1/auth/code:IP+邮箱双维度 Redis 滑窗限频(同邮箱 1/min、同 IP 10/h)
  + 一次性邮箱域黑名单(embed 词表)→ 6 位数字码写 auth:code:{email} TTL 10min
  → 异步发信(SMTP / 开发态 log mailer)。
- POST /v1/auth/register:验码(一次性,删 key;超次数烧码)→ 单事务建号
  (uuid + dp_uuid 应用层生成、argon2id)+ 7 天 PRO 试用(source='trial')→ 签发 JWT。
- POST /v1/auth/login:argon2id 校验(失败恒定时、未知用户走 dummy hash);
  失败计数限流 rl:login:{email} + 锁定;banned 拒绝。
- POST /v1/auth/refresh:RS256,access 15min + refresh 30d 落 Redis 白名单
  jwt:refresh:{jti},旋转时删旧写新;JWT 头带 kid,验证端接受新旧公钥支持轮换。
- RequireAuth 中间件:解析 Bearer access,注入 user id(复用 codes.CtxKeyUserID
  避免循环依赖)+ uuid + claims 到 context。
- 错误体统一走 internal/apierr 的 {code, message_zh, message_en},文案双语脱敏。
- 文件拆分:handler/service/password/token/ratelimit/emailcheck/store/mailer/
  middleware/errors/keyloader;config 增加可选 JWT PEM 路径/kid 加载。

测试:password/token/ratelimit/emailcheck/service/handler/middleware 单测
(miniredis,含注册全流程、重复邮箱 409、验证码错误/过期/复用/烧码、限频 429
带 Retry-After、登录失败锁定、banned 拒绝、refresh 旋转后旧 token 失效),
go test -race 通过;集成测试 integration_test.go(testcontainers MySQL8+Redis,
register→login→refresh→受保护接口全链路,构建 tag integration)与 codes 模块同构。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:19:10 +08:00

272 lines
8.3 KiB
Go

package auth
import (
"context"
"testing"
"time"
)
// newService wires a Service over a fake store + miniredis for unit tests.
// It returns the service, the redis client (to read codes directly), the fake
// store, and the miniredis handle (to fast-forward TTLs).
func newService(t *testing.T, cfg ServiceConfig) (*Service, *fakeStore, *captureMailer) {
t.Helper()
rdb, _ := newMiniRedis(t)
rl := NewRateLimiter(rdb, nil)
tm := newTokenManager(t, rdb, time.Now)
store := newFakeStore()
mailer := newCaptureMailer()
svc := NewService(store, rdb, rl, tm, mailer, cfg, nil)
return svc, store, mailer
}
// codeInRedis reads the active verification code straight from Redis.
func codeInRedis(t *testing.T, svc *Service, email string) string {
t.Helper()
c, err := svc.rdb.Get(context.Background(), codeKey(email)).Result()
if err != nil {
t.Fatalf("no code stored for %s: %v", email, err)
}
return c
}
func TestService_RegisterFullFlow(t *testing.T) {
svc, store, _ := newService(t, ServiceConfig{})
ctx := context.Background()
const email = "alice@example.com"
if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil {
t.Fatalf("SendCode: %v", err)
}
code := codeInRedis(t, svc, email)
pair, apiErr := svc.Register(ctx, email, code, "supersecret")
if apiErr != nil {
t.Fatalf("Register: %v", apiErr)
}
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
t.Fatalf("bad token pair: %+v", pair)
}
// User exists.
u, err := store.GetUserByEmail(ctx, email)
if err != nil {
t.Fatalf("user not created: %v", err)
}
// Trial subscription exists, PRO, ~7 days.
tr, ok := store.trials[u.ID]
if !ok {
t.Fatal("trial subscription not created")
}
if tr.plan != "pro" || tr.source != "trial" {
t.Fatalf("trial = %+v, want pro/trial", tr)
}
days := time.Until(tr.expiresAt).Hours() / 24
if days < 6.9 || days > 7.1 {
t.Fatalf("trial length = %.2f days, want ~7", days)
}
// Issued access token authenticates.
claims, perr := svc.tokens.ParseAccess(pair.AccessToken)
if perr != nil {
t.Fatalf("ParseAccess: %v", perr)
}
if claims.UID != u.ID || claims.Subject != u.UUID {
t.Fatalf("claims mismatch: %+v", claims)
}
}
func TestService_DuplicateEmailConflict(t *testing.T) {
// Higher send limit so the two registration attempts can each request a code.
svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 10})
ctx := context.Background()
const email = "dup@example.com"
// First registration.
_, _ = svc.SendCode(ctx, email, "")
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1"); e != nil {
t.Fatalf("first register: %v", e)
}
// Second: new code, but the email is already taken → 409.
_, _ = svc.SendCode(ctx, email, "")
_, apiErr := svc.Register(ctx, email, codeInRedis(t, svc, email), "password2")
if apiErr == nil || apiErr.Code != ErrEmailExists.Code {
t.Fatalf("want email_exists, got %v", apiErr)
}
}
func TestService_CodeWrong(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{})
ctx := context.Background()
const email = "wrong@example.com"
_, _ = svc.SendCode(ctx, email, "")
_, apiErr := svc.Register(ctx, email, "000000", "password1")
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
t.Fatalf("want code_invalid, got %v", apiErr)
}
}
func TestService_CodeExpired(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{CodeTTL: time.Minute})
ctx := context.Background()
const email = "expired@example.com"
_, _ = svc.SendCode(ctx, email, "")
code := codeInRedis(t, svc, email)
// Expire the code key.
svc.rdb.Del(ctx, codeKey(email))
_, apiErr := svc.Register(ctx, email, code, "password1")
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
t.Fatalf("want code_invalid after expiry, got %v", apiErr)
}
}
func TestService_CodeReuseRejected(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{})
ctx := context.Background()
const email = "reuse@example.com"
_, _ = svc.SendCode(ctx, email, "")
code := codeInRedis(t, svc, email)
if _, e := svc.Register(ctx, email, code, "password1"); e != nil {
t.Fatalf("first register: %v", e)
}
// Re-using the consumed code must fail.
_, apiErr := svc.Register(ctx, "other@example.com", code, "password1")
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
t.Fatalf("want code_invalid on reuse, got %v", apiErr)
}
}
func TestService_CodeBruteForceBurned(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{CodeMaxAttempts: 3})
ctx := context.Background()
const email = "brute@example.com"
_, _ = svc.SendCode(ctx, email, "")
good := codeInRedis(t, svc, email)
// 3 wrong attempts burn the code.
for i := 0; i < 3; i++ {
if _, e := svc.Register(ctx, email, "999999", "password1"); e == nil {
t.Fatal("wrong code should fail")
}
}
// Even the correct code no longer works.
if _, e := svc.Register(ctx, email, good, "password1"); e == nil || e.Code != ErrCodeInvalid.Code {
t.Fatalf("burned code should reject correct value, got %v", e)
}
}
func TestService_SendCodeRateLimited(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 1})
ctx := context.Background()
const email = "rl@example.com"
if _, e := svc.SendCode(ctx, email, "9.9.9.9"); e != nil {
t.Fatalf("first send: %v", e)
}
ra, apiErr := svc.SendCode(ctx, email, "9.9.9.9")
if apiErr == nil || apiErr.Code != ErrRateLimited.Code {
t.Fatalf("want rate_limited, got %v", apiErr)
}
if ra <= 0 {
t.Fatalf("expected positive retry-after, got %v", ra)
}
}
func TestService_SendCodeDisposableBlocked(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{})
_, apiErr := svc.SendCode(context.Background(), "x@mailinator.com", "")
if apiErr == nil || apiErr.Code != ErrEmailDisposable.Code {
t.Fatalf("want email_disposable, got %v", apiErr)
}
}
func TestService_LoginAndLockout(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{LoginFailMax: 3, LoginLockWindow: 15 * time.Minute})
ctx := context.Background()
const email = "login@example.com"
const pw = "rightpassword"
_, _ = svc.SendCode(ctx, email, "")
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil {
t.Fatalf("register: %v", e)
}
// Correct login works.
pair, _, apiErr := svc.Login(ctx, email, pw, "")
if apiErr != nil || pair == nil {
t.Fatalf("login should succeed: %v", apiErr)
}
// 3 wrong attempts.
for i := 0; i < 3; i++ {
_, _, e := svc.Login(ctx, email, "wrong", "")
if e == nil || e.Code != ErrInvalidCredentials.Code {
t.Fatalf("attempt %d want invalid_credentials, got %v", i, e)
}
}
// Now locked, even with the correct password.
_, ra, e := svc.Login(ctx, email, pw, "")
if e == nil || e.Code != ErrAccountLocked.Code {
t.Fatalf("want account_locked, got %v", e)
}
if ra <= 0 {
t.Fatalf("expected positive retry-after on lock, got %v", ra)
}
}
func TestService_LoginUnknownUser(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{})
_, _, apiErr := svc.Login(context.Background(), "ghost@example.com", "whatever", "")
if apiErr == nil || apiErr.Code != ErrInvalidCredentials.Code {
t.Fatalf("want invalid_credentials for unknown user, got %v", apiErr)
}
}
func TestService_BannedUserRejected(t *testing.T) {
svc, store, _ := newService(t, ServiceConfig{})
ctx := context.Background()
const email = "banned@example.com"
const pw = "password1"
_, _ = svc.SendCode(ctx, email, "")
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil {
t.Fatalf("register: %v", e)
}
store.setStatus(email, "banned")
_, _, apiErr := svc.Login(ctx, email, pw, "")
if apiErr == nil || apiErr.Code != ErrAccountBanned.Code {
t.Fatalf("want account_banned, got %v", apiErr)
}
}
func TestService_RefreshRotation(t *testing.T) {
svc, _, _ := newService(t, ServiceConfig{})
ctx := context.Background()
const email = "refresh@example.com"
_, _ = svc.SendCode(ctx, email, "")
pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1")
if e != nil {
t.Fatalf("register: %v", e)
}
rotated, apiErr := svc.Refresh(ctx, pair.RefreshToken)
if apiErr != nil {
t.Fatalf("refresh: %v", apiErr)
}
// Old refresh token now invalid.
if _, e := svc.Refresh(ctx, pair.RefreshToken); e == nil || e.Code != ErrInvalidToken.Code {
t.Fatalf("want invalid_token for rotated-out refresh, got %v", e)
}
// New one works.
if _, e := svc.Refresh(ctx, rotated.RefreshToken); e != nil {
t.Fatalf("new refresh should work: %v", e)
}
}