服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(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 不外泄原文、限流键清理不误删。
88 lines
3.3 KiB
Go
88 lines
3.3 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
|
)
|
|
|
|
// 写限流要真的拦得住(默认 30 次/分钟/用户):
|
|
// 之前没有任何 429 用例,限流键名写错、中间件漏挂都测不出来。
|
|
// 这里把阈值压到 2,避免为了触发限流跑 31 次写请求。
|
|
func TestWriteRateLimitReturns429(t *testing.T) {
|
|
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
|
o.Auth = auth.New(o.System, auth.Options{
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
BcryptCost: bcrypt.MinCost,
|
|
WritePerMinute: 2,
|
|
})
|
|
})
|
|
env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
for i := 1; i <= 2; i++ {
|
|
status, body := env.do(client, http.MethodPost, "/api/teams",
|
|
map[string]any{"code": fmt.Sprintf("T%d", i), "name": "测试单位"}, true)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("第 %d 次写在阈值内应放行,得到 %d %+v", i, status, body.Error)
|
|
}
|
|
}
|
|
|
|
status, body := env.do(client, http.MethodPost, "/api/teams",
|
|
map[string]any{"code": "T9", "name": "超限单位"}, true)
|
|
if status != http.StatusTooManyRequests {
|
|
t.Fatalf("超出阈值应 429,得到 %d %+v", status, body.Error)
|
|
}
|
|
if body.Error == nil || body.Error.Code != "RATE_LIMITED" {
|
|
t.Fatalf("429 的错误码应为 RATE_LIMITED,得到 %+v", body.Error)
|
|
}
|
|
|
|
// 读操作不占写配额:限流只管写,不然大屏轮询会把录入员挤掉。
|
|
if status, _ := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK {
|
|
t.Fatalf("读操作不该被写限流拦住,得到 %d", status)
|
|
}
|
|
}
|
|
|
|
// CSRF 的反向用例:已登录用户写了但没带(或带错)X-CSRF-Token,必须 403。
|
|
// 只有正例的话,「忘了校验」和「校验通过」看起来一模一样。
|
|
func TestCSRFRejectsWriteWithoutToken(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
status, body := env.do(client, http.MethodPost, "/api/teams",
|
|
map[string]any{"code": "C1", "name": "缺 token 的单位"}, false)
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("缺 CSRF token 的写请求应 403,得到 %d %+v", status, body.Error)
|
|
}
|
|
if body.Error == nil || body.Error.Code != "FORBIDDEN" {
|
|
t.Fatalf("403 的错误码应为 FORBIDDEN,得到 %+v", body.Error)
|
|
}
|
|
|
|
// 带一个与 Cookie 不一致的 token 同样要拦,且数据不能落库。
|
|
status, _ = env.doH(client, http.MethodPost, "/api/teams",
|
|
map[string]any{"code": "C2", "name": "错 token 的单位"}, false,
|
|
map[string]string{"X-CSRF-Token": "not-the-cookie-value"})
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("CSRF token 不匹配应 403,得到 %d", status)
|
|
}
|
|
status, list := env.do(client, http.MethodGet, "/api/teams", nil, false)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("读取单位列表失败,得到 %d", status)
|
|
}
|
|
for _, team := range decodeData[[]map[string]any](t, list) {
|
|
if code, _ := team["code"].(string); code == "C1" || code == "C2" {
|
|
t.Fatalf("被 CSRF 拦下的请求不该写进库:%+v", team)
|
|
}
|
|
}
|
|
}
|