2f298f0a0a
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 18s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m13s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
migration 000016(mysql+sqlite,含 down):新增 sessions 表(绑 device+refresh JTI) + devices 加 client_version/totp_trusted_until。devices 唯一键改 + platform CHECK 加 linux(需 SQLite 表重建)拆出后续迁移,降风险。 后端:新 internal/sessions Store(Create/Rotate/Revoke/RevokeByDevice/ LastLoginByDevice);TokenManager 外露 refresh JTI(IssueWithJTI/RefreshWithJTI/ ParseRefreshJTI);auth.Service 注入 SessionStore——登录建会话、刷新轮换、登出吊销; DeviceRegistrar 返回 deviceID;ReportUsage 心跳 touch devices.last_seen(在线判定); devices.ListDevices 经 LastLoginSource 注入返回 online(last_seen<3min)/client_version/ last_login;RegisterIfAbsent 存 client_version。 客户端:Device model 加 online/clientVersion/lastLogin(fromJson 自动解析)。 测试:sessions store 3 例 + ListDevices 在线/最后登录 + device model 2 例 + migration v16;全量 go test/flutter test 绿。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
285 lines
9.2 KiB
Go
285 lines
9.2 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", "", DeviceMeta{})
|
|
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", "", DeviceMeta{}); e != nil {
|
|
t.Fatalf("first register: %v", e)
|
|
}
|
|
|
|
// Anti-enumeration: SendCode for a now-registered email must NOT store a
|
|
// verification code (it mails a login reminder instead), so the API response
|
|
// can't be used to discover that the email is registered.
|
|
if _, e := svc.SendCode(ctx, email, ""); e != nil {
|
|
t.Fatalf("second SendCode: %v", e)
|
|
}
|
|
if c := svc.rdb.Get(ctx, codeKey(email)).Val(); c != "" {
|
|
t.Fatalf("registered email must not receive a code, got %q", c)
|
|
}
|
|
|
|
// Even if a code is forced (e.g. a race), Register returns the generic code
|
|
// error rather than "email exists" — no enumeration leak.
|
|
if err := svc.rdb.Set(ctx, codeKey(email), "654321", 10*time.Minute).Err(); err != nil {
|
|
t.Fatalf("force code: %v", err)
|
|
}
|
|
_, apiErr := svc.Register(ctx, email, "654321", "password2", "", DeviceMeta{})
|
|
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
|
t.Fatalf("want code_invalid (anti-enumeration), 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", "", DeviceMeta{})
|
|
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", "", DeviceMeta{})
|
|
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", "", DeviceMeta{}); e != nil {
|
|
t.Fatalf("first register: %v", e)
|
|
}
|
|
// Re-using the consumed code must fail.
|
|
_, apiErr := svc.Register(ctx, "other@example.com", code, "password1", "", DeviceMeta{})
|
|
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", "", DeviceMeta{}); e == nil {
|
|
t.Fatal("wrong code should fail")
|
|
}
|
|
}
|
|
// Even the correct code no longer works.
|
|
if _, e := svc.Register(ctx, email, good, "password1", "", DeviceMeta{}); 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, "", DeviceMeta{}); e != nil {
|
|
t.Fatalf("register: %v", e)
|
|
}
|
|
|
|
// Correct login works.
|
|
pair, _, apiErr := svc.Login(ctx, email, pw, "", DeviceMeta{})
|
|
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", "", DeviceMeta{})
|
|
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, "", DeviceMeta{})
|
|
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", "", DeviceMeta{})
|
|
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, "", DeviceMeta{}); e != nil {
|
|
t.Fatalf("register: %v", e)
|
|
}
|
|
store.setStatus(email, "banned")
|
|
|
|
_, _, apiErr := svc.Login(ctx, email, pw, "", DeviceMeta{})
|
|
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", "", DeviceMeta{})
|
|
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)
|
|
}
|
|
}
|