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 文件通过。
537 lines
19 KiB
Go
537 lines
19 KiB
Go
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)
|
||
}
|
||
}
|