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", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"}) assertKeys(t, "settings.standings", asMap(t, settings["standings"]), []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"}) _, 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) } }