textmachine/platform/internal/httpapi/bank_test.go

338 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
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
}