- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
196 lines
5.1 KiB
Go
196 lines
5.1 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
_ "modernc.org/sqlite"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
|
)
|
|
|
|
// testEnv 是集成测试的完整环境:临时系统库 + 临时赛事库 + httptest 服务。
|
|
type testEnv struct {
|
|
t *testing.T
|
|
server *httptest.Server
|
|
sys *sql.DB
|
|
ev *sql.DB
|
|
auth *auth.Service
|
|
}
|
|
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
st, err := store.Open(filepath.Join(dir, "data"))
|
|
if err != nil {
|
|
t.Fatalf("打开系统库: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
if err := st.Migrate(); err != nil {
|
|
t.Fatalf("迁移系统库: %v", err)
|
|
}
|
|
|
|
evPath := filepath.Join(dir, "data", "event_projs", "test", "event.db")
|
|
if err := os.MkdirAll(filepath.Dir(evPath), 0o755); err != nil {
|
|
t.Fatalf("创建赛事目录: %v", err)
|
|
}
|
|
ev, err := sql.Open("sqlite", "file:"+evPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
|
|
if err != nil {
|
|
t.Fatalf("打开赛事库: %v", err)
|
|
}
|
|
ev.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = ev.Close() })
|
|
if err := store.MigrateEvent(ev); err != nil {
|
|
t.Fatalf("迁移赛事库: %v", err)
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost})
|
|
handler := httpapi.New(httpapi.Options{
|
|
Logger: logger,
|
|
Version: "test",
|
|
System: st.DB(),
|
|
Event: ev,
|
|
Auth: svc,
|
|
DataDir: filepath.Join(dir, "data"),
|
|
})
|
|
srv := httptest.NewServer(handler)
|
|
t.Cleanup(srv.Close)
|
|
|
|
return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc}
|
|
}
|
|
|
|
func (e *testEnv) createUser(username, password, role string) int64 {
|
|
e.t.Helper()
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
|
if err != nil {
|
|
e.t.Fatalf("生成密码哈希: %v", err)
|
|
}
|
|
res, err := e.sys.Exec(
|
|
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
|
|
VALUES (?, ?, ?, ?, 1, 0)`, username, string(hash), username, role)
|
|
if err != nil {
|
|
e.t.Fatalf("创建用户 %s: %v", username, err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
return id
|
|
}
|
|
|
|
func (e *testEnv) client() (*http.Client, *cookiejar.Jar) {
|
|
e.t.Helper()
|
|
jar, err := cookiejar.New(nil)
|
|
if err != nil {
|
|
e.t.Fatalf("创建 cookie jar: %v", err)
|
|
}
|
|
return &http.Client{Jar: jar}, jar
|
|
}
|
|
|
|
type envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data json.RawMessage `json:"data"`
|
|
Error *apiErrorBody `json:"error"`
|
|
}
|
|
|
|
type apiErrorBody struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Detail json.RawMessage `json:"detail"`
|
|
}
|
|
|
|
// do 发起请求;csrf=true 时带上与 csrf_token Cookie 一致的请求头。
|
|
func (e *testEnv) do(client *http.Client, method, path string, body any, csrf bool) (int, envelope) {
|
|
return e.doH(client, method, path, body, csrf, nil)
|
|
}
|
|
|
|
// doH 在 do 的基础上附加自定义请求头(例如 X-Request-Id)。
|
|
func (e *testEnv) doH(client *http.Client, method, path string, body any, csrf bool, headers map[string]string) (int, envelope) {
|
|
e.t.Helper()
|
|
|
|
var reader io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
e.t.Fatalf("序列化请求体: %v", err)
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequest(method, e.server.URL+path, reader)
|
|
if err != nil {
|
|
e.t.Fatalf("构造请求: %v", err)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if csrf {
|
|
token := e.csrfToken(client)
|
|
if token == "" {
|
|
e.t.Fatalf("请求 %s %s 需要 CSRF,但 Cookie 里没有 csrf_token", method, path)
|
|
}
|
|
req.Header.Set("X-CSRF-Token", token)
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
e.t.Fatalf("请求 %s %s: %v", method, path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
var env envelope
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if len(bytes.TrimSpace(raw)) > 0 {
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
e.t.Fatalf("解析响应 %s %s: %v (%s)", method, path, err, raw)
|
|
}
|
|
}
|
|
return resp.StatusCode, env
|
|
}
|
|
|
|
func (e *testEnv) csrfToken(client *http.Client) string {
|
|
u, err := url.Parse(e.server.URL)
|
|
if err != nil {
|
|
e.t.Fatalf("解析测试服务地址: %v", err)
|
|
}
|
|
for _, c := range client.Jar.Cookies(u) {
|
|
if c.Name == auth.CookieCSRF {
|
|
return c.Value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (e *testEnv) login(client *http.Client, username, password string) envelope {
|
|
e.t.Helper()
|
|
status, env := e.do(client, http.MethodPost, "/api/auth/login",
|
|
map[string]any{"username": username, "password": password}, false)
|
|
if status != http.StatusOK || !env.OK {
|
|
e.t.Fatalf("登录失败 status=%d env=%+v", status, env.Error)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func decodeData[T any](t *testing.T, env envelope) T {
|
|
t.Helper()
|
|
var out T
|
|
if err := json.Unmarshal(env.Data, &out); err != nil {
|
|
t.Fatalf("解析 data: %v (%s)", err, env.Data)
|
|
}
|
|
return out
|
|
}
|