Files
Scoreboard/server/internal/httpapi/review_fixes_test.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

533 lines
18 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")
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)
}
}