Files
Scoreboard/server/internal/auth/session_ttl_test.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

344 lines
13 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)
}
}
// 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。
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
}