package httpapi_test import ( "bytes" "database/sql" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" ) // importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。 const importHeader = "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。 func TestImportRequiresIDCardWhenNameGiven(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,得到 %+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 是可选列:表头缺失不报错(团体行不涉及人员)。 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) } } }