Files
Scoreboard/server/internal/httpapi/system_test.go
T
AskaEth 2c9ff65fc4 feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对)
  与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的
  重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端
- export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep),
  自动、手动、发布前三类备份共用同一把互斥锁与滚动清理
- realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行,
  跨站需命中 server.allowedOrigins)
- console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀),
  服务管理器新增「已连接的大屏实例」卡片
- deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空,
  导致最近这次构建的 exe 里根本没注入署名
- docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
2026-09-09 21:41:08 +08:00

302 lines
11 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"
)
// 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/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)
}
}