服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(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 不外泄原文、限流键清理不误删。
295 lines
11 KiB
Go
295 lines
11 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// detailOf 把错误响应的 detail 还原成字符串,用来断言「detail 是原始入参」。
|
|
func detailOf(t *testing.T, raw json.RawMessage) string {
|
|
t.Helper()
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
t.Fatalf("detail 应是原始值字符串: %v (%s)", err, raw)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// 枚举入参非法时必须是 400 VALIDATION_FAILED,且 message 带字段名、detail 带原始值;
|
|
// 而不是一路落到 SQLite 的 CHECK 上变成 500 INTERNAL。
|
|
func TestEventEnumValidation(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ids := env.seed(t)
|
|
client := recorderClient(t, env)
|
|
|
|
for i, tc := range []struct{ field, value string }{
|
|
{"eventType", "bogus"},
|
|
{"resultMode", "bogus"},
|
|
{"resultType", "bogus"},
|
|
{"compare", "bogus"},
|
|
{"status", "bogus"},
|
|
} {
|
|
status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
|
"sportId": ids.sport, "code": fmt.Sprintf("E-BAD-%d", i), "name": "非法枚举",
|
|
tc.field: tc.value,
|
|
}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("%s=%q 应 400 VALIDATION_FAILED,得到 %d %+v", tc.field, tc.value, status, resp.Error)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, tc.field) {
|
|
t.Fatalf("%s 的错误文案应带字段名,得到 %q", tc.field, resp.Error.Message)
|
|
}
|
|
if got := detailOf(t, resp.Error.Detail); got != tc.value {
|
|
t.Fatalf("%s 的 detail 应是原始值 %q,得到 %q", tc.field, tc.value, got)
|
|
}
|
|
}
|
|
|
|
// 合法值原样落库(含 0016 追加的 individual_team)。
|
|
status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
|
"sportId": ids.sport, "code": "E-OK", "name": "合法枚举",
|
|
"eventType": "individual_team", "resultMode": "manual", "resultType": "score",
|
|
"compare": "desc", "status": "published",
|
|
}, true)
|
|
if status != http.StatusOK || !resp.OK {
|
|
t.Fatalf("合法枚举应 200,得到 %d %+v", status, resp.Error)
|
|
}
|
|
created := decodeData[map[string]any](t, resp)
|
|
for field, want := range map[string]string{
|
|
"eventType": "individual_team", "resultMode": "manual",
|
|
"resultType": "score", "compare": "desc", "status": "published",
|
|
} {
|
|
if created[field] != want {
|
|
t.Fatalf("%s 应落库为 %q,得到 %v", field, want, created[field])
|
|
}
|
|
}
|
|
|
|
// 空值走建表默认值,不能报错。
|
|
status, resp = env.do(client, http.MethodPost, "/api/events", map[string]any{
|
|
"sportId": ids.sport, "code": "E-EMPTY", "name": "空枚举",
|
|
}, true)
|
|
if status != http.StatusOK || !resp.OK {
|
|
t.Fatalf("空枚举应走默认值并 200,得到 %d %+v", status, resp.Error)
|
|
}
|
|
got := decodeData[map[string]any](t, resp)
|
|
for field, want := range map[string]string{
|
|
"eventType": "individual", "resultMode": "auto",
|
|
"resultType": "none", "compare": "none", "status": "pending",
|
|
} {
|
|
if got[field] != want {
|
|
t.Fatalf("空 %s 应默认 %q,得到 %v", field, want, got[field])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUpdateEventEnumValidation(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ids := env.seed(t)
|
|
client := recorderClient(t, env)
|
|
|
|
// 非法值在事务前就被拦下,不该碰库。
|
|
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{
|
|
"eventType": "bogus",
|
|
}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("更新非法 eventType 应 400,得到 %d %+v", status, resp.Error)
|
|
}
|
|
if got := detailOf(t, resp.Error.Detail); got != "bogus" {
|
|
t.Fatalf("detail 应是原始值,得到 %q", got)
|
|
}
|
|
|
|
// 空值走默认值。
|
|
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), map[string]any{
|
|
"sportId": ids.sport, "code": "M100", "name": "100米跑",
|
|
}, true)
|
|
if status != http.StatusOK || !resp.OK {
|
|
t.Fatalf("更新空枚举应走默认值并 200,得到 %d %+v", status, resp.Error)
|
|
}
|
|
got := decodeData[map[string]any](t, resp)
|
|
if got["eventType"] != "individual" || got["status"] != "pending" || got["resultMode"] != "auto" {
|
|
t.Fatalf("更新空枚举应回落默认值,得到 %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestProfileEnumValidation(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
client := recorderClient(t, env)
|
|
|
|
for i, tc := range []struct{ field, value string }{
|
|
{"tieMode", "bogus"},
|
|
{"recordBonusMode", "bogus"},
|
|
} {
|
|
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
|
"code": fmt.Sprintf("P-BAD-%d", i), "name": "非法模板", tc.field: tc.value,
|
|
}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("%s=%q 应 400 VALIDATION_FAILED,得到 %d %+v", tc.field, tc.value, status, resp.Error)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, tc.field) {
|
|
t.Fatalf("%s 的错误文案应带字段名,得到 %q", tc.field, resp.Error.Message)
|
|
}
|
|
if got := detailOf(t, resp.Error.Detail); got != tc.value {
|
|
t.Fatalf("%s 的 detail 应是原始值 %q,得到 %q", tc.field, tc.value, got)
|
|
}
|
|
}
|
|
|
|
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
|
"code": "P-OK", "name": "合法模板", "tieMode": "average", "recordBonusMode": "sum",
|
|
}, true)
|
|
if status != http.StatusOK || !resp.OK {
|
|
t.Fatalf("合法枚举应 200,得到 %d %+v", status, resp.Error)
|
|
}
|
|
created := decodeData[map[string]any](t, resp)
|
|
if created["tieMode"] != "average" || created["recordBonusMode"] != "sum" {
|
|
t.Fatalf("合法枚举应原样落库,得到 %+v", created)
|
|
}
|
|
profileID := int64(created["id"].(float64))
|
|
|
|
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
|
"code": "P-EMPTY", "name": "空枚举",
|
|
}, true)
|
|
if status != http.StatusOK || !resp.OK {
|
|
t.Fatalf("空枚举应走默认值并 200,得到 %d %+v", status, resp.Error)
|
|
}
|
|
got := decodeData[map[string]any](t, resp)
|
|
if got["tieMode"] != "skip" || got["recordBonusMode"] != "max" {
|
|
t.Fatalf("空枚举应默认 skip / max,得到 %+v", got)
|
|
}
|
|
|
|
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/scoring-profiles/%d", profileID), map[string]any{
|
|
"code": "P-OK", "name": "合法模板", "tieMode": "nope",
|
|
}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("更新非法 tieMode 应 400,得到 %d %+v", status, resp.Error)
|
|
}
|
|
}
|
|
|
|
func TestResultStatusEnumValidation(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ids := env.seed(t)
|
|
client := recorderClient(t, env)
|
|
|
|
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"results": []map[string]any{{"entryId": ids.entryA, "status": "bogus"}},
|
|
}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("非法 results[].status 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, "status") {
|
|
t.Fatalf("错误文案应带字段名,得到 %q", resp.Error.Message)
|
|
}
|
|
if got := detailOf(t, resp.Error.Detail); got != "bogus" {
|
|
t.Fatalf("detail 应是原始值,得到 %q", got)
|
|
}
|
|
|
|
status, resp = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"results": []map[string]any{{"entryId": ids.entryA, "status": "dq"}},
|
|
}, true)
|
|
if status != http.StatusOK || !resp.OK {
|
|
t.Fatalf("合法 status 应 200,得到 %d %+v", status, resp.Error)
|
|
}
|
|
items := decodeData[[]map[string]any](t, resp)
|
|
if len(items) != 1 || items[0]["status"] != "dq" {
|
|
t.Fatalf("合法 status 应落库为 dq,得到 %+v", items)
|
|
}
|
|
|
|
// 空值走建表默认值 ok。
|
|
status, resp = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"results": []map[string]any{{"entryId": ids.entryA}},
|
|
}, true)
|
|
if status != http.StatusOK || !resp.OK {
|
|
t.Fatalf("空 status 应走默认值并 200,得到 %d %+v", status, resp.Error)
|
|
}
|
|
items = decodeData[[]map[string]any](t, resp)
|
|
if len(items) != 1 || items[0]["status"] != "ok" {
|
|
t.Fatalf("空 status 应默认 ok,得到 %+v", items)
|
|
}
|
|
}
|
|
|
|
// 原本就有校验的枚举入参(通道 scope/basis、纪录 level、用户 role)一并钉住,
|
|
// 确认它们在这次改动里没有被绕过、也仍然返回 400 而不是撞 CHECK 的 500。
|
|
func TestExistingEnumValidationGuards(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ids := env.seed(t)
|
|
client := recorderClient(t, env)
|
|
|
|
var profileID int64
|
|
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles LIMIT 1`).Scan(&profileID); err != nil {
|
|
t.Fatalf("读取积分模板: %v", err)
|
|
}
|
|
for _, ch := range []map[string]any{
|
|
{"scope": "bogus", "basis": "result", "profileId": profileID},
|
|
{"scope": "athlete", "basis": "bogus", "profileId": profileID},
|
|
} {
|
|
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
|
|
map[string]any{"channels": []map[string]any{ch}}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("非法通道 %+v 应 400 VALIDATION_FAILED,得到 %d %+v", ch, status, resp.Error)
|
|
}
|
|
field := "scope"
|
|
if ch["basis"] == "bogus" {
|
|
field = "basis"
|
|
}
|
|
if !strings.Contains(resp.Error.Message, field) {
|
|
t.Fatalf("错误文案应带 %s,得到 %q", field, resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
status, resp := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
|
|
"eventId": ids.event, "entryId": ids.entryA, "level": "bogus",
|
|
}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("非法纪录 level 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, "level") {
|
|
t.Fatalf("错误文案应带 level,得到 %q", resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
func TestUserRoleEnumValidation(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
env.createUser("admin", "admin-pass-1", "admin")
|
|
client, _ := env.client()
|
|
env.login(client, "admin", "admin-pass-1")
|
|
|
|
status, resp := env.do(client, http.MethodPost, "/api/users", map[string]any{
|
|
"username": "someone", "password": "password-1", "role": "bogus",
|
|
}, true)
|
|
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
|
t.Fatalf("非法 role 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
|
|
}
|
|
if !strings.Contains(resp.Error.Message, "role") {
|
|
t.Fatalf("错误文案应带 role,得到 %q", resp.Error.Message)
|
|
}
|
|
}
|
|
|
|
// 拦截点必须指向 enums.go 里的校验函数,而不是 response.go 的构造函数。
|
|
func TestEnumInterceptLogPointsAtEnumsValidator(t *testing.T) {
|
|
env, buf := logEnv(t)
|
|
ids := env.seed(t)
|
|
client := recorderClient(t, env)
|
|
|
|
status, _ := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
|
"sportId": ids.sport, "code": "E-LOG", "name": "非法枚举", "eventType": "bogus",
|
|
}, true)
|
|
if status != http.StatusBadRequest {
|
|
t.Fatalf("非法 eventType 应 400,得到 %d", status)
|
|
}
|
|
|
|
logs := waitLog(t, buf, "请求被拦截")
|
|
if !strings.Contains(logs, `"origin":"internal/httpapi/enums.go:`) ||
|
|
!strings.Contains(logs, "normalizeEventType") {
|
|
t.Fatalf("WARN 行应指出拦截点是 normalizeEventType,得到:\n%s", logs)
|
|
}
|
|
for _, forbidden := range []string{"response.go", "badRequest", "newAPIError", "/home/"} {
|
|
if strings.Contains(logs, forbidden) {
|
|
t.Fatalf("拦截点不该出现 %q,得到:\n%s", forbidden, logs)
|
|
}
|
|
}
|
|
}
|