Files
Scoreboard/server/internal/httpapi/session_revoke_test.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

211 lines
8.7 KiB
Go

package httpapi_test
import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// countUserSessions 数某账号在库里的会话行。
func countUserSessions(t *testing.T, env *testEnv, userID int64) int {
t.Helper()
var n int
if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&n); err != nil {
t.Fatalf("统计会话: %v", err)
}
return n
}
// meRole 走 /api/auth/me 读当前身份:会话被撤之后这里会退回 guest。
func meRole(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读 /api/auth/me 应 200,得到 %d %+v", status, body.Error)
}
return decodeData[map[string]any](t, body)["role"].(string)
}
// 会话失效的三条管理动作(重置密码 / 停用 / 自己改密)必须**当场**生效:
// 旧 Cookie 下一次请求就该被当成访客,库里那行会话也该没了。
// 在此之前这些承诺只有 auth 包内的函数级断言,没有一条走 HTTP 的端到端用例——
// 管理端 handler 漏调撤会话(或撤了却忘了清 Cookie)不会有任何东西变红。
func TestAdminActionsInvalidateSessionsImmediately(t *testing.T) {
env := newTestEnv(t)
admin := adminClient(t, env)
uid := env.createUser("recorder", "recorder-pass", "recorder")
worker, _ := env.client()
env.login(worker, "recorder", "recorder-pass")
if got := meRole(t, env, worker); got != "recorder" {
t.Fatalf("登录后身份应为 recorder,得到 %q", got)
}
// ① 管理员重置密码:旧会话立即失效(旧 Cookie 直接当访客用),库里的行也没了。
status, body := env.do(admin, http.MethodPost, fmt.Sprintf("/api/users/%d/password", uid),
map[string]any{"password": "new-recorder-pass"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("重置密码应 200,得到 %d %+v", status, body.Error)
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("重置密码后该用户的会话行应清空,库里还剩 %d 行", n)
}
if got := meRole(t, env, worker); got != "guest" {
t.Fatalf("重置密码后旧会话应退回 guest,得到 %q", got)
}
status, body = env.do(worker, http.MethodPost, "/api/teams",
map[string]any{"code": "X1", "name": "旧会话不该还能写"}, true)
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("重置密码后旧会话的写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
}
// 旧密码登不进去,新密码可以:证明失效的是会话,不是账号。
fresh, _ := env.client()
if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login",
map[string]any{"username": "recorder", "password": "recorder-pass"}, false); status == http.StatusOK {
t.Fatalf("重置密码后旧密码不该还能登录,得到 %d", status)
}
env.login(fresh, "recorder", "new-recorder-pass")
if got := meRole(t, env, fresh); got != "recorder" {
t.Fatalf("新密码登录后身份应为 recorder,得到 %q", got)
}
// ② 停用账号:刚建好的这条新会话也必须当场失效。
status, body = env.do(admin, http.MethodPut, fmt.Sprintf("/api/users/%d", uid),
map[string]any{"active": false}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("停用账号应 200,得到 %d %+v", status, body.Error)
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("停用后该用户的会话行应清空,库里还剩 %d 行", n)
}
if got := meRole(t, env, fresh); got != "guest" {
t.Fatalf("停用后旧会话应退回 guest,得到 %q", got)
}
if status, _ := env.do(fresh, http.MethodPost, "/api/auth/login",
map[string]any{"username": "recorder", "password": "new-recorder-pass"}, false); status == http.StatusOK {
t.Fatalf("停用后不该还能登录,得到 %d", status)
}
}
// 自己改密:本人的会话也要当场作废,并明确告诉前端「重新登录」。
// 只清 Cookie 不撤会话(或反过来)都算没做到——前者旧 Cookie 还能用,后者页面看着没退。
func TestChangeOwnPasswordInvalidatesOwnSession(t *testing.T) {
env := newTestEnv(t)
uid := env.createUser("recorder", "recorder-pass", "recorder")
client, jar := env.client()
env.login(client, "recorder", "recorder-pass")
// 改密前先把旧会话 token 记下来:改密之后要用它重放(模拟「Cookie 被抄走/浏览器还没收到清除指令」)。
u, err := url.Parse(env.server.URL)
if err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
oldSession := ""
for _, c := range jar.Cookies(u) {
if c.Name == auth.CookieSession {
oldSession = c.Value
}
}
if oldSession == "" {
t.Fatalf("登录后 Cookie 里应有 %s", auth.CookieSession)
}
status, body := env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "recorder-pass", "newPassword": "brand-new-pass"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改密应 200,得到 %d %+v", status, body.Error)
}
if relogin, _ := decodeData[map[string]any](t, body)["relogin"].(bool); !relogin {
t.Fatalf("改密回执应带 relogin=true 引导重新登录,得到 %+v", decodeData[map[string]any](t, body))
}
if n := countUserSessions(t, env, uid); n != 0 {
t.Fatalf("改密后自己的会话行应清空,库里还剩 %d 行", n)
}
// 服务端下发了删除指令:浏览器不会再带着这枚 Cookie 发请求。
for _, c := range jar.Cookies(u) {
if c.Name == auth.CookieSession && c.Value != "" && c.MaxAge >= 0 {
t.Fatalf("改密应答应清掉会话 Cookie,得到 %+v", c)
}
}
// 旧 Cookie 手工塞回来重放也是访客:撤会话与清 Cookie 两件事必须都做到,
// 只做后者的话,任何抓到过旧 Cookie 的地方都还能继续用这个身份。
replay, err := http.NewRequest(http.MethodGet, env.server.URL+"/api/auth/me", nil)
if err != nil {
t.Fatalf("构造重放请求: %v", err)
}
replay.AddCookie(&http.Cookie{Name: auth.CookieSession, Value: oldSession})
resp, err := (&http.Client{}).Do(replay)
if err != nil {
t.Fatalf("重放旧 Cookie: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(raw), `"role":"guest"`) {
t.Fatalf("旧 Cookie 重放应被当成访客,得到 %d %s", resp.StatusCode, raw)
}
}
// 滑动续期必须带着 Cookie 的 Max-Age 一起走,并且 Max-Age 跟的是**该会话的剩余寿命**,
// 不是当前有效期:有效期被改短之后,老会话仍按自己的到期时间下发(否则浏览器比服务端早丢 Cookie)。
func TestMeRefreshesCookieMaxAgeFromSessionExpiry(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
// 登录时会话到期 = 登录时刻 + 24h;把有效期改短成 1 小时(不动已存在的会话)。
if status, body := env.do(client, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 1}, true); status != http.StatusOK {
t.Fatalf("把有效期改成 1 小时应 200,得到 %d %+v", status, body.Error)
}
stored := sessionExpiry(t, env, "admin")
resp, err := client.Get(env.server.URL + "/api/auth/me")
if err != nil {
t.Fatalf("请求 /auth/me: %v", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, resp.Body)
var session, csrf *http.Cookie
for _, c := range resp.Cookies() {
switch c.Name {
case auth.CookieSession:
session = c
case auth.CookieCSRF:
csrf = c
}
}
if session == nil || csrf == nil {
t.Fatalf("/auth/me 应重发会话与 CSRF Cookie(滑动续期要同步浏览器端的 Max-Age),得到会话 %+v / CSRF %+v",
session, csrf)
}
// 剩余寿命 = 库里那条会话的到期时间 - 现在(容许几秒误差)。
want := int(time.Until(stored).Seconds())
if diff := session.MaxAge - want; diff > 3 || diff < -3 {
t.Fatalf("会话 Cookie 的 Max-Age 应等于这条会话的剩余寿命 %d 秒,得到 %d", want, session.MaxAge)
}
// 关键对照:有效期已经改成 1 小时了,Max-Age 不能跟着它变成 3600。
if session.MaxAge < 20*60*60 {
t.Fatalf("Max-Age 跟错了对象(疑似用了当前有效期而非会话到期时间):%d,库里到期时间是 %s",
session.MaxAge, stored.Format(time.RFC3339))
}
// CSRF 不轮换:值必须与 Cookie 里原样一致,否则飞行中的写请求会集体 403。
if _, err := url.Parse(env.server.URL); err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
if csrf.MaxAge != session.MaxAge {
t.Fatalf("CSRF Cookie 应与会话 Cookie 同一期限,得到 %d / %d", csrf.MaxAge, session.MaxAge)
}
if strings.TrimSpace(csrf.Value) == "" {
t.Fatalf("重发的 CSRF Cookie 不该是空值")
}
}