From 3b2280de7f9079d5151e03c78ecffc50c5367962 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Tue, 19 May 2026 10:08:52 -0500 Subject: [PATCH] test(handler): coverage for record audit + history flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds history_test.go with eight cases exercising the record-write orchestration path: - CreateStampsAuditFields: PUT to a fresh mdl path → audit fields injected; response echoes the stamped YAML; no history dir yet. - UpdateIncrementsRevisionAndArchivesPrior: second PUT archives the prior bytes under .history//-.yaml, bumps revision, preserves created_*, chains previous_sha. - ConflictPreservesHistory: 412 from stale If-Match leaves the live file untouched and writes NO history entry (the failed write must be a true no-op). - ClientAuditFieldsStripped: client-supplied created_by / revision are silently overwritten by server values — anti-forgery test. - FilenameMismatch: URL says ...-0002 but body composes to ...-0001 → 422. - LockedFieldRejected: posting type=SPC to an rsk row → 422 with /type error (rsk/ locks type=RSK via cascade). - SSRHistoryAtPartyLevel: writes to archive//ssr.yaml put history at archive//.history/ssr/, NOT at archive/.history//. - RollupCreate_AssignsRowAndComposesFilename: three POSTs to /project/rsk/form.html in two table-scope groups demonstrate the server picks up filename_format + row_field+row_scope_fields from the cascade, auto-assigns sequence row numbers per group, and composes the canonical filename. Bug fix surfaced by the first test: composeFilename was eliding TWO separators around an optional placeholder when one was correct. "ACM-{phase?}-PRJ" with phase="" was producing "ACMPRJ" instead of "ACM-PRJ". Now drops only the trailing separator from output and lets the next iteration emit the connector. Default-project-{mdl,rsk}.form.yaml updated: project-rollup MDL + RSK schemas gained the six readOnly audit fields and the project- rsk schema picked up the full table-tracking component shape (+ row) plus an enum-locked type=RSK. The required: list no longer includes type for rsk schemas — the cascade's field_defaults injects it after schema validation, and requiring it would 422 well-behaved clients. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../handler/default-project-mdl.form.yaml | 31 ++ .../handler/default-project-rsk.form.yaml | 79 +++- zddc/internal/handler/default-rsk.form.yaml | 5 +- zddc/internal/handler/history.go | 15 +- zddc/internal/handler/history_test.go | 391 ++++++++++++++++++ 5 files changed, 508 insertions(+), 13 deletions(-) create mode 100644 zddc/internal/handler/history_test.go diff --git a/zddc/internal/handler/default-project-mdl.form.yaml b/zddc/internal/handler/default-project-mdl.form.yaml index 423a7b1..2ee7437 100644 --- a/zddc/internal/handler/default-project-mdl.form.yaml +++ b/zddc/internal/handler/default-project-mdl.form.yaml @@ -86,6 +86,37 @@ schema: notes: type: string title: Notes + + # --- Audit fields (server-managed; read-only). + created_at: + type: string + title: Created + format: date-time + readOnly: true + created_by: + type: string + title: Created by + format: email + readOnly: true + updated_at: + type: string + title: Updated + format: date-time + readOnly: true + updated_by: + type: string + title: Updated by + format: email + readOnly: true + revision: + type: integer + title: Revision + minimum: 1 + readOnly: true + previous_sha: + type: string + title: Previous SHA + readOnly: true ui: notes: ui:widget: textarea diff --git a/zddc/internal/handler/default-project-rsk.form.yaml b/zddc/internal/handler/default-project-rsk.form.yaml index a03b09f..f75aeb9 100644 --- a/zddc/internal/handler/default-project-rsk.form.yaml +++ b/zddc/internal/handler/default-project-rsk.form.yaml @@ -17,7 +17,12 @@ description: One risk across all parties. The first field (Package) routes the r schema: type: object - required: [party, id, title] + # `type` is intentionally absent from required: — the cascade's + # field_defaults inject type=RSK after schema validation, and the + # form renderer surfaces it as a locked readOnly field. Requiring + # it here would 422 well-behaved clients that omit the cascade- + # owned field. + required: [party, originator, project, discipline, sequence, title] additionalProperties: false properties: party: @@ -26,11 +31,46 @@ schema: description: Routing key — must match an existing /archive// folder. pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*$" minLength: 1 - id: + + # --- Table-tracking components (same shape as the per-party rsk + # schema). Together with `row` they compose the filename. + originator: type: string - title: ID - description: Stable identifier, e.g. R-001. + title: Originator minLength: 1 + phase: + type: string + title: Phase + project: + type: string + title: Project + minLength: 1 + area: + type: string + title: Area + discipline: + type: string + title: Discipline + minLength: 1 + type: + type: string + title: Document type + description: Locked to RSK by the cascade; the form renders this read-only. + enum: [RSK] + sequence: + type: string + title: Sequence + minLength: 1 + suffix: + type: string + title: Suffix + row: + type: string + title: Row + description: Zero-padded sequence within the parent register. Server-assigned. + readOnly: true + + # --- Risk-level data. title: type: string title: Risk @@ -73,6 +113,37 @@ schema: notes: type: string title: Notes + + # --- Audit fields (server-managed; read-only). + created_at: + type: string + title: Created + format: date-time + readOnly: true + created_by: + type: string + title: Created by + format: email + readOnly: true + updated_at: + type: string + title: Updated + format: date-time + readOnly: true + updated_by: + type: string + title: Updated by + format: email + readOnly: true + revision: + type: integer + title: Revision + minimum: 1 + readOnly: true + previous_sha: + type: string + title: Previous SHA + readOnly: true ui: description: ui:widget: textarea diff --git a/zddc/internal/handler/default-rsk.form.yaml b/zddc/internal/handler/default-rsk.form.yaml index eaf1e74..683827b 100644 --- a/zddc/internal/handler/default-rsk.form.yaml +++ b/zddc/internal/handler/default-rsk.form.yaml @@ -36,7 +36,10 @@ description: One identified risk. The first eight fields together identify the p schema: type: object - required: [originator, project, discipline, type, sequence, title] + # `type` is intentionally absent from required: — the cascade's + # field_defaults inject type=RSK after schema validation, and the + # form renderer surfaces it as a locked readOnly field. + required: [originator, project, discipline, sequence, title] additionalProperties: false properties: # --- Table-tracking components: identify which RSK deliverable diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go index 9d99baf..536e3c6 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -369,19 +369,18 @@ func composeFilename(format string, body map[string]any) (string, error) { if !optional { return "", fmt.Errorf("filename_format: required field %q is missing or empty", name) } - // Drop the trailing separator we just wrote, if any: - // avoids "A-B-" or "A--C" when an optional middle - // segment elides. + // Drop the trailing separator we just wrote, if any. + // For "A-{b?}-C" with b empty we want "A-C": dropping + // the preceding '-' here, then letting the next + // iteration emit the trailing '-' from the format, is + // exactly one connector between A and C. (Earlier + // versions of this code also skipped the leading + // separator, which double-elided.) s := out.String() if n := len(s); n > 0 && (s[n-1] == '-' || s[n-1] == '_') { out.Reset() out.WriteString(s[:n-1]) } - // And skip a leading separator that immediately - // follows the elided field. - if i < len(format) && (format[i] == '-' || format[i] == '_') { - i++ - } continue } out.WriteString(val) diff --git a/zddc/internal/handler/history_test.go b/zddc/internal/handler/history_test.go new file mode 100644 index 0000000..926ab57 --- /dev/null +++ b/zddc/internal/handler/history_test.go @@ -0,0 +1,391 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/config" + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" + "gopkg.in/yaml.v3" +) + +// historyTestSetup wires a fresh root with the embedded defaults +// (which declare the records: rules for mdl/rsk/ssr) plus a +// permissive ACL for *@example.com so the test cohort can write +// anywhere under archive/. +// +// Returns (cfg, do) where do invokes ServeFileAPI directly — we +// bypass the dispatch tree because the system-under-test is the +// serveFilePut path, not the entire HTTP stack. +func historyTestSetup(t *testing.T) (config.Config, func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder) { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".zddc"), + []byte("acl:\n permissions:\n \"*@example.com\": rwcd\n"), 0o644); err != nil { + t.Fatal(err) + } + zddc.InvalidateCache(root) + cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"} + + do := func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, target, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/yaml") + } else { + req = httptest.NewRequest(method, target, nil) + } + for k, v := range headers { + req.Header.Set(k, v) + } + ctx := context.WithValue(req.Context(), EmailKey, email) + ctx = context.WithValue(ctx, ElevatedKey, true) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + ServeFileAPI(cfg, rec, req) + return rec + } + return cfg, do +} + +// TestRecordPut_CreateStampsAuditFields verifies that a PUT to a +// fresh mdl row inserts created_*, updated_*, revision=1 server-side, +// and that the response body echoes the stamped YAML. +func TestRecordPut_CreateStampsAuditFields(t *testing.T) { + cfg, do := historyTestSetup(t) + + // Build a body with the right components for the embedded + // mdl rule's filename_format. + body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Test spec\n") + url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" + + rec := do(http.MethodPut, url, "alice@example.com", body, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + // Response body should be the stamped YAML. + out := map[string]any{} + if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("parse response body: %v", err) + } + if out["created_by"] != "alice@example.com" { + t.Errorf("created_by=%v want alice@example.com", out["created_by"]) + } + if out["updated_by"] != "alice@example.com" { + t.Errorf("updated_by=%v want alice@example.com", out["updated_by"]) + } + if out["revision"] != 1 { + t.Errorf("revision=%v want 1", out["revision"]) + } + if out["created_at"] == "" || out["updated_at"] == "" { + t.Errorf("created_at/updated_at empty: %+v", out) + } + if _, hasPrev := out["previous_sha"]; hasPrev { + t.Errorf("previous_sha should be absent on create: %+v", out) + } + + // On-disk file matches the response body. + abs := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", "ACM-PRJ-EL-SPC-0001.yaml") + disk, err := os.ReadFile(abs) + if err != nil { + t.Fatalf("read disk: %v", err) + } + if !bytes.Equal(disk, rec.Body.Bytes()) { + t.Errorf("response body != disk bytes\nresponse=%s\ndisk=%s", rec.Body.String(), disk) + } + + // No history dir yet (create only). + histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history") + if _, err := os.Stat(histDir); !os.IsNotExist(err) { + t.Errorf(".history/ should not exist after create-only; got err=%v", err) + } +} + +// TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior verifies +// that the second write captures the first into .history//, +// chains previous_sha, and increments revision. +func TestRecordPut_UpdateIncrementsRevisionAndArchivesPrior(t *testing.T) { + cfg, do := historyTestSetup(t) + url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" + + body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n") + rec := do(http.MethodPut, url, "alice@example.com", body1, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) + } + firstEtag := strings.Trim(rec.Result().Header.Get("ETag"), `"`) + + body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n") + rec = do(http.MethodPut, url, "bob@example.com", body2, map[string]string{ + "If-Match": `"` + firstEtag + `"`, + }) + if rec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String()) + } + + out := map[string]any{} + if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("parse update response: %v", err) + } + if out["created_by"] != "alice@example.com" { + t.Errorf("created_by should be preserved as alice: %v", out["created_by"]) + } + if out["updated_by"] != "bob@example.com" { + t.Errorf("updated_by should be bob: %v", out["updated_by"]) + } + if out["revision"] != 2 { + t.Errorf("revision=%v want 2", out["revision"]) + } + if out["previous_sha"] == "" || out["previous_sha"] == nil { + t.Errorf("previous_sha should be non-empty on update: %+v", out) + } + + // .history/ACM-PRJ-EL-SPC-0001/ has exactly one entry (the v1 bytes). + histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history", "ACM-PRJ-EL-SPC-0001") + ents, err := os.ReadDir(histDir) + if err != nil { + t.Fatalf("read history dir: %v", err) + } + if len(ents) != 1 { + t.Fatalf("history entries=%d want 1", len(ents)) + } + // The archived file's title is V1 (the prior version). + prior, err := os.ReadFile(filepath.Join(histDir, ents[0].Name())) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(prior, []byte("title: V1")) { + t.Errorf("archived prior version missing title=V1; got %s", prior) + } +} + +// TestRecordPut_ConflictPreservesHistory ensures a 412 doesn't +// write anything — no history entry, no overwrite. +func TestRecordPut_ConflictPreservesHistory(t *testing.T) { + cfg, do := historyTestSetup(t) + url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" + + body1 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V1\n") + if rec := do(http.MethodPut, url, "alice@example.com", body1, nil); rec.Code != http.StatusCreated { + t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String()) + } + + body2 := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: V2\n") + rec := do(http.MethodPut, url, "bob@example.com", body2, map[string]string{ + "If-Match": `"deadbeefdeadbeefdeadbeefdeadbeef"`, // wrong + }) + if rec.Code != http.StatusPreconditionFailed { + t.Fatalf("expected 412, got %d body=%s", rec.Code, rec.Body.String()) + } + + histDir := filepath.Join(cfg.Root, "Project", "archive", "Acme", "mdl", ".history") + if _, err := os.Stat(histDir); !os.IsNotExist(err) { + t.Errorf("history dir should not exist after 412 conflict; got err=%v", err) + } +} + +// TestRecordPut_ClientAuditFieldsStripped: client tries to forge +// audit fields → server silently strips and overwrites them. +func TestRecordPut_ClientAuditFieldsStripped(t *testing.T) { + _, do := historyTestSetup(t) + url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0001.yaml" + + body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: Forged\n" + + "created_by: eve@evil.com\nupdated_by: eve@evil.com\nrevision: 999\n") + rec := do(http.MethodPut, url, "alice@example.com", body, nil) + if rec.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + out := map[string]any{} + if err := yaml.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out["created_by"] != "alice@example.com" { + t.Errorf("client-forged created_by leaked through: %v", out["created_by"]) + } + if out["revision"] != 1 { + t.Errorf("client-forged revision leaked through: %v", out["revision"]) + } +} + +// TestRecordPut_FilenameMismatch: body fields compose to a different +// tracking number than the URL → 422 with a "/" path error. +func TestRecordPut_FilenameMismatch(t *testing.T) { + _, do := historyTestSetup(t) + // URL claims sequence=0002 but body says 0001 → mismatch. + url := "/Project/archive/Acme/mdl/ACM-PRJ-EL-SPC-0002.yaml" + body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\ntitle: X\n") + rec := do(http.MethodPut, url, "alice@example.com", body, nil) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestRecordPut_LockedFieldRejected: rsk rule locks type=RSK; a +// client submitting type=SPC for an rsk row gets 422 with +// path=/type. +func TestRecordPut_LockedFieldRejected(t *testing.T) { + _, do := historyTestSetup(t) + url := "/Project/archive/Acme/rsk/ACM-PRJ-EL-RSK-0001-001.yaml" + // Client tries type=SPC even though rsk/ locks type=RSK. + body := []byte("originator: ACM\nproject: PRJ\ndiscipline: EL\ntype: SPC\nsequence: '0001'\nrow: '001'\ntitle: X\n") + rec := do(http.MethodPut, url, "alice@example.com", body, nil) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d body=%s", rec.Code, rec.Body.String()) + } + var errs struct { + Errors []struct { + Path string `json:"path"` + Message string `json:"message"` + } `json:"errors"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &errs) + found := false + for _, e := range errs.Errors { + if e.Path == "/type" { + found = true + } + } + if !found { + t.Errorf("expected /type lock error; got %v", errs.Errors) + } +} + +// TestRecordPut_SSRHistoryAtPartyLevel: writing to an SSR row's +// canonical archive//ssr.yaml puts history at +// archive//.history/ssr/, NOT at archive/.history//. +func TestRecordPut_SSRHistoryAtPartyLevel(t *testing.T) { + cfg, do := historyTestSetup(t) + // We bypass the SSR create handler and just PUT directly to the + // canonical path the SSR rewrites would land on. + abs := filepath.Join(cfg.Root, "Project", "archive", "0330C1") + if err := os.MkdirAll(abs, 0o755); err != nil { + t.Fatal(err) + } + // The plain file API uses the bytes as-is; ssr.yaml's records: + // rule will trigger audit stamping but no filename composition + // (no filename_format on the SSR records: entry). + url := "/Project/archive/0330C1/ssr.yaml" + body := []byte("kind: SSR\nvendorType: subcontractor\ncontractNo: PO-001\nscopeSummary: Concrete\n") + if rec := do(http.MethodPut, url, "alice@example.com", body, nil); rec.Code != http.StatusCreated { + t.Fatalf("first put status=%d body=%s", rec.Code, rec.Body.String()) + } + + // Read back current ETag so we can update with If-Match. + getRec := do(http.MethodGet, url, "alice@example.com", nil, nil) + _ = getRec // ETag rebuilt from disk by fileETag inside serveFilePut + + rec := do(http.MethodPut, url, "bob@example.com", []byte("kind: SSR\nvendorType: supplier\ncontractNo: PO-002\nscopeSummary: Pipe\n"), nil) + if rec.Code != http.StatusOK { + t.Fatalf("second put status=%d body=%s", rec.Code, rec.Body.String()) + } + + // History at archive/0330C1/.history/ssr/, NOT at archive/.history/. + wanted := filepath.Join(cfg.Root, "Project", "archive", "0330C1", ".history", "ssr") + if _, err := os.Stat(wanted); err != nil { + t.Fatalf("expected history at %s; err=%v", wanted, err) + } + bad := filepath.Join(cfg.Root, "Project", "archive", ".history") + if _, err := os.Stat(bad); !os.IsNotExist(err) { + t.Errorf("history must NOT live at %s; err=%v", bad, err) + } +} + +// TestRollupCreate_AssignsRowAndComposesFilename: posting an rsk +// row via the rollup create endpoint causes the server to compute +// the filename from the body components AND auto-assign the next +// row number within the table-scope group. +func TestRollupCreate_AssignsRowAndComposesFilename(t *testing.T) { + cfg, _ := historyTestSetup(t) + // Materialize the party folder (rollup create requires it). + partyAbs := filepath.Join(cfg.Root, "Project", "archive", "0330C1") + if err := os.MkdirAll(partyAbs, 0o755); err != nil { + t.Fatal(err) + } + + // First row: full table-tracking components + the routing party + // field. Server should pick row=001. + body1 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Schedule slip"}` + rec := doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body1) + if rec.Code != http.StatusCreated { + t.Fatalf("first rsk create status=%d body=%s", rec.Code, rec.Body.String()) + } + loc := rec.Result().Header.Get("Location") + if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-001.yaml") { + t.Errorf("first row location=%q want ...-RSK-0001-001.yaml", loc) + } + + // Second row in the same table: row=002. + body2 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0001","title":"Cost overrun"}` + rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body2) + if rec.Code != http.StatusCreated { + t.Fatalf("second rsk create status=%d body=%s", rec.Code, rec.Body.String()) + } + loc = rec.Result().Header.Get("Location") + if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0001-002.yaml") { + t.Errorf("second row location=%q want ...-RSK-0001-002.yaml", loc) + } + + // Different table-scope (sequence=0002) restarts at row=001. + body3 := `{"party":"0330C1","originator":"ACM","project":"PRJ","discipline":"EL","sequence":"0002","title":"Risk in second register"}` + rec = doForm(t, cfg, "POST", "/Project/rsk/form.html", "alice@example.com", body3) + if rec.Code != http.StatusCreated { + t.Fatalf("third rsk create status=%d body=%s", rec.Code, rec.Body.String()) + } + loc = rec.Result().Header.Get("Location") + if !strings.Contains(loc, "ACM-PRJ-EL-RSK-0002-001.yaml") { + t.Errorf("third row (new scope) location=%q want ...-RSK-0002-001.yaml", loc) + } + + // All three files contain audit fields (proves WriteWithHistory ran). + rskDir := filepath.Join(partyAbs, "rsk") + ents, err := os.ReadDir(rskDir) + if err != nil { + t.Fatal(err) + } + yamlCount := 0 + for _, e := range ents { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + yamlCount++ + data, err := os.ReadFile(filepath.Join(rskDir, e.Name())) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte("created_by: alice@example.com")) { + t.Errorf("%s missing created_by stamp: %s", e.Name(), data) + } + } + if yamlCount != 3 { + t.Errorf("expected 3 rsk row files; got %d", yamlCount) + } +} + +// doForm is a small helper that dispatches a form POST through +// RecognizeFormRequest → ServeForm (the rollup/SSR create entry +// point). Lets the history tests share the same harness without +// pulling in the full ssrTestSetup helper. +func doForm(t *testing.T, cfg config.Config, method, target, email, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + ctx := context.WithValue(req.Context(), EmailKey, email) + ctx = context.WithValue(ctx, ElevatedKey, true) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + formReq := RecognizeFormRequest(cfg.Root, method, target) + if formReq == nil { + t.Fatalf("RecognizeFormRequest returned nil for %s %s", method, target) + } + ServeForm(cfg, formReq, rec, req) + return rec +}