package httpapi_test import ( "bytes" "database/sql" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "strings" "testing" "time" "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', bib = 'A100' WHERE id = ?`, ids.athleteA) body := importHeader + "\nM100,男子,B,张伟,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["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 TestImportAcceptsDuplicateIDCardInFile(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["skipped"] != float64(0) { t.Fatalf("同表重复证件号不应再拦截: %+v", result) } if errs, _ := result["errors"].([]any); len(errs) != 0 { t.Fatalf("不应有行级报错,得到 %+v", errs) } if result["created"] != float64(1) || result["updated"] != float64(1) { t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result) } 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) } } // 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。 func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) { env, ids, client := recorderWithSeed(t) env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA) // 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。 body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,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 { t.Fatalf("应报 1 条行级错误,得到 %+v", errs) } want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)" if errs[0] != want { t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want) } // 库里的张伟一行没动,也没有多出一个人。 var ( name string bib string count int ) if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil { t.Fatalf("查询人员: %v", err) } if name != "张伟" || bib != "A100" { t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib) } if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil { t.Fatalf("统计人员: %v", err) } if count != 2 { t.Fatalf("撞号不应新建人员,得到 %d 个", count) } } // 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。 func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) { env, ids, client := recorderWithSeed(t) env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA) body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,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) } if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 { t.Fatalf("同名不同号码布不进冲突面板: %+v", conflicts) } reported, _ := result["sameNameNewPersons"].([]any) if len(reported) != 1 { t.Fatalf("应报告 1 条同名新建,得到 %+v", reported) } item := reported[0].(map[string]any) if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" { t.Fatalf("同名新建报告字段不对: %+v", item) } var athletes int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil { t.Fatalf("统计人员: %v", err) } if athletes != 2 { t.Fatalf("应新建第二个人,得到 %d 个", athletes) } // 原档案的号码布保持不动,新档案拿表里的号码布。 var oldBib string if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil { t.Fatalf("查询原人员: %v", err) } if oldBib != "1001" { t.Fatalf("原档案号码布不应被改,得到 %s", oldBib) } } // 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。 func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) { env, ids, client := recorderWithSeed(t) // 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。 env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA) body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,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(0) { t.Fatalf("歧义行不落库也不跳过: %+v", result) } conflicts, _ := result["conflicts"].([]any) if len(conflicts) != 1 { t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts) } if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) { t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0]) } } // 有 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', bib = 'A100' WHERE id = ?`, ids.athleteA) body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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', bib = 'A100' WHERE id = ?`, ids.athleteA) body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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) } } // 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时, // 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。 func TestImportSameNameDifferentBibButTakenIDCard(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) if result["skipped"] != float64(1) || result["created"] != float64(0) { t.Fatalf("撞号应跳过该行: %+v", result) } errs, _ := result["errors"].([]any) want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)" if len(errs) != 1 || errs[0] != want { t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want) } if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 { t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported) } 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) } } // 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,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) } } // status 列归一:DNS / 退赛 / 空串 / Registered 都能正常入库,库里存的是四个合法值之一。 func TestImportNormalizesStatusColumn(t *testing.T) { env, _, client := recorderWithSeed(t) cases := []struct{ cell, want string }{ {"DNS", "dns"}, {"退赛", "scratched"}, {"", "registered"}, {"Registered", "registered"}, } body := importHeader + "\n" for i, c := range cases { body += fmt.Sprintf("M100,男子,A,选手%d,11010119900101%04d,男,,,,%s,\n", i+1, i+1, c.cell) } 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(len(cases)) || result["skipped"] != float64(0) { t.Fatalf("四种写法都应导入成功: %+v", result) } for i, c := range cases { idCard := fmt.Sprintf("11010119900101%04d", i+1) var got string if err := env.ev.QueryRow(`SELECT e.status FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, idCard).Scan(&got); err != nil { t.Fatalf("查询 status(%s): %v", c.cell, err) } if got != c.want { t.Errorf("status=%q 应存成 %q,得到 %q", c.cell, c.want, got) } } } // status 填了认不出的值:该行跳过,错误消息要是中文可读的行级提示,不能是原始 SQLite 报错。 func TestImportRejectsUnknownStatus(t *testing.T) { env, _, client := recorderWithSeed(t) body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,已报到,\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("乱填 status 的行应跳过: %+v", result) } errs, _ := result["errors"].([]any) if len(errs) != 1 { t.Fatalf("应有 1 条行级错误,得到 %+v", errs) } msg, _ := errs[0].(string) for _, want := range []string{"第 2 行", "已报到", "registered / scratched / dq / dns"} { if !strings.Contains(msg, want) { t.Fatalf("错误消息应含 %q,得到 %q", want, msg) } } // 该行一个字段都不该落库:既不建人员,也不建报名。 var entries int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, "110101199001011234").Scan(&entries); err != nil { t.Fatalf("统计报名: %v", err) } if entries != 0 { t.Fatalf("认不出 status 的行不应落库,得到 %d 条报名", entries) } } // ---------- 图片上传与静态访问 ---------- // 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 } // 上传的图片要真落盘到 /uploads,且匿名 能取到同样的内容(赛事包导出靠这个目录)。 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) } // 大屏与 不带会话:匿名 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(``)}, {"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) } } // 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1), // 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。 // 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。 func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) client := recorderClient(t, env) boolField := func(t *testing.T, body envelope, field string) bool { t.Helper() got, ok := decodeData[map[string]any](t, body)[field].(bool) if !ok { t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data) } return got } // 库里也真的按接口回报的值落着,不只是响应体好看。 assertStoredFlags := func(t *testing.T, code string, want int) { t.Helper() var scored, recordEligible int if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code). Scan(&scored, &recordEligible); err != nil { t.Fatalf("查询小项 %s 的开关: %v", code, err) } if scored != want || recordEligible != want { t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible) } } base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"} status, body := env.do(client, http.MethodPost, "/api/events", base, true) if status != http.StatusOK || !body.OK { t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error) } if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") { t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data) } assertStoredFlags(t, "E-BOOL", 1) eventID := int64(decodeData[map[string]any](t, body)["id"].(float64)) // 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。 rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"} status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true) if status != http.StatusOK || !body.OK { t.Fatalf("改名应 200,得到 %d %+v", status, body.Error) } if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") { t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data) } // 显式 false 才关得掉:接口不能无视调用方的明确意图。 turnOff := map[string]any{ "sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)", "scored": false, "recordEligible": false, } status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true) if status != http.StatusOK || !body.OK { t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error) } if boolField(t, body, "scored") || boolField(t, body, "recordEligible") { t.Fatalf("显式 false 应落库为 false: %+v", body.Data) } // 反过来:库里是 false/false 时,更新漏传也不能把它打开。 rename["name"] = "开关缺省(改名三)" status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true) if status != http.StatusOK || !body.OK { t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error) } if boolField(t, body, "scored") || boolField(t, body, "recordEligible") { t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data) } // 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。 explicitOff := map[string]any{ "sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭", "scored": false, "recordEligible": false, } status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true) if status != http.StatusOK || !body.OK { t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error) } if boolField(t, body, "scored") || boolField(t, body, "recordEligible") { t.Fatalf("显式 false 应落库为 false: %+v", body.Data) } assertStoredFlags(t, "E-BOOL-OFF", 0) assertStoredFlags(t, "E-BOOL", 0) } // ---------- 上传图片的删除与孤儿清理 ---------- // uploadOrphansBody 对应 GET /api/uploads/orphans 的 data。 type uploadOrphansBody struct { Items []uploadOrphanBody `json:"items"` Total int `json:"total"` TotalBytes int64 `json:"totalBytes"` } type uploadOrphanBody struct { Name string `json:"name"` URL string `json:"url"` Size int64 `json:"size"` ModifiedAt string `json:"modifiedAt"` } type uploadDeleteFailureBody struct { Name string `json:"name"` Reason string `json:"reason"` } type uploadDeleteBody struct { Deleted []string `json:"deleted"` Failed []uploadDeleteFailureBody `json:"failed"` } // uploadAdminEnv 建好带项目目录的环境并登录 admin: // 删文件与孤儿清理是系统级资源管理,只有 admin 能做(与字体删除同一档)。 func uploadAdminEnv(t *testing.T) (*testEnv, string, *http.Client) { t.Helper() env, uploadsDir := newUploadEnv(t) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") return env, uploadsDir, client } // uploadOne 上传一张图片,返回 /uploads/<随机名>。 func uploadOne(t *testing.T, env *testEnv, client *http.Client) string { t.Helper() status, body := env.postUpload(t, client, "logo.png", pngContent()) if status != http.StatusOK || !body.OK { t.Fatalf("上传图片应 200,得到 %d %+v", status, body.Error) } return decodeData[uploadResultBody](t, body).Path } func uploadBaseName(path string) string { return path[strings.LastIndex(path, "/")+1:] } type teamCreatedBody struct { ID int64 `json:"id"` } // createTeamWithLogo 建一个挂 logo 的单位,返回 id;logo 引用点是 teams.logo_path。 func createTeamWithLogo(t *testing.T, env *testEnv, client *http.Client, code, name, logo string) int64 { t.Helper() status, body := env.do(client, http.MethodPost, "/api/teams", map[string]any{ "code": code, "name": name, "logoPath": logo, "active": true, }, true) if status != http.StatusOK || !body.OK { t.Fatalf("建单位应 200,得到 %d %+v", status, body.Error) } return decodeData[teamCreatedBody](t, body).ID } // putImageRef 改一处赛事设置的图片引用(path 传空串表示清除)。 func putImageRef(t *testing.T, env *testEnv, client *http.Client, key, path string) { t.Helper() event := map[string]any{} display := map[string]any{} switch key { case "event.logo_path": event["logoPath"] = path case "display.logo": display["logo"] = path case "display.background": display["background"] = path default: t.Fatalf("未知的引用点 %s", key) } status, body := env.do(client, http.MethodPut, "/api/settings", // requestId 必须带上目标值:服务端按 body.requestId 做幂等回放, // 「先设成 A 再清空」用同一个 id 会让第二次直接回放第一次的响应,清空根本没落库。 map[string]any{"event": event, "display": display, "requestId": "req-ref-" + key + "-" + path}, true) if status != http.StatusOK { t.Fatalf("设置 %s 应 200,得到 %d %+v", key, status, body.Error) } } func listOrphans(t *testing.T, env *testEnv, client *http.Client) uploadOrphansBody { t.Helper() status, body := env.do(client, http.MethodGet, "/api/uploads/orphans", nil, false) if status != http.StatusOK || !body.OK { t.Fatalf("列孤儿图片应 200,得到 %d %+v", status, body.Error) } return decodeData[uploadOrphansBody](t, body) } // 孤儿清单只列「没有任何引用点用到」的图片:赛事会标与单位图标都要被认出来。 func TestListUploadOrphansOnlyUnreferenced(t *testing.T) { env, uploadsDir, client := uploadAdminEnv(t) eventLogo := uploadOne(t, env, client) teamLogo := uploadOne(t, env, client) free := uploadOne(t, env, client) if names := uploadsDirEntries(t, uploadsDir); len(names) != 3 { t.Fatalf("应先落盘 3 张图片,实际 %v", names) } putImageRef(t, env, client, "event.logo_path", eventLogo) createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo) orphans := listOrphans(t, env, client) if orphans.Total != 1 || len(orphans.Items) != 1 { t.Fatalf("只应剩 1 张孤儿,得到 total=%d items=%+v", orphans.Total, orphans.Items) } item := orphans.Items[0] if item.Name != uploadBaseName(free) { t.Fatalf("孤儿应是未被引用的那张,得到 %q,期望 %q", item.Name, uploadBaseName(free)) } if item.URL != free { t.Fatalf("孤儿 URL = %q,期望 %q", item.URL, free) } if item.Size != int64(len(pngContent())) { t.Fatalf("孤儿大小 = %d,期望 %d", item.Size, len(pngContent())) } if _, err := time.Parse(time.RFC3339, item.ModifiedAt); err != nil { t.Fatalf("修改时间应是 RFC3339,得到 %q(%v)", item.ModifiedAt, err) } if orphans.TotalBytes != item.Size { t.Fatalf("总占用 = %d,期望 %d", orphans.TotalBytes, item.Size) } // 把图配到大屏会标上,它就不再是孤儿。 putImageRef(t, env, client, "display.logo", free) if after := listOrphans(t, env, client); after.Total != 0 || len(after.Items) != 0 { t.Fatalf("全部被引用后应没有孤儿,得到 %+v", after) } } // 没有任何上传时是空清单而不是错误,便于前端直接渲染空态。 func TestListUploadOrphansEmptyWhenNoUploads(t *testing.T) { env, _, client := uploadAdminEnv(t) orphans := listOrphans(t, env, client) if orphans.Total != 0 || orphans.TotalBytes != 0 || orphans.Items == nil { t.Fatalf("空目录应回空数组而不是 null:%+v(items=%#v)", orphans, orphans.Items) } } // 删除被引用的图片必须 409,并把「被谁引用」写进提示;被引用的文件不能被删掉。 func TestDeleteUploadRejectsReferenced(t *testing.T) { env, uploadsDir, client := uploadAdminEnv(t) eventLogo := uploadOne(t, env, client) displayLogo := uploadOne(t, env, client) background := uploadOne(t, env, client) teamLogo := uploadOne(t, env, client) createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo) putImageRef(t, env, client, "event.logo_path", eventLogo) putImageRef(t, env, client, "display.logo", displayLogo) putImageRef(t, env, client, "display.background", background) cases := []struct { path string label string }{ {eventLogo, "赛事会标"}, {displayLogo, "大屏会标"}, {background, "大屏背景图"}, {teamLogo, "单位「第一中学」的单位图标"}, } for _, c := range cases { status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(c.path), nil, true) if status != http.StatusConflict { t.Fatalf("删除被引用的 %s 应 409,得到 %d %+v", c.path, status, body.Error) } if body.Error == nil || !strings.Contains(body.Error.Message, c.label) { t.Fatalf("409 提示应说明引用位置 %q:%+v", c.label, body.Error) } if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(c.path))); err != nil { t.Fatalf("被引用的图片不能被删除 %s: %v", c.path, err) } } // 清掉引用后同一张图就能删掉,文件确实消失。 putImageRef(t, env, client, "event.logo_path", "") if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(eventLogo), nil, true); status != http.StatusOK { t.Fatalf("不再被引用的图片应可删除,得到 %d %+v", status, body.Error) } if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(eventLogo))); !os.IsNotExist(err) { t.Fatalf("删除后文件应消失: %v", err) } // 名字合法但文件不存在 → 404;非法文件名一律 404,不做任何删除尝试。 if status, _ := env.do(client, http.MethodDelete, "/api/uploads/0123456789abcdef0123456789abcdef.png", nil, true); status != http.StatusNotFound { t.Fatalf("删除不存在的图片应 404,得到 %d", status) } if status, _ := env.do(client, http.MethodDelete, "/api/uploads/notes.txt", nil, true); status != http.StatusNotFound { t.Fatalf("非图片扩展名应 404,得到 %d", status) } } // 被「彻底删除」(可恢复)的单位仍算引用:恢复后图标必须还在,所以不能当孤儿删掉。 func TestUploadOrphansCountsHiddenTeam(t *testing.T) { env, _, client := uploadAdminEnv(t) logo := uploadOne(t, env, client) teamID := createTeamWithLogo(t, env, client, "T1", "第一中学", logo) if status, body := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", teamID), nil, true); status != http.StatusOK { t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, body.Error) } if orphans := listOrphans(t, env, client); orphans.Total != 0 { t.Fatalf("隐藏单位的 logo 仍算被引用,不应进孤儿清单:%+v", orphans.Items) } if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(logo), nil, true); status != http.StatusConflict { t.Fatalf("隐藏单位的 logo 应拒删,得到 %d %+v", status, body.Error) } } // 批量删除只动点名的文件:被引用的、不存在的、非法的逐条回报且不动文件。 func TestDeleteUploadOrphansBatch(t *testing.T) { env, uploadsDir, client := uploadAdminEnv(t) used := uploadOne(t, env, client) orphanA := uploadOne(t, env, client) orphanB := uploadOne(t, env, client) createTeamWithLogo(t, env, client, "T1", "第一中学", used) names := []string{uploadBaseName(orphanA), uploadBaseName(orphanB), uploadBaseName(used), "0123456789abcdef0123456789abcdef.png", "../config.yaml"} status, body := env.do(client, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": names, "requestId": "req-orphan-del-1"}, true) if status != http.StatusOK || !body.OK { t.Fatalf("批量删除应 200,得到 %d %+v", status, body.Error) } result := decodeData[uploadDeleteBody](t, body) if len(result.Deleted) != 2 || result.Deleted[0] != uploadBaseName(orphanA) || result.Deleted[1] != uploadBaseName(orphanB) { t.Fatalf("只应删掉两张孤儿,得到 %+v", result.Deleted) } if len(result.Failed) != 3 { t.Fatalf("应有 3 条失败回报,得到 %+v", result.Failed) } reasonOf := func(name string) string { for _, item := range result.Failed { if item.Name == name { return item.Reason } } t.Fatalf("失败回报里缺少 %s:%+v", name, result.Failed) return "" } if reason := reasonOf(uploadBaseName(used)); !strings.Contains(reason, "仍被引用") || !strings.Contains(reason, "单位「第一中学」") { t.Fatalf("被引用条目的原因应点名引用位置,得到 %q", reason) } if reason := reasonOf("0123456789abcdef0123456789abcdef.png"); !strings.Contains(reason, "文件不存在") { t.Fatalf("缺失文件应报不存在,得到 %q", reason) } if reason := reasonOf("../config.yaml"); !strings.Contains(reason, "文件名非法") { t.Fatalf("非法文件名应被拒,得到 %q", reason) } for _, name := range []string{uploadBaseName(orphanA), uploadBaseName(orphanB)} { if _, err := os.Stat(filepath.Join(uploadsDir, name)); !os.IsNotExist(err) { t.Fatalf("孤儿 %s 应已删除: %v", name, err) } } if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(used))); err != nil { t.Fatalf("被引用的图片不能被批量删除: %v", err) } var audits int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.delete'`).Scan(&audits); err != nil { t.Fatalf("查询删除审计: %v", err) } if audits != 1 { t.Fatalf("批量删除应写一条 uploads.delete 审计,得到 %d", audits) } // 空清单直接拒绝,不能让「没传 names」变成「什么都没删但显示成功」。 if status, _ := env.do(client, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{}, "requestId": "req-orphan-del-2"}, true); status != http.StatusBadRequest { t.Fatalf("空 names 应 400,得到 %d", status) } } // 删除与清理是系统级资源管理:guest 401、recorder 403。 func TestUploadDeleteRequiresAdmin(t *testing.T) { env, uploadsDir, admin := uploadAdminEnv(t) orphan := uploadBaseName(uploadOne(t, env, admin)) env.createUser("recorder", "recorder-pass", "recorder") guest, _ := env.client() // 各自用对应的方法:GET 那条走的是 /api/uploads/{name} 之外的兜底路由,用错方法量到的是 404 而不是权限。 if status, _ := env.do(guest, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden { t.Fatalf("guest 读孤儿清单应 403,得到 %d", status) } if status, _ := env.do(guest, http.MethodDelete, "/api/uploads/"+orphan, nil, false); status != http.StatusUnauthorized { t.Fatalf("guest 删图片应 401,得到 %d", status) } if status, _ := env.do(guest, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{orphan}}, false); status != http.StatusUnauthorized { t.Fatalf("guest 批量删孤儿应 401,得到 %d", status) } recorder, _ := env.client() env.login(recorder, "recorder", "recorder-pass") if status, _ := env.do(recorder, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden { t.Fatalf("recorder 读孤儿清单应 403,得到 %d", status) } if status, _ := env.do(recorder, http.MethodDelete, "/api/uploads/"+orphan, nil, true); status != http.StatusForbidden { t.Fatalf("recorder 删图片应 403,得到 %d", status) } if status, _ := env.do(recorder, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{orphan}, "requestId": "req-orphan-rec"}, true); status != http.StatusForbidden { t.Fatalf("recorder 批量删孤儿应 403,得到 %d", status) } // 权限不足的请求不能有任何副作用:那张孤儿必须还在。 if _, err := os.Stat(filepath.Join(uploadsDir, orphan)); err != nil { t.Fatalf("权限不足的删除请求不该动文件: %v", err) } } // 删除接口不能靠路径穿越打到 uploads 之外:uploads 同级的图片扩展名文件必须毫发无损。 func TestDeleteUploadRejectsTraversal(t *testing.T) { env, uploadsDir, client := uploadAdminEnv(t) projectDir := filepath.Dir(uploadsDir) const secret = "outside-secret" if err := os.MkdirAll(projectDir, 0o755); err != nil { t.Fatalf("创建项目目录: %v", err) } outside := filepath.Join(projectDir, "secret.png") if err := os.WriteFile(outside, []byte(secret), 0o644); err != nil { t.Fatalf("写 uploads 之外的文件: %v", err) } paths := []string{ "/api/uploads/..%2Fsecret.png", "/api/uploads/%2e%2e%2Fsecret.png", "/api/uploads/a%2Fb.png", } for _, path := range paths { status, _ := env.do(client, http.MethodDelete, path, nil, true) if status == http.StatusOK { t.Fatalf("%s 不应删成功", path) } if body, err := os.ReadFile(outside); err != nil || string(body) != secret { t.Fatalf("uploads 之外的文件被动了 %s: %v %q", path, err, body) } } }