Files
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

344 lines
14 KiB
Go
Raw Permalink 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 httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// cookieValue 读 jar 里某个 Cookie 的当前值;没有则返回空串。
func (e *testEnv) cookieValue(t *testing.T, client *http.Client, name string) string {
t.Helper()
u, err := url.Parse(e.server.URL)
if err != nil {
t.Fatalf("解析测试服务地址: %v", err)
}
for _, c := range client.Jar.Cookies(u) {
if c.Name == name {
return c.Value
}
}
return ""
}
// ---------- A38:/auth/me 在 CSRF Cookie 丢失时补发 ----------
// 会话有效但这台机器没有 CSRF Cookie 时,写操作会永久 403,而刷新页面走的还是 /auth/me,
// 补不回 Cookie。修法是就地签发一枚新 token 并下发——会话本身不轮换。
func TestMeReissuesMissingCSRFCookie(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, jar := env.client()
env.login(client, "admin", "admin-pass")
session := env.cookieValue(t, client, auth.CookieSession)
// 模拟丢票:浏览器清理、换端口 / 换域名后残留、手工拷了会话 Cookie。
u, _ := url.Parse(env.server.URL)
jar.SetCookies(u, []*http.Cookie{{Name: auth.CookieCSRF, Value: "", MaxAge: -1, Path: "/"}})
if got := env.csrfToken(client); got != "" {
t.Fatalf("前置条件:此时 jar 里不该还有 CSRF Cookie,得到 %q", got)
}
status, header, body := env.getRaw(t, client, "/api/auth/me")
if status != http.StatusOK {
t.Fatalf("me 应 200,得到 %d:%s", status, body)
}
var setCookie bool
for _, v := range header.Values("Set-Cookie") {
if strings.Contains(v, auth.CookieCSRF+"=") {
setCookie = true
}
}
if !setCookie {
t.Fatalf("/auth/me 必须补发 CSRF Cookie,响应头是 %v", header.Values("Set-Cookie"))
}
if env.csrfToken(client) == "" {
t.Fatal("补发的 CSRF Cookie 应落进 jar 且有值")
}
// 补的是票,不是新登录:会话 Cookie 不能被换掉。
if got := env.cookieValue(t, client, auth.CookieSession); got != session {
t.Fatalf("补票不该轮换会话:%s → %s", session, got)
}
// 新票立即可用:带上它写就通过(这正是「能看不能写」的自愈路径)。
if status, resp := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"event": map[string]any{"name": "补票后写入"}}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("持补发票的写请求应通过,得到 %d %+v", status, resp.Error)
}
}
// ---------- A37:密码下限按字符判 ----------
// 「至少 8 位」是按字符说的,前端也按字符数判;按字节判会放行 7 个汉字这种
// 界面明确拒绝的口令,同一口令两个口径。上限仍按字节(bcrypt 只吃 72 字节)。
func TestChangePasswordLengthCountsRunes(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// 7 个汉字 = 21 字节:按字节判会放行,必须按字符拒掉。
status, body := env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七"}, true)
if status != http.StatusBadRequest || body.Error == nil || body.Error.Message != "新密码至少 8 位" {
t.Fatalf("7 个字符的口令应 400「新密码至少 8 位」,得到 %d %+v", status, body.Error)
}
// 25 个汉字 = 75 字节:字符数够,但超过 bcrypt 的 72 字节上限,仍要拒。
long := strings.Repeat("汉", 25)
status, body = env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": long}, true)
if status != http.StatusBadRequest || body.Error == nil ||
body.Error.Message != fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes) {
t.Fatalf("超 72 字节应 400 字节上限,得到 %d %+v", status, body.Error)
}
// 失败的改密不该动会话:紧接着还能再改一次。
// 8 个汉字 = 24 字节:字符与字节都合法,收下。
status, body = env.do(client, http.MethodPost, "/api/auth/password",
map[string]any{"currentPassword": "admin-pass", "newPassword": "一二三四五六七八"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("8 个字符的口令应被接受,得到 %d %+v", status, body.Error)
}
var hash string
if err := env.sys.QueryRow(`SELECT password_hash FROM users WHERE username = 'admin'`).Scan(&hash); err != nil {
t.Fatalf("读密码哈希: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("一二三四五六七八")); err != nil {
t.Fatalf("落库的应是这串汉字口令: %v", err)
}
}
// ---------- A05:漏传 lastPlaceZero 不能静默变成 false ----------
func TestUpdateProfileKeepsLastPlaceZeroWhenOmitted(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-LPZ-PUT", "name": "最后一名归零", "lastPlaceZero": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建模板应 200,得到 %d %+v", status, resp.Error)
}
id := int64(decodeData[map[string]any](t, resp)["id"].(float64))
path := fmt.Sprintf("/api/scoring-profiles/%d", id)
// 只想改名字的 PUT:漏传 lastPlaceZero 必须沿用库里的值,而不是被 bool 零值写成 false。
status, resp = env.do(client, http.MethodPut, path,
map[string]any{"code": "P-LPZ-PUT", "name": "改名不改开关"}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改模板应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 1 {
t.Fatalf("漏传 lastPlaceZero 不该把它写成 0,库里是 %d", got)
}
// 显式传 false 才是真的关掉。
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-LPZ-PUT", "name": "显式关掉", "lastPlaceZero": false,
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("显式 false 应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 0 {
t.Fatalf("显式 false 应落库为 0,库里是 %d", got)
}
// 显式传 true 再打开。
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-LPZ-PUT", "name": "显式打开", "lastPlaceZero": true,
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("显式 true 应 200,得到 %d %+v", status, resp.Error)
}
if got := profileLastPlaceZero(t, env, id); got != 1 {
t.Fatalf("显式 true 应落库为 1,库里是 %d", got)
}
}
func profileLastPlaceZero(t *testing.T, env *testEnv, id int64) int {
t.Helper()
var v int
if err := env.ev.QueryRow(`SELECT last_place_zero FROM scoring_profiles WHERE id = ?`, id).Scan(&v); err != nil {
t.Fatalf("读 last_place_zero: %v", err)
}
return v
}
// ---------- A11:号码布查重必须覆盖团体行 ----------
// 团体行(athlete_name 为空)原先完全不参与号码布查重:填了号码布既不登记也不检查,
// 同一张表里两个团体行共用一个号码布会静默落库。
func TestImportChecksBibOnTeamRows(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{
name: "两个团体行共用一个号码布",
body: importHeader + "\n" +
"M100,男子,A,,,男,,B3001,,registered,张三/李四\n" +
"M100,男子,B,,,男,,B3001,,registered,王五/赵六\n",
},
{
name: "团体行与个人行共用一个号码布(团体行在前)",
body: importHeader + "\n" +
"M100,男子,A,,,男,,B3002,,registered,张三/李四\n" +
"M100,男子,B,张三,,男,,B3002,,registered,\n",
},
{
name: "团体行与个人行共用一个号码布(个人行在前)",
body: importHeader + "\n" +
"M100,男子,A,张三,,男,,B3003,,registered,\n" +
"M100,男子,B,,,男,,B3003,,registered,王五/赵六\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
status, resp := env.postImport(t, client, "/api/entries/import", tc.body, "")
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[map[string]any](t, resp)
if result["created"] != float64(1) || result["skipped"] != float64(1) {
t.Fatalf("首行应落库、重号行应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") {
t.Fatalf("应报号码布重复,得到 %+v", errs)
}
})
}
// 号码布不同的团体行照常导入,且号码布真的落进报名行。
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,,,男,,B3101,,registered,张三/李四\n" +
"M100,男子,B,,,男,,B3102,,registered,王五/赵六\n"
status, resp := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[map[string]any](t, resp); result["created"] != float64(2) || result["skipped"] != float64(0) {
t.Fatalf("号码布不重复的团体行都该落库: %+v", result)
}
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries WHERE bib IN ('B3101', 'B3102')`).Scan(&n); err != nil {
t.Fatalf("统计报名号码布: %v", err)
}
if n != 2 {
t.Fatalf("团体行的号码布应写进报名,得到 %d 行", n)
}
}
// ---------- C14:登出要写审计 ----------
func TestLogoutWritesAudit(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("登出应 200,得到 %d", status)
}
var actorID sql.NullInt64
if err := env.ev.QueryRow(
`SELECT user_id FROM audit_logs WHERE action = 'auth.logout' ORDER BY id DESC LIMIT 1`).Scan(&actorID); err != nil {
t.Fatalf("登出会真删会话行,必须写审计: %v", err)
}
if !actorID.Valid || actorID.Int64 != userID {
t.Fatalf("审计应记在真实用户 %d 上,得到 %+v", userID, actorID)
}
// 访客没有会话可撤:不记(否则 audit_logs 里会出现 user_id 为空的行)。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/auth/logout", nil, false); status != http.StatusOK {
t.Fatalf("访客登出应 200,得到 %d", status)
}
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'auth.logout'`).Scan(&n); err != nil {
t.Fatalf("统计登出审计: %v", err)
}
if n != 1 {
t.Fatalf("访客登出不该写审计,得到 %d 条", n)
}
}
// ---------- A39:反代改写 Host 时的登录(端到端) ----------
// 现场场景:nginx 把 Host 改写成回源地址(127.0.0.1:port),浏览器带的 Origin 是外部域名。
// 只要直连对端在可信代理白名单里、并透传了原 Host,登录就该照常成功;
// 反过来,没配可信代理时服务端不采信任何代理头——同源校验不放水。
func TestLoginBehindTrustedProxyWithForwardedHost(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
forwarded := map[string]string{
"Origin": "http://scoreboard.local",
"X-Forwarded-Host": "scoreboard.local",
}
// 没配可信代理:r.Host(回源地址)与 Origin 不一致 → 403,提示指向反代配置。
status, resp := env.doH(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded)
if status != http.StatusForbidden || resp.Error == nil || resp.Error.Code != "FORBIDDEN" {
t.Fatalf("未配置可信代理时应 403(不采信代理头),得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "Host") {
t.Fatalf("提示应说明 Host 与 Origin 不一致,得到 %q", resp.Error.Message)
}
// 把直连对端登记为可信代理:代理透传的原 Host 与 Origin 一致 → 登录放行。
if err := middleware.SetTrustedProxies([]string{"127.0.0.1"}); err != nil {
t.Fatalf("配置可信代理: %v", err)
}
t.Cleanup(func() { _ = middleware.SetTrustedProxies(nil) })
status, resp = env.doH(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": "admin", "password": "admin-pass"}, false, forwarded)
if status != http.StatusOK || !resp.OK {
t.Fatalf("可信代理透传原 Host 时登录应成功,得到 %d %+v", status, resp.Error)
}
if env.cookieValue(t, client, auth.CookieSession) == "" {
t.Fatal("登录成功应下发会话 Cookie")
}
}
// ---------- C15:登录限流不再是死配置 ----------
// 迁移 0005 种下的 security.login_rate_per_minute 必须真的被读(此前全仓无人引用)。
func TestLoginRateSettingFromMigrationIsRead(t *testing.T) {
env := newTestEnv(t)
var seeded string
if err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, auth.LoginRateKey).Scan(&seeded); err != nil {
t.Fatalf("迁移应种下 %s: %v", auth.LoginRateKey, err)
}
if seeded != strconv.Itoa(auth.DefaultLoginPerMinute) {
t.Fatalf("迁移种下的值应与内置默认一致,得到 %q", seeded)
}
// 「重启后」(新起一个 Service)读库里的值:改成 2 之后第 3 次登录就该被限流。
if _, err := env.sys.Exec(`UPDATE settings SET value = '2' WHERE key = ?`, auth.LoginRateKey); err != nil {
t.Fatalf("改登录限流设置: %v", err)
}
svc := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost})
for i := 0; i < 2; i++ {
if !svc.AllowLogin("127.0.0.1") {
t.Fatalf("第 %d 次应放行", i+1)
}
}
if svc.AllowLogin("127.0.0.1") {
t.Fatal("第 3 次应被限流——库里的登录限流设置没有被读到")
}
}