package auth import ( "context" "database/sql" "sync" "testing" "time" "golang.org/x/crypto/bcrypt" ) // ---------- 测试脚手架 ---------- // blockSessionExpiryUpdate 装一个只拦 expires_at 回写的触发器,模拟「续期那句 UPDATE 失败」 // (库被锁 / 磁盘满 / 只读挂载)。last_seen_at 的单列回写不受影响—— // 这两条路径必须能被区分开,否则测不出 A35 的分支。 func blockSessionExpiryUpdate(t *testing.T, db *sql.DB) { t.Helper() execTest(t, db, `CREATE TRIGGER block_renew BEFORE UPDATE OF expires_at ON sessions BEGIN SELECT RAISE(ABORT, '续期写库被拦下'); END`) } // blockSessionDelete 装一个拦 DELETE 的触发器,模拟「作废会话失败」。 func blockSessionDelete(t *testing.T, db *sql.DB) { t.Helper() execTest(t, db, `CREATE TRIGGER block_revoke BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, '撤会话被拦下'); END`) } func insertSessionRow(t *testing.T, db *sql.DB, id string, userID int64, expiresAt time.Time) string { t.Helper() token := "token-" + id if _, err := db.Exec( `INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`, id, userID, hashToken(token), expiresAt.UTC().Format(time.DateTime), expiresAt.UTC().Format(time.DateTime)); err != nil { t.Fatalf("建会话 %s: %v", id, err) } return token } func countSessions(t *testing.T, db *sql.DB) int { t.Helper() var n int if err := db.QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&n); err != nil { t.Fatalf("统计会话: %v", err) } return n } func dbPasswordHash(t *testing.T, db *sql.DB, username string) string { t.Helper() var hash string if err := db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, username).Scan(&hash); err != nil { t.Fatalf("读密码哈希: %v", err) } return hash } func newAuthTestService(t *testing.T, db *sql.DB, opts Options) *Service { t.Helper() opts.Logger = quietLogger() if opts.BcryptCost == 0 { opts.BcryptCost = bcrypt.MinCost } return New(db, opts) } // ---------- A35:滑动续期写库失败 ---------- // 续期写库失败时不能只顺延内存里的到期时间:库里还是旧到期时间,而 /auth/me 会拿内存里那个值 // 重发 Cookie(浏览器端的 Max-Age 跟着顺延),于是「服务端说还有 24 小时、其实早已过期」, // 用户会在毫无征兆的情况下被登出,排查时两边还对不上。 func TestLookupKeepsStoredExpiryWhenRenewFails(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := newAuthTestService(t, db, Options{SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") // 剩余 23h58m:已越过续期节流窗口,本次查找本该顺延到 now+24h。 token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute)) stored := dbExpiry(t, db) blockSessionExpiryUpdate(t, db) _, sess, err := svc.Lookup(context.Background(), token) if err != nil { t.Fatalf("续期写库失败不该让查找失败(会话此刻仍然有效): %v", err) } if want := now.Add(24*time.Hour - 2*time.Minute); !sess.ExpiresAt.Equal(want) { t.Fatalf("续期失败时必须保持库里的到期时间 %s,得到 %s", want, sess.ExpiresAt) } if got := dbExpiry(t, db); got != stored { t.Fatalf("续期失败不该改动库里的到期时间:%s → %s", stored, got) } // 去掉触发器后重来:续期该恢复成正常顺延(证明确实是「失败才不续」而不是「永远不续」)。 execTest(t, db, `DROP TRIGGER block_renew`) if _, sess, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { t.Fatalf("写库恢复后应顺延到 %s,得到 %s", want, sess.ExpiresAt) } } // ---------- A36:改密与作废会话同成同败 ---------- // 正常路径:密码换掉的同时会话被清空(两个动作在一个事务里)。 func TestSetPasswordRevokesSessionsSeenAsOneAction(t *testing.T) { db := newSettingsDB(t, nil) svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) userID := insertUser(t, db, "admin") if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil { t.Fatalf("初始化密码: %v", err) } insertSession(t, db, userID, time.Now().Add(time.Hour)) if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err != nil { t.Fatalf("改密应成功: %v", err) } if err := svc.VerifyPassword(context.Background(), userID, "new-password"); err != nil { t.Fatalf("新密码应能通过校验: %v", err) } if err := svc.VerifyPassword(context.Background(), userID, "old-password"); err == nil { t.Fatal("旧密码不该还能通过校验") } if n := countSessions(t, db); n != 0 { t.Fatalf("改密后旧会话必须全部失效,库里还剩 %d 行", n) } } // 失败路径(本条的要点):撤会话失败时整件事必须回滚, // 绝不能留下「密码已改、旧会话仍全部有效」这种用户以为已经止血、实际没有的中间态。 func TestSetPasswordRollsBackWhenRevokeFails(t *testing.T) { db := newSettingsDB(t, nil) svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) userID := insertUser(t, db, "admin") if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil { t.Fatalf("初始化密码: %v", err) } insertSession(t, db, userID, time.Now().Add(time.Hour)) before := dbPasswordHash(t, db, "admin") blockSessionDelete(t, db) if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err == nil { t.Fatal("撤会话失败时改密必须整体失败") } if got := dbPasswordHash(t, db, "admin"); got != before { t.Fatalf("事务失败后密码哈希必须原样(回滚),%s → %s", before, got) } if n := countSessions(t, db); n != 1 { t.Fatalf("事务失败后旧会话必须仍然存在(与「什么都没变」自洽),库里 %d 行", n) } // 外部看到的就是「什么都没变」:旧密码仍然有效、旧会话仍然可用。 if err := svc.VerifyPassword(context.Background(), userID, "old-password"); err != nil { t.Fatalf("回滚后旧密码应仍然有效: %v", err) } if err := svc.VerifyPassword(context.Background(), userID, "new-password"); err == nil { t.Fatal("回滚后新密码不该生效") } } // 并发路径:改密期间并发的会话查找只会看到「旧世界」或「新世界」, // 改密返回后旧 token 必须彻底查不到(不会出现「密码换了、会话还在」的残留)。 func TestSetPasswordConcurrentLookupsLeaveNoStaleSession(t *testing.T) { db := newSettingsDB(t, nil) svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) userID := insertUser(t, db, "admin") if err := svc.SetPassword(context.Background(), "admin", "old-password", false); err != nil { t.Fatalf("初始化密码: %v", err) } token := insertSession(t, db, userID, time.Now().Add(time.Hour)) stop := make(chan struct{}) var wg sync.WaitGroup for i := 0; i < 4; i++ { wg.Add(1) go func() { defer wg.Done() for { select { case <-stop: return default: // 结果不校验:并发查找允许看到事务提交前的任意中间时刻。 _, _, _ = svc.Lookup(context.Background(), token) } } }() } if err := svc.SetPassword(context.Background(), "admin", "new-password", false); err != nil { t.Fatalf("改密应成功: %v", err) } close(stop) wg.Wait() // 改密返回之后,任何一条旧会话都不该还能用。 if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound { t.Fatalf("改密后旧会话应查不到,得到 %v", err) } if n := countSessions(t, db); n != 0 { t.Fatalf("改密后库里不该还剩会话行,得到 %d", n) } } // 改名走同一条事务路径:改名成功则会话一并作废。 func TestSetUsernameRevokesSessionsInSameTransaction(t *testing.T) { db := newSettingsDB(t, nil) svc := newAuthTestService(t, db, Options{SessionTTL: time.Hour}) userID := insertUser(t, db, "admin") insertSession(t, db, userID, time.Now().Add(time.Hour)) if err := svc.SetUsername(context.Background(), "admin", "boss"); err != nil { t.Fatalf("改名应成功: %v", err) } if n := countSessions(t, db); n != 0 { t.Fatalf("改名后旧会话必须失效,库里还剩 %d 行", n) } // 再给他一条会话,才能让「撤会话」这一步真的去删行(DELETE 不命中行时不会触发触发器)。 insertSession(t, db, userID, time.Now().Add(time.Hour)) blockSessionDelete(t, db) if err := svc.SetUsername(context.Background(), "boss", "chief"); err == nil { t.Fatal("撤会话失败时改名必须整体失败") } var name string if err := db.QueryRow(`SELECT username FROM users`).Scan(&name); err != nil { t.Fatalf("读用户名: %v", err) } if name != "boss" { t.Fatalf("事务失败后用户名必须回滚成 boss,得到 %q", name) } } // ---------- D01:过期会话行清理 ---------- // sessions 只增不减(每次登录一行,过期行与 token 哈希永久留存), // 挂在「查找会话」这条必经路径上按 sessionPurgeInterval 节流清理。 func TestLookupPurgesExpiredSessions(t *testing.T) { db := newSettingsDB(t, nil) now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) svc := newAuthTestService(t, db, Options{SessionTTL: 24 * time.Hour, Now: func() time.Time { return now }}) userID := insertUser(t, db, "admin") token := insertSessionRow(t, db, "live", userID, now.Add(24*time.Hour)) insertSessionRow(t, db, "old-1", userID, now.Add(-time.Hour)) insertSessionRow(t, db, "old-2", userID, now.Add(-48*time.Hour)) if _, _, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } if n := countSessions(t, db); n != 1 { t.Fatalf("过期会话行应被清掉,库里还剩 %d 行", n) } // 节流:同一小时内不再清第二次(每次请求都扫一遍过期行纯属白写库)。 insertSessionRow(t, db, "old-3", userID, now.Add(-time.Minute)) if _, _, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } if n := countSessions(t, db); n != 2 { t.Fatalf("节流窗口内不该再清一次,库里 %d 行", n) } // 越过节流窗口后继续清理,而且不能误删仍然有效的会话。 now = now.Add(sessionPurgeInterval + time.Minute) if _, _, err := svc.Lookup(context.Background(), token); err != nil { t.Fatalf("查询会话: %v", err) } if n := countSessions(t, db); n != 1 { t.Fatalf("越过节流窗口后应清掉过期行、只留有效会话,库里 %d 行", n) } } // ---------- C15:登录限流不再是死配置 ---------- // 迁移 0005 种下的 security.login_rate_per_minute 此前没有任何代码读它, // 现场改这个键不起作用。现在它真的生效(越界 / 非法一律回落到内置默认)。 func TestLoginRateFromSettings(t *testing.T) { for _, tc := range []struct { name string value string want int }{ {"系统库设置生效", "2", 2}, {"上界 60 生效", "60", 60}, {"0 非法回默认", "0", DefaultLoginPerMinute}, {"负数非法回默认", "-1", DefaultLoginPerMinute}, {"超上限非法回默认", "61", DefaultLoginPerMinute}, {"非数字非法回默认", "五次", DefaultLoginPerMinute}, } { t.Run(tc.name, func(t *testing.T) { db := newSettingsDB(t, map[string]string{LoginRateKey: tc.value}) svc := newAuthTestService(t, db, Options{}) if got := svc.loginLimiter.limit; got != tc.want { t.Fatalf("登录限流应为 %d,得到 %d", tc.want, got) } // 行为断言,不只看字段:第 want+1 次必须被拦下。 for i := 0; i < tc.want; i++ { if !svc.AllowLogin("1.2.3.4") { t.Fatalf("第 %d 次应放行", i+1) } } if svc.AllowLogin("1.2.3.4") { t.Fatalf("第 %d 次应被限流", tc.want+1) } }) } t.Run("系统库没有键时用启动参数兜底", func(t *testing.T) { db := newSettingsDB(t, nil) svc := newAuthTestService(t, db, Options{LoginPerMinute: 3}) if got := svc.loginLimiter.limit; got != 3 { t.Fatalf("应回落到启动参数的 3,得到 %d", got) } }) t.Run("系统库有键时优先于启动参数", func(t *testing.T) { db := newSettingsDB(t, map[string]string{LoginRateKey: "2"}) svc := newAuthTestService(t, db, Options{LoginPerMinute: 9}) if got := svc.loginLimiter.limit; got != 2 { t.Fatalf("系统库设置应优先,期望 2,得到 %d", got) } }) }