Files
Scoreboard/server/internal/httpapi/system_test.go
T
AskaEth 6483acf9ad feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动
人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
  都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员

大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
  限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)

图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
  访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
  一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮

关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
  (用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)

安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
  里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
2026-09-10 09:05:06 +08:00

348 lines
13 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 (
"encoding/json"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
)
// waitClosed 等待关闭回调触发;没触发就报错。
func waitClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("等待关闭回调超时")
}
}
func assertNotClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
t.Fatal("鉴权失败时不应触发关闭")
default:
}
}
// waitAuditAction 等待审计落库:Auditor 在 handler 返回后才写,存在极短竞态。
func waitAuditAction(t *testing.T, env *testEnv, action string) int {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
var count int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action).Scan(&count); err != nil {
t.Fatalf("查询审计: %v", err)
}
if count > 0 {
return count
}
if time.Now().After(deadline) {
t.Fatalf("等待审计 %s 超时", action)
}
time.Sleep(10 * time.Millisecond)
}
}
func newShutdownEnv(t *testing.T) (*testEnv, chan struct{}) {
t.Helper()
done := make(chan struct{})
var once sync.Once
return newTestEnvWith(t, func(o *httpapi.Options) {
o.Shutdown = func() { once.Do(func() { close(done) }) }
}), done
}
// POST /api/shutdown:admin 会话可关闭;guest 401、recorder 403;成功只回 {"ok":true} 且写审计。
func TestShutdownByAdminSession(t *testing.T) {
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "")
env, done := newShutdownEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
// 未登录:401,不触发关闭。
if status, env2 := env.do(client, http.MethodPost, "/api/shutdown", nil, false); status != http.StatusUnauthorized ||
env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 关闭应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
assertNotClosed(t, done)
// recorder:403,不触发关闭。
env.createUser("recorder", "recorder-pass", "recorder")
rec, _ := env.client()
env.login(rec, "recorder", "recorder-pass")
if status, env2 := env.do(rec, http.MethodPost, "/api/shutdown", nil, true); status != http.StatusForbidden ||
env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 关闭应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
assertNotClosed(t, done)
// admin:202 + 裸 {"ok":true}(不套 data 包装),并触发关闭回调。
env.login(client, "admin", "admin-pass")
status, raw := env.doRaw(client, http.MethodPost, "/api/shutdown", []byte("{}"), "application/json", true)
if status != http.StatusAccepted {
t.Fatalf("admin 关闭应 202,得到 %d %s", status, raw)
}
if got := strings.TrimSpace(string(raw)); got != `{"ok":true}` {
t.Fatalf("关闭响应应为 {\"ok\":true},得到 %s", got)
}
waitClosed(t, done)
if count := waitAuditAction(t, env, "system.shutdown"); count != 1 {
t.Fatalf("关闭应写 1 条 system.shutdown 审计,得到 %d", count)
}
var after string
if err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = 'system.shutdown' ORDER BY id DESC LIMIT 1`).Scan(&after); err != nil {
t.Fatalf("读取关闭审计: %v", err)
}
if !strings.Contains(after, `"session"`) {
t.Fatalf("关闭审计应标明凭据来源,得到 %s", after)
}
}
// 环境变量配置后:X-Shutdown-Token 正确即可关闭(无需会话),错误则 403。
func TestShutdownByToken(t *testing.T) {
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "s3cret-token")
env, done := newShutdownEnv(t)
client, _ := env.client()
if status, _ := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "wrong"}); status != http.StatusForbidden {
t.Fatalf("错误 token 应 403,得到 %d", status)
}
assertNotClosed(t, done)
status, env2 := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "s3cret-token"})
if status != http.StatusAccepted {
t.Fatalf("正确 token 应 202,得到 %d %+v", status, env2.Error)
}
waitClosed(t, done)
// 没注入关闭回调时应拒绝受理,避免谎报成功。
bare := newTestEnv(t)
bareClient, _ := bare.client()
if status, _ := bare.doH(bareClient, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "s3cret-token"}); status != http.StatusServiceUnavailable {
t.Fatalf("未接入关闭回调应 503,得到 %d", status)
}
}
// GET /api/system/about:版本包里的 buildTime 原样返回;未注入时是空串(前端据此显示「未注入」)。
// 顺带锁住权限:guest 依旧读不到(该端点靠它取内部署名)。
func TestSystemAboutReturnsBuildTime(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/system/about", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读 about 应 403,得到 %d", status)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 模拟 ldflags 注入:直接改包级变量,用例结束后恢复,避免影响其他用例。
original := version.BuildTime
t.Cleanup(func() { version.BuildTime = original })
version.BuildTime = "2026-09-10 08:50:12 +08:00"
status, env2 := env.do(client, http.MethodGet, "/api/system/about", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读 about 应 200,得到 %d %+v", status, env2.Error)
}
var about map[string]any
if err := json.Unmarshal(env2.Data, &about); err != nil {
t.Fatalf("解析 about: %v (%s)", err, env2.Data)
}
if about["buildTime"] != "2026-09-10 08:50:12 +08:00" {
t.Fatalf("buildTime 应原样返回注入值,得到 %#v", about["buildTime"])
}
if _, ok := about["internalSignature"]; !ok {
t.Fatalf("about 仍应带 internalSignature: %v", about)
}
// 未注入时字段仍在、值为空串,前端才能稳定判断「未注入」而不是解析失败。
version.BuildTime = ""
_, env2 = env.do(client, http.MethodGet, "/api/system/about", nil, false)
if err := json.Unmarshal(env2.Data, &about); err != nil {
t.Fatalf("解析 about: %v", err)
}
if got, ok := about["buildTime"]; !ok || got != "" {
t.Fatalf("未注入时 buildTime 应为空串,得到 %#v(存在=%v)", got, ok)
}
}
// GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。
func TestRuleSwitchesReadableByRecorder(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/rules/switches", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读规则开关应 403,得到 %d", status)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读规则开关应 200,得到 %d %+v", status, env2.Error)
}
var switches map[string]any
if err := json.Unmarshal(env2.Data, &switches); err != nil {
t.Fatalf("解析规则开关: %v (%s)", err, env2.Data)
}
want := []string{
"recordBonusEnabled", "adjustmentsEnabled", "adjustmentReasonRequired",
"medalCountsTeamEvents", "showIndividualBoard", "showIndividualMedals",
}
if len(switches) != len(want) {
t.Fatalf("只应返回布尔开关,得到 %v", switches)
}
for _, key := range want {
v, ok := switches[key]
if !ok {
t.Fatalf("缺少开关 %s: %v", key, switches)
}
if _, isBool := v.(bool); !isBool {
t.Fatalf("开关 %s 应为布尔值,得到 %T", key, v)
}
}
// 默认:破纪录加分与人工调整关闭,理由必填、奖牌统计与两块个人榜开关开启。
if switches["recordBonusEnabled"] != false || switches["adjustmentsEnabled"] != false ||
switches["adjustmentReasonRequired"] != true || switches["medalCountsTeamEvents"] != true ||
switches["showIndividualBoard"] != true || switches["showIndividualMedals"] != true {
t.Fatalf("默认开关取值不符: %v", switches)
}
// 规则导入/配置改动后开关随之变化。
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'record_bonus.enabled'`)
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
_, env2 = env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
if err := json.Unmarshal(env2.Data, &switches); err != nil {
t.Fatalf("解析规则开关: %v", err)
}
if switches["recordBonusEnabled"] != true || switches["adjustmentsEnabled"] != true {
t.Fatalf("开关应随 settings 变化: %v", switches)
}
}
// GET /api/settings 与 /api/standings 的 JSON 字段名是对外契约,收敛 DTO 时不能变。
func TestSettingsAndStandingsJSONContract(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
_, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
var settings map[string]any
if err := json.Unmarshal(env2.Data, &settings); err != nil {
t.Fatalf("解析 settings: %v", err)
}
assertKeys(t, "settings", settings, []string{"event", "display", "standings", "defaultProfileId", "requireApproval"})
assertKeys(t, "settings.event", asMap(t, settings["event"]),
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
assertKeys(t, "settings.display", asMap(t, settings["display"]),
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds"})
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
var rows []map[string]any
if err := json.Unmarshal(env2.Data, &rows); err != nil {
t.Fatalf("解析 standings: %v", err)
}
if len(rows) == 0 {
t.Fatal("榜单不应为空")
}
assertKeys(t, "standings[0]", rows[0], []string{
"teamId", "teamCode", "teamName", "shortName", "color", "logoPath",
"rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
})
}
func asMap(t *testing.T, v any) map[string]any {
t.Helper()
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("期望对象,得到 %T", v)
}
return m
}
func assertKeys(t *testing.T, name string, got map[string]any, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s 字段集不符:want=%v got=%v", name, want, got)
}
for _, key := range want {
if _, ok := got[key]; !ok {
t.Fatalf("%s 缺少字段 %s:%v", name, key, got)
}
}
}
type settingsStandings struct {
Standings struct {
ShowIndividualBoard bool `json:"showIndividualBoard"`
ShowIndividualMedals bool `json:"showIndividualMedals"`
} `json:"standings"`
}
// standings.showIndividualMedals 贯通 /api/settings 读写,且不破坏 showIndividualBoard 的别名语义。
func TestSettingsShowIndividualMedals(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK {
t.Fatalf("读配置应 200,得到 %d", status)
}
settings := decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
t.Fatalf("默认两个开关都应为 true: %+v", settings.Standings)
}
// 旧客户端只传 showIndividualBoard:不得影响新的 showIndividualMedals。
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"showIndividualBoard": false},
}, true)
if status != http.StatusOK {
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
}
settings = decodeData[settingsStandings](t, env2)
if settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
t.Fatalf("只改 board 不应影响 medals: %+v", settings.Standings)
}
// 两个开关可以独立配置:board=true + medals=false。
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"showIndividualBoard": true, "showIndividualMedals": false},
}, true)
if status != http.StatusOK {
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
}
settings = decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
t.Fatalf("两个开关应可独立配置: %+v", settings.Standings)
}
// 落库并复读。
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
settings = decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
t.Fatalf("复读配置不符: %+v", settings.Standings)
}
}