textmachine/platform/internal/httpapi/rename_test.go

208 lines
9.1 KiB
Go

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)
}
}