package httpapi import ( "errors" "strings" "testing" "textmachine/platform/internal/pgstore" ) // The door exists at all, and it answers the card the canon promises: a `Book`, with the new name on // it. Before this the route was not mounted and the only name a reader ever had was the one the // intake derived from their file (unified backlog row 274). func TestRenamingABookAnswersTheCardWithTheNewName(t *testing.T) { lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Title: "book_final2", Revision: 4}} h := v0Server(t, lib, &fakeRuns{}) w := call(t, h, "PATCH", "/v0/books/bk_1", `{"title":"Мастер Гу"}`) if w.Code != 200 { t.Fatalf("PATCH answered %d: %s", w.Code, w.Body) } if lib.renamedTo != "Мастер Гу" { t.Errorf("the store was asked for %q", lib.renamedTo) } // The IDS the handler passed down, not just the fact that it called. A handler that read the // wrong path value, or the wrong principal, renames somebody else's book — and every other // assertion in this file would still pass. if lib.renamedBook != "bk_1" { t.Errorf("the handler renamed book %q, not the one in the path", lib.renamedBook) } if lib.renamedUser == "" { t.Errorf("the handler passed no principal down: ownership would be decided by nobody") } body := decode(t, w) if body["title"] != "Мастер Гу" { t.Errorf("the answer does not carry the new name: %v", body) } // `Book` and not `BookDetail`: the canon's 200 for this operation is the plain card, and it // carries a revision so a client can order this write against the frames it is watching. if _, ok := body["revision"]; !ok { t.Errorf("the answer carries no revision, so a client cannot order it: %v", body) } if _, ok := body["run"]; ok { t.Errorf("the answer carries a run, which the canon's Book does not: %v", body) } } // `title: null` is REFUSED, and that is a deliberate narrowing of RFC 7386 — where null means // "remove this member" — written into the canon: a book without a title is not a state this surface // has, and quietly restoring the derived name would be a rename nobody asked for. // // Mutation caught: decoding into a `*string`, which collapses `null` and "absent" into one value and // turns the refusal into a silent no-op. func TestRemovingATitleIsRefusedAndWritesNothing(t *testing.T) { lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Title: "kept"}} h := v0Server(t, lib, &fakeRuns{}) w := call(t, h, "PATCH", "/v0/books/bk_1", `{"title":null}`) if w.Code != 400 { t.Fatalf("null title answered %d: %s", w.Code, w.Body) } if lib.renames != 0 { t.Errorf("the store was written to on a refused patch (%d times)", lib.renames) } if !strings.Contains(w.Body.String(), "/title") { t.Errorf("the refusal does not point at the member: %s", w.Body) } } // A member absent from a merge patch is left alone — so an empty patch changes nothing, writes // nothing and does not move the revision. Without this branch an empty body would either fail or // store the zero value over the name. func TestAPatchWithNoTitleChangesNothing(t *testing.T) { lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Title: "unchanged", Revision: 9}} h := v0Server(t, lib, &fakeRuns{}) w := call(t, h, "PATCH", "/v0/books/bk_1", `{}`) if w.Code != 200 { t.Fatalf("an empty patch answered %d: %s", w.Code, w.Body) } if lib.renames != 0 { t.Errorf("an empty patch wrote to the store %d times", lib.renames) } if decode(t, w)["title"] != "unchanged" { t.Errorf("an empty patch changed the card: %s", w.Body) } } // The canon's own bounds, enforced by REFUSING rather than by repairing. The intake truncates a name // it derived from a file because the user did not choose it; here they typed it, and answering a // card with something other than what they typed is a rename they did not make. func TestATitleOutsideTheCanonsBoundsIsRefusedRatherThanTrimmed(t *testing.T) { lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Title: "kept"}} h := v0Server(t, lib, &fakeRuns{}) for name, body := range map[string]string{ "too long": `{"title":"` + strings.Repeat("ы", 201) + `"}`, "empty": `{"title":""}`, "only whitespace": `{"title":" "}`, "not a string": `{"title":42}`, } { w := call(t, h, "PATCH", "/v0/books/bk_1", body) if w.Code != 400 { t.Errorf("%s: answered %d, want 400: %s", name, w.Code, w.Body) } if !strings.Contains(w.Body.String(), "/title") { t.Errorf("%s: the refusal does not point at the member: %s", name, w.Body) } } // Exactly at the bound is ACCEPTED: an off-by-one here refuses a name the canon allows. if w := call(t, h, "PATCH", "/v0/books/bk_1", `{"title":"`+strings.Repeat("ы", 200)+`"}`); w.Code != 200 { t.Errorf("a title of exactly 200 runes was refused: %d %s", w.Code, w.Body) } if lib.renames != 1 { t.Errorf("the store was written %d times, want only the accepted one", lib.renames) } } // A body of the bare literal `null` is a REFUSAL and not an empty patch. It decodes into a nil map // without error, so every member reads as absent and the handler would have answered 200 with the // card — measured live before the guard. The canon declares this body `required: true` with // `schema: BookPatch` (`type: object`), and `null` is the one JSON value that means "no document". // // Mutation caught: dropping the nil-map check. func TestABodyOfNullIsRefusedRatherThanReadAsAnEmptyPatch(t *testing.T) { lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Title: "kept"}} h := v0Server(t, lib, &fakeRuns{}) if w := call(t, h, "PATCH", "/v0/books/bk_1", `null`); w.Code != 400 { t.Errorf("a body of `null` answered %d, want 400: %s", w.Code, w.Body) } if lib.renames != 0 { t.Errorf("a body of `null` reached the store %d times", lib.renames) } } // ⛔ A TITLE THIS SURFACE ACCEPTS MUST NOT BE ONE THE DATABASE REFUSES. Measured live before the // guard: a title carrying U+0000 reached Postgres, which cannot hold it in a `text` column, and the // caller got `500 internal_error` — a code this operation's canon does not list, for a request that // was simply wrong. // // ⚠ The second half of this test is the one that will matter later: bidi MARKS and the zero-width // joiners are ordinary content in Hebrew, Arabic, Devanagari and Persian, and a guard that banned // "everything invisible" would quietly refuse a legitimate title in a language pair this repository // does not carry yet. The engine's own inbound fence draws the line in the same place, and its own // session said so explicitly when it landed. func TestAControlCharacterInATitleIsRefusedButBidiMarksAreNot(t *testing.T) { lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Title: "kept"}} h := v0Server(t, lib, &fakeRuns{}) for name, escape := range map[string]string{ "NUL": `\u0000`, "newline": `\u000a`, "escape": `\u001b`, "line separator": `\u2028`, "paragraph separator": `\u2029`, } { w := call(t, h, "PATCH", "/v0/books/bk_1", `{"title":"be`+escape+`fore"}`) if w.Code != 400 { t.Errorf("%s: answered %d, want 400: %s", name, w.Code, w.Body) } if !strings.Contains(w.Body.String(), "/title") { t.Errorf("%s: the refusal does not point at the member", name) } } // Legitimate content that must NOT be refused. for name, escape := range map[string]string{ "right-to-left mark": `\u200f`, "left-to-right mark": `\u200e`, "zero-width joiner": `\u200d`, "soft hyphen": `\u00ad`, } { if w := call(t, h, "PATCH", "/v0/books/bk_1", `{"title":"ab`+escape+`cd"}`); w.Code != 200 { t.Errorf("%s: a legitimate title was refused with %d: %s", name, w.Code, w.Body) } } } // Somebody else's book is a 404, like every other book-scoped route: the store answers ErrNoBook for // both "not there" and "not yours", and telling them apart would let anyone enumerate libraries. func TestRenamingSomebodyElsesBookIsIndistinguishableFromAMissingOne(t *testing.T) { lib := &fakeLibrary{err: pgstore.ErrNoBook} h := v0Server(t, lib, &fakeRuns{}) w := call(t, h, "PATCH", "/v0/books/bk_secret", `{"title":"mine now"}`) if w.Code != 404 { t.Fatalf("answered %d, want 404: %s", w.Code, w.Body) } if strings.Contains(strings.ToLower(w.Body.String()), "forbidden") { t.Errorf("the answer distinguishes ownership from existence: %s", w.Body) } } // A body that is not JSON is the ordinary 400 of this surface, never a 500. func TestAMalformedPatchBodyIsTheOrdinaryRefusal(t *testing.T) { lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1"}} h := v0Server(t, lib, &fakeRuns{}) for _, body := range []string{`not json`, `[]`, `"just a string"`} { if w := call(t, h, "PATCH", "/v0/books/bk_1", body); w.Code != 400 { t.Errorf("%q answered %d, want 400: %s", body, w.Code, w.Body) } } if lib.renames != 0 { t.Errorf("a malformed patch reached the store %d times", lib.renames) } } // A store failure that is not ErrNoBook is not swallowed into a 200. func TestAStoreFailureOnRenameIsNotAnswerAsSuccess(t *testing.T) { lib := &fakeLibrary{err: errors.New("the database is down")} h := v0Server(t, lib, &fakeRuns{}) if w := call(t, h, "PATCH", "/v0/books/bk_1", `{"title":"x"}`); w.Code == 200 { t.Errorf("a failed write answered 200: %s", w.Body) } }