Files
Scoreboard/server/internal/auth/session_ttl_test.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

399 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
// 登录有效期的解析与新默认值:迁移种下的历史占位值 12 从未被任何代码读过,
// 按「未配置」处理,否则新装的库会永远停在 12(现场前一晚登录、次日比赛当天掉线)。
func TestEffectiveSessionHours(t *testing.T) {
for _, tc := range []struct {
name string
values map[string]string
want int
}{
{"历史占位值 12(无面板标记)按未配置处理", map[string]string{SessionHoursKey: "12"}, 24},
{"面板明确选过的 12 原样生效", map[string]string{SessionHoursKey: "12", SessionHoursSourceKey: "panel"}, 12},
{"面板写的 72 生效", map[string]string{SessionHoursKey: "72", SessionHoursSourceKey: "panel"}, 72},
{"未配置回默认 24", map[string]string{}, 24},
{"上界 720", map[string]string{SessionHoursKey: "720"}, 720},
{"下界 1", map[string]string{SessionHoursKey: "1"}, 1},
{"0 非法回默认", map[string]string{SessionHoursKey: "0"}, 24},
{"721 非法回默认", map[string]string{SessionHoursKey: "721"}, 24},
{"非数字非法回默认", map[string]string{SessionHoursKey: "十二"}, 24},
} {
t.Run(tc.name, func(t *testing.T) {
if got := EffectiveSessionHours(tc.values); got != tc.want {
t.Fatalf("EffectiveSessionHours(%v) = %d,期望 %d", tc.values, got, tc.want)
}
})
}
}
func TestParseSessionHours(t *testing.T) {
for raw, want := range map[string]int{"1": 1, "24": 24, "720": 720, " 48 ": 48} {
got, ok := ParseSessionHours(raw)
if !ok || got != want {
t.Fatalf("ParseSessionHours(%q) = %d,%v,期望 %d,true", raw, got, ok, want)
}
}
for _, raw := range []string{"", "0", "-1", "721", "24.5", "abc"} {
if _, ok := ParseSessionHours(raw); ok {
t.Fatalf("ParseSessionHours(%q) 应判为非法", raw)
}
}
}
// 系统库里的登录有效期要真正被认成会话寿命;显式传入的 Options.SessionTTL 优先(测试 / 嵌入用)。
func TestSessionTTLFromSystemSettings(t *testing.T) {
for _, tc := range []struct {
name string
seed map[string]string
optsTTL time.Duration
want time.Duration
}{
{"历史占位值 12 视为未配置 → 24h", map[string]string{SessionHoursKey: "12"}, 0, 24 * time.Hour},
{"面板写的 1 → 1h", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 0, time.Hour},
{"面板里的值优先于启动参数", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 90 * time.Minute, time.Hour},
{"系统库没有配置时用启动参数兜底", nil, 90 * time.Minute, 90 * time.Minute},
} {
t.Run(tc.name, func(t *testing.T) {
db := newSettingsDB(t, tc.seed)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: tc.optsTTL})
if got := svc.SessionTTL(); got != tc.want {
t.Fatalf("SessionTTL() = %s,期望 %s", got, tc.want)
}
})
}
}
// 滑动续期:剩余寿命少于「有效期 - 节流窗口」时才回写,且回写后到期时间顺延到 now+有效期。
func TestLookupSlidesExpiryWithThrottle(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
token := insertSession(t, db, userID, now.Add(24*time.Hour))
// 刚创建的会话:剩余 = 有效期,不写 expires_at(首次请求不该产生额外写)。
if _, sess, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("查询会话: %v", err)
} else if !sess.ExpiresAt.Equal(now.Add(24 * time.Hour)) {
t.Fatalf("新鲜会话的到期时间不该变,得到 %s", sess.ExpiresAt)
}
if got := dbExpiry(t, db); got != now.Add(24*time.Hour).Format(time.DateTime) {
t.Fatalf("新鲜会话不应回写 expires_at,库里是 %s", got)
}
// 剩余 23h58m(不足 24h-1m):下一次请求应把到期时间顺延到 now+24h。
before := now.Add(24*time.Hour - 2*time.Minute)
setExpiry(t, db, before)
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)
}
if got, want := dbExpiry(t, db), now.Add(24*time.Hour).Format(time.DateTime); got != want {
t.Fatalf("库里到期时间应为 %s,得到 %s", want, got)
}
// 节流:紧接着的多次请求既不写 expires_at,也不写 last_seen_at——
// 现场一次页面加载会打七八个请求,逐个写库纯属放大写压力。
renewed := dbExpiry(t, db)
seen := dbLastSeen(t, db)
for i := 0; i < 5; i++ {
now = now.Add(time.Second)
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("第 %d 次查询会话: %v", i, err)
}
if got := dbExpiry(t, db); got != renewed {
t.Fatalf("节流窗口内不该回写 expires_at:%s → %s", renewed, got)
}
if got := dbLastSeen(t, db); got != seen {
t.Fatalf("节流窗口内不该回写 last_seen_at:%s → %s", seen, got)
}
}
// 越过节流窗口(1 分钟)后再次顺延:活跃会话大约每分钟才写一次库。
now = now.Add(5 * time.Minute)
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)
}
if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want {
t.Fatalf("续期时活动时间应一起回写为 %s,得到 %s", want, got)
}
}
// 活动时间的节流不会影响续期:会话剩余寿命超过当前有效期(例如有效期被调短)时,
// 只回写 last_seen_at,expires_at 保持原值。
func TestLookupTouchThrottleKeepsLongLivedSession(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
token := insertSession(t, db, userID, now.Add(20*time.Hour))
setLastSeen(t, db, now.Add(-time.Hour))
stored := dbExpiry(t, db)
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("查询会话: %v", err)
}
if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want {
t.Fatalf("活动时间应回写为 %s,得到 %s", want, got)
}
if got := dbExpiry(t, db); got != stored {
t.Fatalf("已登录会话的到期时间不该被改设置的续期逻辑改写:%s → %s", stored, got)
}
}
// 会话复查:/ws 长连接靠它判断背后的会话是否还有效。撤销(登出 / 改密 / 管理员重置密码 /
// 停用用户)与到期都必须判为失效,否则被停用的人只要不关页面就能一直收推送。
func TestSessionActiveTracksRevocation(t *testing.T) {
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
sessionID string
prepare func(t *testing.T, db *sql.DB)
want bool
}{
{"有效会话", "sid", nil, true},
{"会话被撤销(登出 / 改密 / 重置密码都删行)", "sid", func(t *testing.T, db *sql.DB) {
execTest(t, db, `DELETE FROM sessions`)
}, false},
{"会话已过期", "sid", func(t *testing.T, db *sql.DB) {
setExpiry(t, db, now.Add(-time.Second))
}, false},
{"用户已停用", "sid", func(t *testing.T, db *sql.DB) {
execTest(t, db, `UPDATE users SET active = 0`)
}, false},
{"会话 id 未知", "other", nil, false},
{"会话 id 为空", "", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
db := newSettingsDB(t, nil)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
insertSession(t, db, userID, now.Add(time.Hour))
if tc.prepare != nil {
tc.prepare(t, db)
}
got, err := svc.SessionActive(context.Background(), tc.sessionID)
if err != nil {
t.Fatalf("复查会话: %v", err)
}
if got != tc.want {
t.Fatalf("SessionActive = %v,期望 %v", got, tc.want)
}
// 复查是只读判定:不能顺延到期时间,也不能回写活动时间。
if tc.want && dbExpiry(t, db) != now.Add(time.Hour).Format(time.DateTime) {
t.Fatalf("复查不该写库,库里到期时间是 %s", dbExpiry(t, db))
}
})
}
}
func execTest(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("执行 %s: %v", query, err)
}
}
// 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。
func TestLookupRenewsAfterThrottleWindow(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute))
if _, _, err := svc.Lookup(context.Background(), token); err != nil {
t.Fatalf("查询会话: %v", err)
}
now = now.Add(2 * time.Minute)
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)
}
}
// 到期校验不能被续期改坏:已过期的会话仍然查不到(调用方据此回 401)。
func TestLookupRejectsExpiredSession(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
token := insertSession(t, db, userID, now.Add(-time.Minute))
if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound {
t.Fatalf("过期会话应返回 ErrSessionNotFound,得到 %v", err)
}
}
// 改有效期不动已存在的会话:只换进程内的 TTL,库里的 expires_at 原样保留。
func TestSetSessionTTLKeepsExistingExpiry(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
token := insertSession(t, db, userID, now.Add(time.Hour))
stored := dbExpiry(t, db)
svc.SetSessionTTL(72 * time.Hour)
if got := svc.SessionTTL(); got != 72*time.Hour {
t.Fatalf("SessionTTL() = %s,期望 72h", got)
}
if got := dbExpiry(t, db); got != stored {
t.Fatalf("改有效期不该动已存在会话的 expires_at:%s → %s", stored, got)
}
// 旧会话下一次被使用时按新的、更长的有效期顺延(是延长,不是踢下线)。
_, sess, err := svc.Lookup(context.Background(), token)
if err != nil {
t.Fatalf("旧会话不该被踢下线: %v", err)
}
if want := now.Add(72 * time.Hour); !sess.ExpiresAt.Equal(want) {
t.Fatalf("旧会话应顺延到 %s,得到 %s", want, sess.ExpiresAt)
}
}
// 新会话按改后的有效期创建。
func TestCreateSessionUsesCurrentTTL(t *testing.T) {
db := newSettingsDB(t, nil)
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
svc.SetSessionTTL(720 * time.Hour)
if _, sess, err := svc.createSession(context.Background(), userID, "127.0.0.1", "test"); err != nil {
t.Fatalf("创建会话: %v", err)
} else if want := now.Add(720 * time.Hour); !sess.ExpiresAt.Equal(want) {
t.Fatalf("新会话应在 %s 到期,得到 %s", want, sess.ExpiresAt)
}
}
// ---------- 测试脚手架 ----------
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
const testSchema = `
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'recorder',
active INTEGER NOT NULL DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0,
last_login_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);`
func newSettingsDB(t *testing.T, seed map[string]string) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "system.db"))
if err != nil {
t.Fatalf("打开测试库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if _, err := db.Exec(testSchema); err != nil {
t.Fatalf("建表: %v", err)
}
for k, v := range seed {
if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)`, k, v); err != nil {
t.Fatalf("写入设置 %s: %v", k, err)
}
}
return db
}
func insertUser(t *testing.T, db *sql.DB, username string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO users (username, role) VALUES (?, 'admin')`, username)
if err != nil {
t.Fatalf("建用户: %v", err)
}
id, _ := res.LastInsertId()
return id
}
func insertSession(t *testing.T, db *sql.DB, userID int64, expiresAt time.Time) string {
t.Helper()
token := "token-" + expiresAt.Format(time.RFC3339Nano)
if _, err := db.Exec(
`INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`,
"sid", userID, hashToken(token), expiresAt.Format(time.DateTime), expiresAt.Format(time.DateTime)); err != nil {
t.Fatalf("建会话: %v", err)
}
return token
}
func setExpiry(t *testing.T, db *sql.DB, expiresAt time.Time) {
t.Helper()
if _, err := db.Exec(`UPDATE sessions SET expires_at = ?`, expiresAt.Format(time.DateTime)); err != nil {
t.Fatalf("改到期时间: %v", err)
}
}
func setLastSeen(t *testing.T, db *sql.DB, at time.Time) {
t.Helper()
if _, err := db.Exec(`UPDATE sessions SET last_seen_at = ?`, at.Format(time.DateTime)); err != nil {
t.Fatalf("改活动时间: %v", err)
}
}
func dbExpiry(t *testing.T, db *sql.DB) string {
t.Helper()
var v string
if err := db.QueryRow(`SELECT expires_at FROM sessions`).Scan(&v); err != nil {
t.Fatalf("读 expires_at: %v", err)
}
return v
}
func dbLastSeen(t *testing.T, db *sql.DB) string {
t.Helper()
var v string
if err := db.QueryRow(`SELECT last_seen_at FROM sessions`).Scan(&v); err != nil {
t.Fatalf("读 last_seen_at: %v", err)
}
return v
}