package httpapi import ( "context" "net/http" "strings" "testing" "textmachine/platform/internal/ingest" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/runs" ) // The correction door (canon 0.5.0 §applyBankCorrections): the wire form, its whole-form // validation, the receipt's projection and the refusal envelope. type fakeBank struct { in runs.BankCorrectionsInput called bool receipt runs.BankReceipt err error } func (f *fakeBank) ApplyBankCorrections(_ context.Context, in runs.BankCorrectionsInput) (runs.BankReceipt, error) { f.in, f.called = in, true return f.receipt, f.err } func bankServer(t *testing.T, f *fakeBank) http.Handler { t.Helper() return v0ServerWith(t, Deps{Bank: f, Capabilities: Capabilities{BankCorrectionsEnabled: true}}) } // A deployment that has not mounted the door says so twice with ONE fact: the capability is false // and the path answers 404 (canon: «declared ahead of its serving half»). func TestAnUnmountedCorrectionDoorAnswers404AndSaysSoInCapabilities(t *testing.T) { h := readingServer(t, &fakeLibrary{}) // no Bank dependency w := call(t, h, "POST", "/v0/books/bk_1/bank/corrections", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","sense":"","since_chapter":null,"until_chapter":null}]}`) if w.Code != http.StatusNotFound { t.Errorf("an unmounted door answered %d, want 404", w.Code) } caps := decode(t, call(t, h, "GET", "/v0/capabilities", "")) if got, ok := caps["bank_corrections_enabled"]; !ok || got != false { t.Errorf("bank_corrections_enabled = %v, want false on a deployment without the door", got) } } // The whole-form validation: every refusal is a 400 with the offending member's JSON Pointer, and // nothing reaches the service. The rules are the schema's own — the tuple takes ALL FOUR members, // identity is id XOR tuple, `dst` belongs to `approve` alone, the wire's TermKind is closed. func TestACorrectionRequestIsValidatedWholeWithPointers(t *testing.T) { cases := []struct { name, body string pointer string }{ {"no preview", `{"book_id":"bk_1","corrections":[{"action":"decline","id":"tm_1"}]}`, "/preview"}, {"book_id mismatch", `{"book_id":"bk_OTHER","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`, "/book_id"}, {"no corrections", `{"book_id":"bk_1","preview":true,"corrections":[]}`, "/corrections"}, {"no action", `{"book_id":"bk_1","preview":true,"corrections":[{"id":"tm_1"}]}`, "/corrections/0/action"}, {"both identities", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","src":"蛊"}]}`, "/corrections/0"}, {"a partial tuple", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","since_chapter":null,"until_chapter":null}]}`, "/corrections/0/sense"}, {"approve without dst", `{"book_id":"bk_1","preview":false,"corrections":[{"action":"approve","id":"tm_1"}]}`, "/corrections/0/dst"}, {"dst on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","dst":"Гу"}]}`, "/corrections/0/dst"}, {"kind on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","kind":"name"}]}`, "/corrections/0/kind"}, {"a kind outside the wire vocabulary", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"approve","id":"tm_1","dst":"Гу","kind":"weapon"}]}`, "/corrections/0/kind"}, {"a zero chapter", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","src":"蛊","sense":"","since_chapter":0,"until_chapter":null}]}`, "/corrections/0/since_chapter"}, // The presence rules are about KEYS, and a client that serializes every optional member as // `null` must not walk through them: an explicit null is a type violation on every string // member, seen and refused rather than collapsed into «absent» (workflow finding, P9). {"a null src smuggled into the id form", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"approve","id":"tm_1","src":null,"sense":null,"dst":"Гу"}]}`, "/corrections/0/src"}, {"a null dst on a decline", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","dst":null,"kind":null}]}`, "/corrections/0/dst"}, {"a null action", `{"book_id":"bk_1","preview":true,"corrections":[{"action":null,"id":"tm_1"}]}`, "/corrections/0/action"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { f := &fakeBank{} w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", tc.body) if w.Code != http.StatusBadRequest { t.Fatalf("status %d, want 400: %s", w.Code, w.Body) } if f.called { t.Error("an invalid document reached the service") } if !strings.Contains(w.Body.String(), `"`+tc.pointer+`"`) { t.Errorf("the 400 does not point at %s: %s", tc.pointer, w.Body) } }) } // An UNDECLARED member refuses the request — this schema is strict, unlike the run request. f := &fakeBank{} w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","aliases":["蛊虫"]}]}`) if w.Code != http.StatusBadRequest || f.called { t.Errorf("an undeclared member answered %d (service called: %v), want a 400 refusal", w.Code, f.called) } } // A valid document reaches the service in seam vocabulary — `null` windows as the seam's 0 — and // the receipt comes back in contract words: `refinement` and never the engine's wave name, `dst` // null on a decline, `displaced` a boolean, `signature` present when a stop exists. func TestAValidCorrectionRoundTripsIntoTheReceipt(t *testing.T) { f := &fakeBank{receipt: runs.BankReceipt{ Preview: false, Changed: true, Depth: "edit_wave", Accepted: []ingest.AcceptedDecision{ {Index: 0, Action: "approve", ID: "tm_9", Src: "蛊", Dst: "гу", State: "applied", Replaced: []string{"a previous rendering"}}, {Index: 1, Action: "decline", ID: "tm_2", Src: "方源", State: "already_applied"}, }, PreexistingFaults: 2, Signature: &ingest.SignatureState{Map: "/srv/books/bk_1/x.mined-signature.yaml", Surfaces: 200, Undecided: 40}, }} body := `{"book_id":"bk_1","preview":false,"corrections":[ {"action":"approve","src":"蛊","sense":"","since_chapter":null,"until_chapter":null,"dst":"гу","kind":"term","note":"почему"}, {"action":"decline","id":"tm_2"}]}` w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body) if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body) } if !f.called || f.in.BookID != "bk_1" || f.in.Preview { t.Fatalf("the service saw %+v", f.in) } if len(f.in.Decisions) != 2 { t.Fatalf("decisions: %+v", f.in.Decisions) } if d := f.in.Decisions[0]; d.Src != "蛊" || d.Sense != "" || d.SinceChapter != 0 || d.UntilChapter != 0 || d.Dst != "гу" || d.Kind != "term" || d.Note != "почему" || d.ID != "" { t.Errorf("the tuple form reached the seam as %+v", d) } if d := f.in.Decisions[1]; d.ID != "tm_2" || d.Src != "" { t.Errorf("the id form reached the seam as %+v", d) } got := decode(t, w) for _, k := range []string{"preview", "changed", "depth", "accepted", "preexisting_faults", "signature"} { if _, ok := got[k]; !ok { t.Errorf("the receipt is missing the required member %q", k) } } if got["depth"] != "refinement" { t.Errorf("depth = %v, want the contract's word", got["depth"]) } if strings.Contains(w.Body.String(), "edit_wave") { t.Errorf("the engine's wave name reached the wire: %s", w.Body) } rows, _ := got["accepted"].([]any) if len(rows) != 2 { t.Fatalf("accepted: %v", got["accepted"]) } first, _ := rows[0].(map[string]any) if first["displaced"] != true || first["state"] != "applied" || first["dst"] != "гу" { t.Errorf("accepted[0]: %v", first) } if strings.Contains(w.Body.String(), "a previous rendering") { t.Errorf("the engine's free-text itemization reached the wire: %s", w.Body) } second, _ := rows[1].(map[string]any) if second["dst"] != nil || second["displaced"] != false { t.Errorf("accepted[1]: %v", second) } sig, _ := got["signature"].(map[string]any) if sig["surfaces"] != float64(200) || sig["undecided"] != float64(40) || sig["unreadable"] != false { t.Errorf("signature: %v", got["signature"]) } if strings.Contains(w.Body.String(), "mined-signature") { t.Errorf("a server-side path reached the wire: %s", w.Body) } // No stop yet: `signature` is null, not a zeroed count that reads as "everything decided". f.receipt.Signature = nil later := decode(t, call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body)) if v, ok := later["signature"]; !ok || v != nil { t.Errorf("with no stop, signature = %v, want null", v) } } // `"preview": true` REACHES the service as a preview — the one bit that separates a safe look from // an irreversible write, pinned at the seam it crosses. The reviewer regressed exactly this with // one constant (`Preview: false`) and the whole battery stayed green; the engine-side halves are // pinned in runner (the `--dry-run` argv, and the live no-write proof). // // Mutation caught: hardcoding Preview on the way into the service, either way. func TestAPreviewStaysAPreviewAcrossTheWire(t *testing.T) { f := &fakeBank{receipt: runs.BankReceipt{Preview: true, Depth: "edit_wave"}} w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`) if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body) } if !f.called || !f.in.Preview { t.Errorf("preview:true reached the service as preview=%v — a safe look became a write", f.in.Preview) } if got := decode(t, w)["preview"]; got != true { t.Errorf("the receipt echoes preview=%v, want true", got) } f = &fakeBank{receipt: runs.BankReceipt{Preview: false, Depth: "edit_wave"}} call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", `{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"}]}`) if !f.called || f.in.Preview { t.Errorf("preview:false reached the service as preview=%v — every write would be a no-op", f.in.Preview) } } // One refused correction refuses the set: the 409 carries `refusals[]` in the Problem envelope, // each with the pointer of its correction — or the empty string for a refusal about the whole. func TestARefusedSetAnswers409WithItsRefusals(t *testing.T) { f := &fakeBank{err: &runs.ErrBankRefused{Refusals: []ingest.RejectedDecision{ {Index: 1, Reason: "declining it would change nothing"}, {Index: -1, Reason: "the set contradicts itself"}, }}} w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", `{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"},{"action":"decline","id":"tm_2"}]}`) if w.Code != http.StatusConflict { t.Fatalf("status %d: %s", w.Code, w.Body) } got := decode(t, w) if got["code"] != "bank_corrections_refused" { t.Errorf("code = %v", got["code"]) } refusals, _ := got["refusals"].([]any) if len(refusals) != 2 { t.Fatalf("refusals: %v", got["refusals"]) } first, _ := refusals[0].(map[string]any) whole, _ := refusals[1].(map[string]any) if first["pointer"] != "/corrections/1" || whole["pointer"] != "" { t.Errorf("pointers: %v / %v", first["pointer"], whole["pointer"]) } if first["detail"] == "" { t.Error("a refusal without its developer-facing reason") } } // The three remedies stay apart on the wire: «re-decide» (409 refused, above), «re-send the same» // (503 with its OWN code) and «call the operator / retry later» (503 service_unavailable) — plus // the shared table's run_in_flight and not_found. func TestTheDoorsFailuresKeepTheirRemediesApart(t *testing.T) { body := `{"book_id":"bk_1","preview":false,"corrections":[{"action":"decline","id":"tm_1"}]}` cases := []struct { err error status int code string }{ {runs.ErrBankIncomplete, http.StatusServiceUnavailable, "bank_corrections_incomplete"}, {runs.ErrBankUnavailable, http.StatusServiceUnavailable, "service_unavailable"}, {pgstore.ErrRunInFlight, http.StatusConflict, "run_in_flight"}, {pgstore.ErrNoBook, http.StatusNotFound, "not_found"}, } for _, tc := range cases { w := call(t, bankServer(t, &fakeBank{err: tc.err}), "POST", "/v0/books/bk_1/bank/corrections", body) if w.Code != tc.status { t.Errorf("%v answered %d, want %d", tc.err, w.Code, tc.status) } if got := decode(t, w)["code"]; got != tc.code { t.Errorf("%v carried code %v, want %q", tc.err, got, tc.code) } } } // A receipt whose vocabulary this build cannot map is refused, not forwarded: the depth field is // where a wave name would leak through to the one string a screen quotes. func TestAnUnmappableReceiptIsRefusedNotForwarded(t *testing.T) { f := &fakeBank{receipt: runs.BankReceipt{Depth: "draft_wave_v2"}} w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}`) if w.Code != http.StatusInternalServerError { t.Errorf("status %d, want 500", w.Code) } if strings.Contains(w.Body.String(), "draft_wave_v2") { t.Errorf("the unmapped engine word reached the wire: %s", w.Body) } } // The 5000-correction ceiling is a POINT OF STOPPING: the 400 carries the cap's own item and // nothing per element, so an oversized document cannot buy an itemization of its every flaw — a // megabyte of minimal corrections used to come back as tens of megabytes of pointers (~100×). // // Mutation caught: falling through the cap into the per-item walk. func TestTheCorrectionCountCeilingStopsTheValidation(t *testing.T) { f := &fakeBank{} var b strings.Builder b.WriteString(`{"book_id":"bk_1","preview":true,"corrections":[`) for i := 0; i < maxCorrections+1; i++ { if i > 0 { b.WriteString(",") } // Deliberately FLAWED elements: without the stop each would add its items to the answer. b.WriteString(`{"action":"decline"}`) } b.WriteString(`]}`) w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", b.String()) if w.Code != http.StatusBadRequest || f.called { t.Fatalf("status %d (service called: %v), want a 400 refusal", w.Code, f.called) } if w.Body.Len() > 1<<10 { t.Errorf("the refusal of an oversized document is %d bytes — the cap did not stop the walk", w.Body.Len()) } if !strings.Contains(w.Body.String(), `"/corrections"`) || strings.Contains(w.Body.String(), `"/corrections/0`) { t.Errorf("the 400 itemizes past the cap: %s", firstOf(w.Body.String())) } } // The canon's 1 MiB document ceiling, measured on the wire: over it is 413 `payload_too_large`, and // the service is never called. func TestAnOversizedCorrectionDocumentAnswers413(t *testing.T) { f := &fakeBank{} body := `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1","note":"` + strings.Repeat("х", 1<<20) + `"}]}` w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body) if w.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status %d, want 413: %s", w.Code, firstOf(w.Body.String())) } if got := decode(t, w)["code"]; got != "payload_too_large" { t.Errorf("code = %v", got) } if f.called { t.Error("an oversized document reached the service") } } // The engine's byte cap is measured on the RENDERED document in runs; when it refuses, the wire // word is the same 413 the body cap answers — one fact, one word — never the engine's «re-decide» // (workflow finding, P9). func TestARenderedDocumentOverTheEngineCapAnswers413(t *testing.T) { f := &fakeBank{err: runs.ErrBankDocumentTooLarge} body := `{"book_id":"bk_1","preview":true,"corrections":[{"action":"decline","id":"tm_1"}]}` w := call(t, bankServer(t, f), "POST", "/v0/books/bk_1/bank/corrections", body) if w.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status %d, want 413: %s", w.Code, firstOf(w.Body.String())) } if got := decode(t, w)["code"]; got != "payload_too_large" { t.Errorf("code = %v", got) } } func firstOf(s string) string { if len(s) > 200 { return s[:200] } return s }