338 lines
16 KiB
Go
338 lines
16 KiB
Go
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
|
||
}
|