Files
Scoreboard/server/internal/httpapi/enums_internal_test.go
T
AskaEth 2c983bcfdf fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理
服务端一批「会误导现场」的缺陷,全部补了测试:

- 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、
  scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status)
  过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成
  服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED,
  message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。
- 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的
  原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文,
  日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数,
  拦截点不再退化成 response.go 里的构造函数。
- /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」),
  超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号,
  数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。
- 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential):
  401 的语义是「本请求没有有效会话」,而这里会话是好的。
- 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动,
  否则堆键数就能冲掉别人的计数)。
- createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。
  数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results /
  score_ledger 的级联,风险高不做),这一层先兜住。
- 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、
  5xx 不外泄原文、限流键清理不误删。
2026-09-11 14:28:25 +08:00

182 lines
6.1 KiB
Go

package httpapi
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// createEntry / updateEntry 目前没有挂路由,只能在包内直接调用验证。
func newEntryTestServer(t *testing.T) (*server, *sql.DB) {
t.Helper()
db, err := sql.Open("sqlite",
"file:"+filepath.Join(t.TempDir(), "event.db")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
return &server{ev: db, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, db
}
type entryFixture struct {
eventA, eventB int64
catA, catB int64
team int64
}
func seedEntryFixture(t *testing.T, db *sql.DB) entryFixture {
t.Helper()
exec := func(query string, args ...any) int64 {
res, err := db.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("取 id 失败: %v", err)
}
return id
}
sport := exec(`INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
var f entryFixture
f.eventA = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'A100', '甲组100米', 'pending')`, sport)
f.eventB = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'B100', '乙组100米', 'pending')`, sport)
f.catA = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子A', 1)`, f.eventA)
f.catB = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子B', 1)`, f.eventB)
f.team = exec(`INSERT INTO teams (code, name) VALUES ('T1', '第一队')`)
return f
}
func requireAPIStatus(t *testing.T, err error, status int) *apiError {
t.Helper()
if err == nil {
t.Fatalf("期望错误,得到 nil")
}
var api *apiError
if !errors.As(err, &api) {
t.Fatalf("期望 *apiError,得到 %T: %v", err, err)
}
if api.status != status {
t.Fatalf("期望状态 %d,得到 %d (%s)", status, api.status, api.message)
}
return api
}
func TestNormalizeEntryStatus(t *testing.T) {
for _, tc := range []struct{ raw, want string }{
{"", "registered"},
{"dns", "dns"},
{" scratched ", "scratched"},
} {
got, err := normalizeEntryStatus(tc.raw)
if err != nil || got != tc.want {
t.Fatalf("normalizeEntryStatus(%q) = %q, %v;期望 %q", tc.raw, got, err, tc.want)
}
}
got, err := normalizeEntryStatus("bogus")
if got != "" || err == nil {
t.Fatalf("非法 status 应报错,得到 %q, %v", got, err)
}
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.code != "VALIDATION_FAILED" {
t.Fatalf("非法 status 的 code 应是 VALIDATION_FAILED,得到 %q", api.code)
}
if !strings.Contains(api.message, "status") {
t.Fatalf("错误文案应带字段名,得到 %q", api.message)
}
if api.detail != "bogus" {
t.Fatalf("detail 应是原始值,得到 %v", api.detail)
}
}
func TestCreateEntryRejectsForeignCategory(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
ctx := context.Background()
_, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catB, TeamID: f.team, Status: "registered"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.message != "categoryId 不属于该小项" {
t.Fatalf("错误文案应是「categoryId 不属于该小项」,得到 %q", api.message)
}
if api.detail != f.catB {
t.Fatalf("detail 应是 categoryId %d,得到 %v", f.catB, api.detail)
}
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
t.Fatalf("统计报名项: %v", err)
}
if n != 0 {
t.Fatalf("组别不属于小项时不该落库,entries 有 %d 行", n)
}
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "dq"})
if err != nil {
t.Fatalf("同小项组别应创建成功: %v", err)
}
if created.CategoryID != f.catA || created.EventID != f.eventA || created.Status != "dq" {
t.Fatalf("报名项字段不对: %+v", created)
}
}
func TestCreateEntryRejectsUnknownStatus(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
_, err := s.createEntry(context.Background(),
entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "bogus"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.code != "VALIDATION_FAILED" || !strings.Contains(api.message, "status") {
t.Fatalf("非法 status 应 400 VALIDATION_FAILED 且带字段名,得到 %+v", api)
}
}
func TestUpdateEntryCategoryCheckKeepsNotFound(t *testing.T) {
s, db := newEntryTestServer(t)
f := seedEntryFixture(t, db)
ctx := context.Background()
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "registered"})
if err != nil {
t.Fatalf("准备报名项失败: %v", err)
}
// 组别必须属于该报名项所在小项(updateEntry 不改 event_id)。
_, err = s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catB, TeamID: f.team, Status: "registered"})
api := requireAPIStatus(t, err, http.StatusBadRequest)
if api.message != "categoryId 不属于该小项" || api.detail != f.catB {
t.Fatalf("非法组别应被拦下,得到 %+v", api)
}
updated, err := s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "scratched"})
if err != nil {
t.Fatalf("同小项组别应更新成功: %v", err)
}
if updated.CategoryID != f.catA || updated.Status != "scratched" {
t.Fatalf("更新结果不对: %+v", updated)
}
// 报名项不存在仍是 404,不能被组别校验降级成 400。
_, err = s.updateEntry(ctx, created.ID+9999, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "registered"})
api = requireAPIStatus(t, err, http.StatusNotFound)
if api.message != "报名项不存在" {
t.Fatalf("不存在的报名项应 404「报名项不存在」,得到 %+v", api)
}
}