Files
Scoreboard/server/internal/httpapi/handlers_config_test.go
T
AskaEth 6483acf9ad feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动
人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
  都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员

大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
  限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)

图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
  访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
  一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮

关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
  (用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)

安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
  里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
2026-09-10 09:05:06 +08:00

1198 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。
const importHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names"
// importHeaderNoAge 是升级前的 10 列表头,用来验证缺 age 列不报错也不误报冲突。
const importHeaderNoAge = "event_code,category,team_code,athlete_name,id_card,gender,bib,seed,status,member_names"
// postImport 以 multipart 上传报名表;decisions 非空时作为 resolve 接口的 decisions 字段。
func (e *testEnv) postImport(t *testing.T, client *http.Client, path, content, decisions string) (int, envelope) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", "entries.csv")
if err != nil {
t.Fatalf("构造 multipart: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("写入报名表: %v", err)
}
if decisions != "" {
if err := mw.WriteField("decisions", decisions); err != nil {
t.Fatalf("写入 decisions: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+path, &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传报名表: %v", 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 {
t.Fatalf("解析响应: %v (%s)", err, raw)
}
}
return resp.StatusCode, env
}
// recorderWithSeed 建好种子数据并返回已登录的 recorder 客户端。
func recorderWithSeed(t *testing.T) (*testEnv, seedIDs, *http.Client) {
t.Helper()
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
return env, ids, client
}
// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
func TestLoginAuditRecordsUserID(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var auditUser sql.NullInt64
if err := env.ev.QueryRow(
`SELECT user_id FROM audit_logs WHERE action = 'auth.login' ORDER BY id DESC LIMIT 1`).Scan(&auditUser); err != nil {
t.Fatalf("查询登录审计: %v", err)
}
if !auditUser.Valid || auditUser.Int64 != userID {
t.Fatalf("登录审计 user_id 应为 %d,得到 %+v", userID, auditUser)
}
// 按 userId 过滤必须能查到这条登录记录。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
_, env2 := env.do(adminClient, http.MethodGet, fmt.Sprintf("/api/audit-logs?userId=%d", userID), nil, false)
page := decodeData[map[string]any](t, env2)
items, _ := page["items"].([]any)
found := false
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
if item["action"] == "auth.login" && item["userId"] == float64(userID) {
found = true
}
}
if !found {
t.Fatalf("按 userId 过滤应能查到登录审计,得到 %+v", items)
}
}
// 跨站登录(login CSRF)必须被同源校验挡住(P2-6)。
func TestLoginRejectsCrossOrigin(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
body := map[string]any{"username": "recorder", "password": "recorder-pass"}
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": "http://evil.example"})
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("跨站登录应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
// 同源(Origin 与 Host 一致)仍可登录。
status, env2 = env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": env.server.URL})
if status != http.StatusOK || !env2.OK {
t.Fatalf("同源登录应 200,得到 %d %+v", status, env2.Error)
}
}
// 超过 bcrypt 72 字节上限的新密码必须 400,而不是被静默截断(P2-6)。
func TestChangePasswordRejectsTooLong(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
status, env2 := env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
"currentPassword": "recorder-pass",
"newPassword": tooLong,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
}
}
// 导入遇到新证件号 → 新建人员(name/gender/id_card/team)并建报名。
func TestImportCreatesAthleteWithIDCard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("应新建 1 条报名且无跳过: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("不应有冲突: %+v", conflicts)
}
var (
name, gender string
teamID int64
)
if err := env.ev.QueryRow(
`SELECT name, gender, team_id FROM athletes WHERE id_card = ?`, "110101199001011234").
Scan(&name, &gender, &teamID); err != nil {
t.Fatalf("查询新建人员: %v", err)
}
if name != "张三" || gender != "男" || teamID != ids.teamA {
t.Fatalf("新建人员字段不对: name=%s gender=%s team=%d", name, gender, teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
ids.event, "110101199001011234").Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 1 {
t.Fatalf("应建 1 条报名,得到 %d", entries)
}
}
// 姓名 + 单位命中且库中证件号为空 → 回填证件号,不新建人员。
func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("回填应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("回填不算冲突: %+v", conflicts)
}
var idCard sql.NullString
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
t.Fatalf("查询人员证件号: %v", err)
}
if !idCard.Valid || idCard.String != "110101199001011234" {
t.Fatalf("应回填证件号,得到 %+v", idCard)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
}
}
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(0) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("冲突行不应计入新增/更新/跳过: %+v", result)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["row"] != float64(2) || conflict["idCard"] != "110101199001011234" ||
conflict["athleteId"] != float64(ids.athleteA) {
t.Fatalf("冲突定位信息不对: %+v", conflict)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "teamCode" {
t.Fatalf("应只有 teamCode 变化,得到 %+v", changed)
}
existing := conflict["existing"].(map[string]any)
if existing["name"] != "张伟" || existing["teamCode"] != "A" || existing["gender"] != "男" {
t.Fatalf("existing 快照不对: %+v", existing)
}
incoming := conflict["incoming"].(map[string]any)
if incoming["name"] != "张伟" || incoming["teamCode"] != "B" {
t.Fatalf("incoming 快照不对: %+v", incoming)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询人员单位: %v", err)
}
if teamID != ids.teamA {
t.Fatalf("冲突不应改人员单位,得到 %d", teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 0 {
t.Fatalf("冲突行的报名不应落库,得到 %d 条", entries)
}
}
// 同一文件里证件号重复出现 → 该行报错并跳过。
func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,,registered,\n" +
"M100,男子,A,张三,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(1) {
t.Fatalf("首行应新建、次行应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") {
t.Fatalf("应报证件号重复,得到 %+v", errs)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 1 {
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
}
}
// 有 athlete_name 的行必须给 id_card 或 bib,两个都缺才报错。
func TestImportRequiresIDCardOrBibWhenNameGiven(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(0) || result["skipped"] != float64(1) {
t.Fatalf("缺证件号与号码布应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "id_card") {
t.Fatalf("应报缺少 id_card 或 bib,得到 %+v", errs)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("该行不应建人员,得到 %d 个", athletes)
}
}
// id_card / gender / age 是可选列:表头缺失不报错(团体行不涉及人员)。
func TestImportToleratesMissingOptionalColumns(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := "event_code,category,team_code,athlete_name,bib,seed,status,member_names\n" +
"M100,男子,A,,,,,registered,张三/李四\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("缺可选列应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("团体行应正常导入: %+v", result)
}
}
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
func TestImportResolveKeepExisting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
// 未给决策时,冲突原样返回。
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策接口应 200,得到 %d %+v", status, env2.Error)
}
if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 1 {
t.Fatalf("未决策的冲突应继续返回: %+v", conflicts)
}
decisions := `[{"idCard":"110101199001011234","action":"keep-existing"}]`
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("keep-existing 应更新库里已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询人员单位: %v", err)
}
if teamID != ids.teamA {
t.Fatalf("keep-existing 应保留库中单位,得到 %d", teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 0 {
t.Fatalf("keep-existing 不应在表里的单位下建报名,得到 %d 条", entries)
}
var audit int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entries.import.resolve'`).Scan(&audit); err != nil {
t.Fatalf("统计审计: %v", err)
}
if audit == 0 {
t.Fatalf("决策应写审计")
}
}
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
func TestImportResolveKeepNew(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["updated"] != float64(0) {
t.Fatalf("keep-new 应在表里的单位下新建报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询人员单位: %v", err)
}
if teamID != ids.teamB {
t.Fatalf("keep-new 应用表里的单位更新人员,得到 %d", teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 1 {
t.Fatalf("keep-new 应在表里的单位下建 1 条报名,得到 %d 条", entries)
}
}
// decisions 非法时返回 400。
func TestImportResolveRejectsBadDecisions(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
for _, decisions := range []string{"不是 JSON", `[{"idCard":"1","action":"keep-both"}]`} {
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("非法 decisions 应 400,得到 %d %+v", status, env2.Error)
}
}
}
// 只给号码布(没有证件号)也能新建人员,人员与报名都写入号码布。
func TestImportCreatesAthleteWithBibOnly(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,赵六,,男,,B1001,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("仅给号码布应新建 1 条报名: %+v", result)
}
var (
idCard sql.NullString
bib string
)
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE name = '赵六'`).Scan(&idCard, &bib); err != nil {
t.Fatalf("查询新建人员: %v", err)
}
if idCard.Valid || bib != "B1001" {
t.Fatalf("人员应只有号码布: id_card=%+v bib=%s", idCard, bib)
}
var entryBib sql.NullString
if err := env.ev.QueryRow(
`SELECT bib FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B1001')`).Scan(&entryBib); err != nil {
t.Fatalf("查询报名号码布: %v", err)
}
if !entryBib.Valid || entryBib.String != "B1001" {
t.Fatalf("报名也应写号码布: %+v", entryBib)
}
}
// 号码布精确命中已有人员 → 视为同一人,只更新报名。
func TestImportMatchesAthleteByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("号码布命中应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("号码布命中不算冲突: %+v", conflicts)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("号码布命中不应新建人员,得到 %d 个", athletes)
}
}
// 姓名 + 单位命中且库中证件号为空 → 回填号码布,不新建人员。
func TestImportBackfillsBibForExistingAthlete(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("回填应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("回填不算冲突: %+v", conflicts)
}
var bib sql.NullString
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员号码布: %v", err)
}
if !bib.Valid || bib.String != "A100" {
t.Fatalf("应回填号码布,得到 %+v", bib)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
}
}
// 号码布命中时,表里多给的证件号也回填进库中为空的人员。
func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A100,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("号码布命中应更新已有报名: %+v", result)
}
var idCard sql.NullString
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
t.Fatalf("查询人员证件号: %v", err)
}
if !idCard.Valid || idCard.String != "110101199001011234" {
t.Fatalf("应回填证件号,得到 %+v", idCard)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
}
}
// 同一文件里号码布重复出现 → 该行报错并跳过。
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,张三,,男,,B2001,,registered,\n" +
"M100,男子,A,李四,,男,,B2001,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(1) {
t.Fatalf("首行应新建、次行应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") {
t.Fatalf("应报号码布重复,得到 %+v", errs)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE bib = 'B2001'`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 1 {
t.Fatalf("重复号码布只应建 1 个人员,得到 %d", athletes)
}
}
// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。
func TestImportConflictIncludesBibDiff(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["bib"] != "A200" {
t.Fatalf("冲突定位应带表里的号码布: %+v", conflict)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "bib" {
t.Fatalf("应只有 bib 变化,得到 %+v", changed)
}
existing := conflict["existing"].(map[string]any)
if existing["bib"] != "A100" {
t.Fatalf("existing 快照应带库中号码布: %+v", existing)
}
incoming := conflict["incoming"].(map[string]any)
if incoming["bib"] != "A200" {
t.Fatalf("incoming 快照应带表里号码布: %+v", incoming)
}
var bib string
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员号码布: %v", err)
}
if bib != "A100" {
t.Fatalf("冲突不应改库中号码布,得到 %s", bib)
}
}
// 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,keep-new 用表里字段覆盖。
func TestImportResolveConflictByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟伟,,男,,A100,,registered,\n"
decisions := `[{"bib":"A100","action":"keep-new"}]`
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("keep-new 应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var name string
if err := env.ev.QueryRow(`SELECT name FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name); err != nil {
t.Fatalf("查询人员姓名: %v", err)
}
if name != "张伟伟" {
t.Fatalf("keep-new 应用表里的姓名覆盖人员,得到 %s", name)
}
}
// 表里的 age 列写入新建人员。
func TestImportCreatesAthleteWithAge(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,23,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("应新建 1 条报名且无跳过: %+v", result)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&age); err != nil {
t.Fatalf("查询新建人员: %v", err)
}
if !age.Valid || age.Int64 != 23 {
t.Fatalf("应写入年龄 23,得到 %+v", age)
}
}
// age 非整数或超出 0-120 → 该行报错并跳过,人员与报名都不落库。
func TestImportRejectsInvalidAge(t *testing.T) {
env, _, client := recorderWithSeed(t)
for _, age := range []string{"abc", "-1", "121", "30.5"} {
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男," + age + ",,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(0) || result["skipped"] != float64(1) {
t.Fatalf("age=%s 应跳过该行: %+v", age, result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "age") {
t.Fatalf("age=%s 应报年龄非法,得到 %+v", age, errs)
}
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("非法行不应建人员,得到 %d 个", athletes)
}
}
// 姓名 + 单位命中且库中证件号为空 → 一并回填年龄,不新建人员。
func TestImportBackfillsAgeForExistingAthlete(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,35,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("回填应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("回填不算冲突: %+v", conflicts)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 35 {
t.Fatalf("应回填年龄 35,得到 %+v", age)
}
// 回填后年龄两边相同,再导入一次不算冲突。
status, env2 = env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("二次导入应 200,得到 %d %+v", status, env2.Error)
}
result = decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("年龄相同不算冲突: %+v", conflicts)
}
if result["updated"] != float64(1) {
t.Fatalf("二次导入应更新已有报名: %+v", result)
}
}
// 年龄两边都有且不同 → 冲突,快照与差异字段都带 age,本轮不落库。
func TestImportConflictIncludesAgeDiff(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("年龄不同应收集 1 条冲突,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "age" {
t.Fatalf("应只有 age 变化,得到 %+v", changed)
}
if existing := conflict["existing"].(map[string]any); existing["age"] != float64(30) {
t.Fatalf("existing 快照应带库中年龄: %+v", existing)
}
if incoming := conflict["incoming"].(map[string]any); incoming["age"] != float64(26) {
t.Fatalf("incoming 快照应带表里年龄: %+v", incoming)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 30 {
t.Fatalf("冲突不应改库中年龄,得到 %+v", age)
}
}
// 升级前的 10 列表头没有 age 列:不报错,也不能把已有年龄的人误报成冲突。
func TestImportWithoutAgeColumnKeepsExistingAge(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
body := importHeaderNoAge + "\nM100,男子,A,张伟,110101199001011234,男,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("缺 age 列应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("缺 age 列不该误报冲突: %+v", conflicts)
}
if result["updated"] != float64(1) {
t.Fatalf("缺 age 列应正常更新: %+v", result)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 30 {
t.Fatalf("缺 age 列不该清掉库中年龄,得到 %+v", age)
}
}
// keep-new 只在表里给了年龄时才覆盖,缺列/空值不能清掉库中已有的年龄。
func TestImportResolveKeepNewAgeOnlyOverwritesWhenGiven(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
// 表里 age 为空:keep-new 覆盖单位与姓名,年龄保持库里的值。
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 30 {
t.Fatalf("表里没给年龄时不该清掉库里年龄,得到 %+v", age)
}
// 表里给了 age:keep-new 覆盖成表里的值。
body = importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,registered,\n"
if status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions); status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 26 {
t.Fatalf("keep-new 应用表里的年龄覆盖,得到 %+v", age)
}
}
// ---------- 图片上传与静态访问 ----------
// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。
func newUploadEnv(t *testing.T) (*testEnv, string) {
t.Helper()
projectDir := filepath.Join(t.TempDir(), "event_projs", "test")
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
return env, filepath.Join(projectDir, "uploads")
}
// pngContent 是占位 PNG:服务端只按扩展名白名单放行,不解码图片内容。
func pngContent() []byte {
return append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, []byte("fake-png-body")...)
}
// postUpload 以 multipart 字段 file 上传到 /api/uploads。
func (e *testEnv) postUpload(t *testing.T, client *http.Client, filename string, content []byte) (int, envelope) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("构造 multipart: %v", err)
}
if _, err := fw.Write(content); err != nil {
t.Fatalf("写入上传内容: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/uploads", &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if token := e.csrfToken(client); token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传图片: %v", 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 {
t.Fatalf("解析上传响应: %v (%s)", err, raw)
}
}
return resp.StatusCode, env
}
type uploadResultBody struct {
Path string `json:"path"`
URL string `json:"url"`
}
func uploadsDirEntries(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
t.Fatalf("读取 uploads 目录: %v", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
// 上传的图片要真落盘到 <ProjectDir>/uploads,且匿名 <img> 能取到同样的内容(赛事包导出靠这个目录)。
func TestUploadImageThenServeAnonymously(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
content := pngContent()
status, body := env.postUpload(t, client, "赛事会标.png", content)
if status != http.StatusOK || !body.OK {
t.Fatalf("上传 png 应 200,得到 %d %+v", status, body.Error)
}
result := decodeData[uploadResultBody](t, body)
if result.Path != result.URL {
t.Fatalf("path 与 url 应一致,得到 %q / %q", result.Path, result.URL)
}
if !strings.HasPrefix(result.Path, "/uploads/") || !strings.HasSuffix(result.Path, ".png") {
t.Fatalf("返回路径应形如 /uploads/xxx.png,得到 %q", result.Path)
}
// 落盘名不能用客户端文件名,否则重名会互相覆盖、也会带出路径注入。
if strings.Contains(result.Path, "赛事会标") {
t.Fatalf("落盘名应为随机名,得到 %q", result.Path)
}
stored := filepath.Join(uploadsDir, filepath.Base(result.Path))
got, err := os.ReadFile(stored)
if err != nil {
t.Fatalf("上传文件未落在 projectDir/uploads: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("落盘内容与上传内容不一致: %q", got)
}
var audits int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.create'`).Scan(&audits); err != nil {
t.Fatalf("查询上传审计: %v", err)
}
if audits != 1 {
t.Fatalf("上传应写一条 uploads.create 审计,得到 %d", audits)
}
// 大屏与 <img> 不带会话:匿名 GET 必须 200。
resp, err := http.Get(env.server.URL + result.Path)
if err != nil {
t.Fatalf("匿名取图: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("匿名取图应 200,得到 %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type 应为 image/png,得到 %q", ct)
}
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "max-age") {
t.Fatalf("随机文件名应可长缓存,得到 Cache-Control %q", cc)
}
served, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("读取图片响应: %v", err)
}
if !bytes.Equal(served, content) {
t.Fatalf("静态响应内容与上传内容不一致: %q", served)
}
}
// 只收位图:SVG 可内嵌脚本,匿名同源访问等于存储型 XSS 入口。
func TestUploadRejectsUnsupportedImageType(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
cases := []struct {
name string
content []byte
}{
{"logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)},
{"logo.txt", []byte("not an image")},
{"logo.png.exe", []byte("not an image")},
}
for _, c := range cases {
status, body := env.postUpload(t, client, c.name, c.content)
if status != http.StatusBadRequest || body.OK {
t.Fatalf("上传 %s 应 400,得到 %d %+v", c.name, status, body.Error)
}
}
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
t.Fatalf("被拒的上传不该落盘,实际有 %v", names)
}
}
// 单文件上限 5 MiB:略超与远超都要 400,且不落盘。
func TestUploadRejectsOversizeImage(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
for _, size := range []int{5<<20 + 1, 8 << 20} {
status, body := env.postUpload(t, client, "big.png", bytes.Repeat([]byte{'a'}, size))
if status != http.StatusBadRequest || body.OK {
t.Fatalf("%d 字节应 400,得到 %d %+v", size, status, body.Error)
}
if !strings.Contains(body.Error.Message, "5 MiB") {
t.Fatalf("超限提示应说明上限,得到 %q", body.Error.Message)
}
}
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
t.Fatalf("超限上传不该落盘,实际有 %v", names)
}
}
// 上传是写操作:未登录(guest)一律 401 AUTH_REQUIRED,与其它写接口口径一致。
func TestUploadRequiresRecorder(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
client, _ := env.client()
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusUnauthorized {
t.Fatalf("未登录上传应 401,得到 %d %+v", status, body.Error)
}
if body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("错误码应为 AUTH_REQUIRED,得到 %+v", body.Error)
}
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
t.Fatalf("未登录上传不该落盘,实际有 %v", names)
}
}
// 没配置赛事项目目录时没有落盘位置,明确报 503 而不是 panic。
func TestUploadWithoutProjectDir(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusServiceUnavailable {
t.Fatalf("未配置 ProjectDir 应 503,得到 %d %+v", status, body.Error)
}
}
// 静态访问只认「单层文件名 + 白名单扩展名」,任何穿越写法都不能读到 uploads 之外的文件。
func TestUploadStaticRejectsTraversal(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
root := filepath.Dir(filepath.Dir(uploadsDir)) // projectDir 的上一级(event_projs)
const secret = "top-secret-content"
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("创建 uploads 之外的目录: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(secret), 0o644); err != nil {
t.Fatalf("写 uploads 之外的文件: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "..", "secret.txt"), []byte(secret), 0o644); err != nil {
t.Fatalf("写 uploads 之外的文件: %v", err)
}
// 不跟随重定向:要看到服务端真正给出的状态码。
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
paths := []string{
"/uploads/..%2F..%2Fconfig.yaml",
"/uploads/..%2F..%2F..%2Fsecret.txt",
"/uploads/%2e%2e%2Fconfig.yaml",
"/uploads/a/b.png",
"/uploads/..%2Fconfig.yaml",
}
for _, path := range paths {
resp, err := client.Get(env.server.URL + path)
if err != nil {
t.Fatalf("请求 %s: %v", path, err)
}
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s 应 404/400,得到 %d", path, resp.StatusCode)
}
if strings.Contains(string(raw), secret) {
t.Fatalf("%s 读到了 uploads 之外的文件: %s", path, raw)
}
}
}
// 名字合法但文件不存在时返回 404,不能把目录列表或空图当成功。
func TestUploadStaticMissingFile(t *testing.T) {
env, _ := newUploadEnv(t)
resp, err := http.Get(env.server.URL + "/uploads/0123456789abcdef0123456789abcdef.png")
if err != nil {
t.Fatalf("请求不存在的图片: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("不存在的图片应 404,得到 %d", resp.StatusCode)
}
}
// 上传返回的路径要能直接写进赛事配置,且大屏匿名取图链路通(会标/背景的实际用法)。
func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) {
env, _ := newUploadEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusOK || !body.OK {
t.Fatalf("上传应 200,得到 %d %+v", status, body.Error)
}
path := decodeData[uploadResultBody](t, body).Path
status, body = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"event": map[string]any{"logoPath": path},
"display": map[string]any{"logo": path, "background": path},
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("把上传路径写进配置应 200,得到 %d %+v", status, body.Error)
}
status, body = env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读取配置应 200,得到 %d %+v", status, body.Error)
}
settings := decodeData[dto.EventSettings](t, body)
if settings.Event.LogoPath == nil || *settings.Event.LogoPath != path {
t.Fatalf("event.logoPath 应保存上传路径 %q,得到 %+v", path, settings.Event.LogoPath)
}
if settings.Display.Logo == nil || *settings.Display.Logo != path {
t.Fatalf("display.logo 应保存上传路径 %q,得到 %+v", path, settings.Display.Logo)
}
// 大屏用的就是这个路径,匿名 GET 必须直接取到图。
resp, err := http.Get(env.server.URL + path)
if err != nil {
t.Fatalf("匿名取图: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
}
}