Files
pangolin/server/internal/auth/service_test.go
T
wangjia 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
feat(devices): P2 sessions 表 + 在线/最后登录/客户端版本
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>
2026-06-29 00:50:24 +08:00

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)
}
}