Files
Scoreboard/server/internal/httpapi/review_fixes_test.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

537 lines
19 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 httpapi_test
import (
"archive/zip"
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// ---------- 实时层替身 ----------
type fakeRealtime struct {
db *sql.DB
mu sync.Mutex
batches [][]realtime.Change
instances []realtime.Instance
}
func (f *fakeRealtime) Broadcast(changes []realtime.Change) {
f.mu.Lock()
defer f.mu.Unlock()
f.batches = append(f.batches, changes)
}
func (f *fakeRealtime) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) {
q := `SELECT id, entity_type, entity_id, action, payload_json FROM change_log WHERE id > ? ORDER BY id`
args := []any{sinceVersion}
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := f.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := make([]realtime.Change, 0)
for rows.Next() {
var (
ch realtime.Change
entity string
entityID sql.NullInt64
action string
payload string
)
if err := rows.Scan(&ch.Version, &entity, &entityID, &action, &payload); err != nil {
return nil, err
}
ch.Entity = entity
ch.ID = entityID.Int64
ch.Action = action
if json.Valid([]byte(payload)) {
ch.Data = json.RawMessage(payload)
} else {
ch.Data = json.RawMessage(`{}`)
}
out = append(out, ch)
}
return out, rows.Err()
}
func (f *fakeRealtime) Instances() []realtime.Instance { return f.instances }
func (f *fakeRealtime) firstBatch(t *testing.T) []realtime.Change {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
f.mu.Lock()
if len(f.batches) > 0 {
batch := f.batches[0]
f.mu.Unlock()
return batch
}
f.mu.Unlock()
time.Sleep(10 * time.Millisecond)
}
t.Fatal("等待实时广播超时")
return nil
}
func (e *testEnv) doRaw(client *http.Client, method, path string, body []byte, contentType string, csrf bool) (int, []byte) {
e.t.Helper()
req, err := http.NewRequest(method, e.server.URL+path, bytes.NewReader(body))
if err != nil {
e.t.Fatalf("构造请求: %v", err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if csrf {
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
}
resp, err := client.Do(req)
if err != nil {
e.t.Fatalf("请求 %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw
}
// ---------- must_change_password ----------
// 必须改密的账号除改密/登出外一律禁止写操作,避免初始密码长期可用(devdocs/08 第 1 节)。
func TestMustChangePasswordBlocksWrites(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil {
t.Fatalf("设置 must_change_password: %v", err)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 读操作仍放行:前端要能渲染改密页。
if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK {
t.Fatalf("改密前读 me 应 200,得到 %d", status)
}
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "X", "name": "越权单位", "active": true,
}, true)
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("改密前写操作应 403 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
// 改密本身必须放行,否则用户永远无法解除限制。
status, env2 = env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
"currentPassword": "recorder-pass",
"newPassword": "new-pass-123",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改密应 200,得到 %d %+v", status, env2.Error)
}
// 改密后重新登录,写操作恢复。
env.login(client, "recorder", "new-pass-123")
status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "X", "name": "越权单位", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改密后写操作应 200,得到 %d %+v", status, env2.Error)
}
}
// 登出也必须放行,否则改密失败的用户无法退出。
func TestMustChangePasswordAllowsLogout(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil {
t.Fatalf("设置 must_change_password: %v", err)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, env2 := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("改密前登出应 200,得到 %d %+v", status, env2.Error)
}
}
// ---------- X-Forwarded-For 伪造 ----------
// 默认不信任代理头:伪造 XFF 不能绕过登录限流(否则等于没有限流)。
func TestSpoofedForwardedForDoesNotBypassLoginLimit(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
for i := 0; i < 5; i++ {
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{
"username": "recorder", "password": "wrong-pass",
}, false, map[string]string{"X-Forwarded-For": fmt.Sprintf("203.0.113.%d", i+1)})
if status != http.StatusUnauthorized {
t.Fatalf("第 %d 次错误登录应 401,得到 %d %+v", i+1, status, env2.Error)
}
}
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{
"username": "recorder", "password": "wrong-pass",
}, false, map[string]string{"X-Forwarded-For": "203.0.113.250"})
if status != http.StatusTooManyRequests || env2.Error == nil || env2.Error.Code != "RATE_LIMITED" {
t.Fatalf("伪造 XFF 后第 6 次应被限流 429,得到 %d %+v", status, env2.Error)
}
}
// ---------- 流水幂等 ----------
// 重复发布不能累积破纪录加分流水,同时必须保留人工调整流水(devdocs/02 §6)。
func TestPublishDoesNotAccumulateRecordBonusAndKeepsAdjustments(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("首次发布应 200,得到 %d %+v", status, env2.Error)
}
// 模拟已存在的破纪录加分流水与人工调整流水。
env.exec(t, `INSERT INTO score_ledger (team_id, event_id, entry_id, source_type, source_id, points)
VALUES (?, ?, ?, 'record_bonus', ?, 5)`, ids.teamA, ids.event, ids.entryA, ids.entryA)
env.exec(t, `INSERT INTO score_ledger (team_id, source_type, source_id, points)
VALUES (?, 'adjustment', 1, 3)`, ids.teamA)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-2",
}, true); status != http.StatusOK {
t.Fatalf("再次发布应 200,得到 %d %+v", status, env2.Error)
}
var activeBonus, activeAdjust, activeResult int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger
WHERE voided = 0 AND source_type = 'record_bonus' AND event_id = ?`, ids.event).Scan(&activeBonus); err != nil {
t.Fatalf("统计破纪录流水: %v", err)
}
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0 AND source_type = 'adjustment'`).Scan(&activeAdjust); err != nil {
t.Fatalf("统计调整流水: %v", err)
}
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger
WHERE voided = 0 AND source_type = 'result' AND event_id = ?`, ids.event).Scan(&activeResult); err != nil {
t.Fatalf("统计成绩流水: %v", err)
}
if activeBonus != 0 {
t.Fatalf("重算应作废旧破纪录加分流水且不重复累积,得到 %d 条", activeBonus)
}
if activeAdjust != 1 {
t.Fatalf("重算不得作废人工调整,得到 %d 条", activeAdjust)
}
if activeResult != 2 {
t.Fatalf("重算后成绩流水应仍为 2 条,得到 %d", activeResult)
}
}
// ---------- standings 变更载荷 ----------
// standings 的 replace 载荷必须是整张榜单数组(devdocs/05 第 2 节),否则大屏会被清空。
func TestPublishChangeLogCarriesFullStandingsSnapshot(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
var payload string
if err := env.ev.QueryRow(`SELECT payload_json FROM change_log
WHERE entity_type = 'standings' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil {
t.Fatalf("读取 standings 变更: %v", err)
}
var rows []map[string]any
if err := json.Unmarshal([]byte(payload), &rows); err != nil {
t.Fatalf("standings 载荷应为数组: %v (%s)", err, payload)
}
if len(rows) != 2 {
t.Fatalf("standings 载荷应含 2 个单位,得到 %d (%s)", len(rows), payload)
}
if rows[0]["teamCode"] != "A" || rows[0]["totalPoints"].(float64) != 10 {
t.Fatalf("standings 载荷内容不对: %+v", rows[0])
}
// 发布前必须留备份(08 第 6 节):成功时写 system.backup 审计。
var backupAudits int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs
WHERE action = 'system.backup' AND after_json LIKE '%publish%'`).Scan(&backupAudits); err != nil {
t.Fatalf("查询备份审计: %v", err)
}
if backupAudits == 0 {
t.Fatalf("发布应写入发布前备份审计")
}
}
// ---------- 赛事包内容 ----------
// 赛事包必须内含 rules.yaml 与 uploads/(devdocs/04 §1.1),否则跨机无法复现。
func TestExportProjectZipIncludesRulesAndUploads(t *testing.T) {
projectDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(projectDir, "uploads"), 0o755); err != nil {
t.Fatalf("创建 uploads: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "uploads", "logo.txt"), []byte("logo"), 0o644); err != nil {
t.Fatalf("写 uploads 文件: %v", err)
}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
resp, err := client.Get(env.server.URL + "/api/export/project.zip")
if err != nil {
t.Fatalf("导出赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err)
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("读取 zip: %v", err)
}
names := map[string]bool{}
for _, f := range zr.File {
names[f.Name] = true
}
for _, want := range []string{"meta.yaml", "event.db", "rules.yaml", "uploads/logo.txt"} {
if !names[want] {
t.Fatalf("赛事包缺少 %s,实际含 %v", want, names)
}
}
}
// ---------- result.points ----------
// 已发布成绩必须带得分,供录入页预览与审查页对比。
func TestPublishedResultsReturnPoints(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/results?eventId=%d&state=published", ids.event), nil, false)
if status != http.StatusOK {
t.Fatalf("读取成绩应 200,得到 %d", status)
}
page := decodeData[struct {
Items []map[string]any `json:"items"`
}](t, env2)
items := page.Items
if len(items) != 2 {
t.Fatalf("应返回 2 条成绩,得到 %d", len(items))
}
for _, it := range items {
if it["points"] == nil {
t.Fatalf("已发布成绩应返回 points: %+v", it)
}
}
}
// ---------- records 表写入 ----------
// 批准破纪录必须更新纪录库的当前纪录(devdocs/02 §3、devdocs/04 records)。
func TestApproveRecordBreakUpsertsRecords(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 破纪录登记要求该报名项已有已发布成绩,先落一条。
env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, result_text, unit, status, state)
VALUES (?, ?, 1, 10.5, '10.50', 's', 'ok', 'published')`, ids.event, ids.entryA)
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50",
}, true)
if status != http.StatusOK {
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
breakID := int64(created["id"].(float64))
if status, env2 = env.do(client, http.MethodPost,
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK {
t.Fatalf("批准破纪录应 200,得到 %d %+v", status, env2.Error)
}
var (
holder sql.NullString
teamID sql.NullInt64
isCurrent bool
source string
)
err := env.ev.QueryRow(`SELECT holder_name, holder_team_id, is_current, source
FROM records WHERE event_id = ? AND level = 'meet'`, ids.event).
Scan(&holder, &teamID, &isCurrent, &source)
if err != nil {
t.Fatalf("查询纪录库: %v", err)
}
if !holder.Valid || holder.String != "张伟" {
t.Fatalf("纪录持有者应为运动员姓名,得到 %+v", holder)
}
if !teamID.Valid || teamID.Int64 != ids.teamA {
t.Fatalf("纪录单位应为 %d,得到 %+v", ids.teamA, teamID)
}
if !isCurrent || source != "current" {
t.Fatalf("新纪录应 is_current=1 source=current,得到 isCurrent=%v source=%q", isCurrent, source)
}
}
// ---------- 规则导入审计 ----------
// 规则导入成功后必须留审计(devdocs/02 §7.2、08 §3)。
func TestRulesImportWritesAudit(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
resp, err := client.Get(env.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("导出规则包: %v", err)
}
pack, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", pack, "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
}
var count int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'rules.import'`).Scan(&count); err != nil {
t.Fatalf("查询审计: %v", err)
}
if count != 1 {
t.Fatalf("规则导入应写 1 条审计,得到 %d", count)
}
}
// ---------- 实时广播与在线看板 ----------
// 写事务提交后要把新变更推给实时层;大屏实例列表对 recorder 开放。
func TestWritesBroadcastChangesAndListDisplayInstances(t *testing.T) {
fake := &fakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
fake.db = env.ev
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
batch := fake.firstBatch(t)
found := false
for _, ch := range batch {
if ch.Entity == "standings" {
found = true
var rows []map[string]any
if err := json.Unmarshal(ch.Data, &rows); err != nil || len(rows) == 0 {
t.Fatalf("广播的 standings 应为非空数组: %v %s", err, ch.Data)
}
}
}
if !found {
t.Fatalf("广播批次应包含 standings 变更: %+v", batch)
}
fake.instances = []realtime.Instance{{ID: "display-1", Online: true, Page: "standings"}}
status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false)
if status != http.StatusOK {
t.Fatalf("读取大屏实例应 200,得到 %d %+v", status, env2.Error)
}
list := decodeData[[]map[string]any](t, env2)
if len(list) != 1 || list[0]["id"] != "display-1" {
t.Fatalf("大屏实例列表不对: %+v", list)
}
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读大屏实例应 403,得到 %d", status)
}
}