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) } } }