feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,195 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user