package httpapi import ( "bytes" "context" "mime" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "textmachine/platform/internal/exports" "textmachine/platform/internal/pgstore" ) type fakeExports struct { formats []string created pgstore.Export read pgstore.Export err error // file is handed back by Open; empty means Open answers openErr. file string openErr error // calls records what the door was asked for, so a test can assert the handler's own decisions // rather than the service's. createdFormat string creates int } func (f *fakeExports) Knows(format string) bool { for _, x := range f.formats { if x == format { return true } } return false } func (f *fakeExports) Create(_ context.Context, _, _, format string) (pgstore.Export, error) { f.creates++ f.createdFormat = format return f.created, f.err } func (f *fakeExports) Read(context.Context, string, string, string) (pgstore.Export, error) { return f.read, f.err } func (f *fakeExports) Open(context.Context, string, string, string) (*os.File, pgstore.Export, error) { if f.openErr != nil { return nil, f.read, f.openErr } h, err := os.Open(f.file) return h, f.read, err } func exportServer(t *testing.T, e Exports, lib Library) http.Handler { t.Helper() if lib == nil { lib = &fakeLibrary{} } return v0ServerWith(t, Deps{Library: lib, Runs: &fakeRuns{}, Exports: e}) } // The 202 carries the status resource in `Location` and a body with every member `Export` declares. // Both halves matter: without `Location` the creating call is a dead end, and a member the canon // requires but this build omits is a client crashing on a field it was generated to expect. func TestCreatingAnExportAnswersTheAddressToPollAndTheWholeExportShape(t *testing.T) { e := &fakeExports{formats: []string{"epub", "txt"}, created: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportPending, Revision: 1841}} w := call(t, exportServer(t, e, nil), "POST", "/v0/books/bk_1/exports", `{"format":"epub"}`) if w.Code != http.StatusAccepted { t.Fatalf("status %d: %s", w.Code, w.Body) } if got, want := w.Header().Get("Location"), "/v0/books/bk_1/exports/exp_1"; got != want { t.Errorf("Location %q, want %q", got, want) } got := decode(t, w) for _, k := range []string{"id", "revision", "state", "format", "expires_at", "failure_code", "url"} { if _, ok := got[k]; !ok { t.Errorf("Export is missing the required member %q", k) } } if got["state"] != "pending" || got["format"] != "epub" || got["id"] != "exp_1" { t.Errorf("body %v", got) } // `null` and not absent, and not a link: a `pending` export has no artifact, and a URL published // before there is a file is a 404 a client is invited to follow. if got["url"] != nil || got["expires_at"] != nil || got["failure_code"] != nil { t.Errorf("a pending export published url=%v expires_at=%v failure_code=%v", got["url"], got["expires_at"], got["failure_code"]) } // Nothing about WHERE the file is or whether it came out whole crosses: the first is server // topology, the second is the operator's — the honesty a reader needs is inside the file. for _, forbidden := range []string{"path", "size_bytes", "complete", "book_id"} { if _, ok := got[forbidden]; ok { t.Errorf("the wire carries %q", forbidden) } } } // The door refuses NOTHING by a book's state, and the canon says so in words: «Nothing about a // book's state conflicts with exporting it». A book still being cut, one that was rejected, one // being translated — every one of them is accepted, and what such a book can be given travels as the // export's own outcome on the resource the canon provides for it. // // Mutation caught: restoring a `book_not_ready` branch to failExport, or any other status keyed on // the book rather than on the request. func TestNoBookStateIsARefusalOnThisDoor(t *testing.T) { e := &fakeExports{formats: []string{"epub"}, created: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportPending}} w := call(t, exportServer(t, e, nil), "POST", "/v0/books/bk_1/exports", `{"format":"epub"}`) if w.Code != http.StatusAccepted { t.Fatalf("status %d: %s", w.Code, w.Body) } // And the outcome such a book gets, on the poll rather than on the request. e.read = pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportFailed, FailureCode: exports.FailureBookEmpty} got := decode(t, call(t, exportServer(t, e, nil), "GET", "/v0/books/bk_1/exports/exp_1", "")) if got["state"] != "failed" || got["failure_code"] != exports.FailureBookEmpty { t.Errorf("body %v", got) } } // A format outside `GET /capabilities` is `400` and never reaches the service. The canon says so in // words, and the alternative — accepting it and failing the build — costs a queued job and tells the // user the service is broken rather than that the request was. // // Mutation caught: removing the Knows check from createExport. func TestAFormatOutsideTheCapabilityIsRefusedOnTheWire(t *testing.T) { e := &fakeExports{formats: []string{"epub"}} h := exportServer(t, e, nil) w := call(t, h, "POST", "/v0/books/bk_1/exports", `{"format":"pdf"}`) if w.Code != http.StatusBadRequest { t.Fatalf("status %d: %s", w.Code, w.Body) } if e.creates != 0 { t.Error("a format outside the set still reached the service") } if w := call(t, h, "POST", "/v0/books/bk_1/exports", `{}`); w.Code != http.StatusBadRequest { t.Errorf("a request with no format answered %d, want 400", w.Code) } } // A poll of a running build carries `Retry-After`, and only then. The canon declares the header on // the `200` precisely because RFC 9110 does not define it for one — a client that polls without it // either hammers the service or invents an interval. func TestOnlyARunningBuildTellsTheClientWhenToComeBack(t *testing.T) { e := &fakeExports{formats: []string{"epub"}, read: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportPending}} h := exportServer(t, e, nil) w := call(t, h, "GET", "/v0/books/bk_1/exports/exp_1", "") if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body) } if w.Header().Get("Retry-After") == "" { t.Error("a pending export does not say when to poll again") } ready := time.Now().Add(time.Hour) e.read = pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportReady, ExpiresAt: &ready} w = call(t, h, "GET", "/v0/books/bk_1/exports/exp_1", "") if w.Header().Get("Retry-After") != "" { t.Error("a finished export still asks the client to poll") } got := decode(t, w) if got["url"] != "/v0/books/bk_1/exports/exp_1/content" { t.Errorf("url %v, want the artifact's address on this origin", got["url"]) } if got["expires_at"] == nil { t.Error("a ready export publishes no expiry, so a client cannot know when the link stops working") } } // A failed export names a machine reason and publishes no link. The poll ENDS here — which is the // one thing the canon requires of this resource — and the reason is the client's to phrase. func TestAFailedExportEndsThePollWithAMachineReasonAndNoLink(t *testing.T) { e := &fakeExports{formats: []string{"epub"}, read: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportFailed, FailureCode: exports.FailureBookEmpty}} w := call(t, exportServer(t, e, nil), "GET", "/v0/books/bk_1/exports/exp_1", "") got := decode(t, w) if got["state"] != "failed" || got["failure_code"] != exports.FailureBookEmpty { t.Errorf("body %v", got) } if got["url"] != nil { t.Errorf("a failed export published a link: %v", got["url"]) } } // The three refusals of the download, each with the status whose remedy is the right one: poll again // (404 while pending), build again (410 once expired), and «this is not yours or does not exist» // (404) — which is ONE answer on purpose, because two would tell a stranger whose book it is. func TestTheDownloadAnswersEachKindOfNotNowWithItsOwnStatus(t *testing.T) { for _, tc := range []struct { name string err error want int }{ {"still building", exports.ErrNotReady, http.StatusNotFound}, {"lapsed", exports.ErrExpired, http.StatusGone}, {"somebody else's", pgstore.ErrNoExport, http.StatusNotFound}, } { e := &fakeExports{formats: []string{"epub"}, openErr: tc.err} w := call(t, exportServer(t, e, nil), "GET", "/v0/books/bk_1/exports/exp_1/content", "") if w.Code != tc.want { t.Errorf("%s: status %d, want %d (%s)", tc.name, w.Code, tc.want, w.Body) } } } // The artifact itself: the bytes, a name a reader can keep, and the headers that stop a browser // treating a book as a page. `filename*` is what carries a title in any script — without it a // Chinese or Russian book downloads under a mangled name or under its opaque id. func TestTheArtifactIsServedAsADownloadUnderTheBooksOwnName(t *testing.T) { dir := t.TempDir() art := filepath.Join(dir, "exp_1.epub") if err := os.WriteFile(art, []byte("PK\x03\x04book"), 0o600); err != nil { t.Fatal(err) } ready := time.Now().Add(time.Hour) e := &fakeExports{formats: []string{"epub"}, file: art, read: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportReady, ExpiresAt: &ready}} lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Title: "蛊真人"}} w := call(t, exportServer(t, e, lib), "GET", "/v0/books/bk_1/exports/exp_1/content", "") if w.Code != http.StatusOK { t.Fatalf("status %d: %s", w.Code, w.Body) } if w.Body.String() != "PK\x03\x04book" { t.Errorf("the bytes came back changed: %q", w.Body.String()) } cd := w.Header().Get("Content-Disposition") // ⚠ ParseMediaType DECODES the extended form and returns it under the plain key, dropping // `filename*` — so it proves the header parses and what a current client ends up with, and the // presence of BOTH forms is asserted on the raw string below. kind, params, err := mime.ParseMediaType(cd) if err != nil { t.Fatalf("Content-Disposition %q does not parse: %v", cd, err) } if kind != "attachment" { t.Errorf("disposition %q, want attachment: a book is saved, not rendered", kind) } if params["filename"] != "蛊真人.epub" { t.Errorf("a current client would save this as %q, not under the book's own title", params["filename"]) } if !strings.Contains(cd, `filename="`) { t.Errorf("Content-Disposition %q has no plain filename: the half an old client reads", cd) } if !strings.Contains(cd, "filename*=UTF-8''") { t.Errorf("Content-Disposition %q has no RFC 8187 form: a title outside ASCII would arrive mangled", cd) } if w.Header().Get("X-Content-Type-Options") != "nosniff" { t.Error("the download may be sniffed into markup") } // PT-34 on the artifact too: a book behind a link is exactly what must not be indexed. if !strings.Contains(w.Header().Get("X-Robots-Tag"), "noindex") || w.Header().Get("Cache-Control") != "no-store" { t.Errorf("robots %q cache %q", w.Header().Get("X-Robots-Tag"), w.Header().Get("Cache-Control")) } } // A title that reduces to nothing in ASCII still produces a usable plain `filename` — the half of // RFC 6266 an old client reads. Without the fallback the header is either unparseable or names an // empty file. func TestATitleWithNoAsciiInItStillHasAPlainFileName(t *testing.T) { cd := exportDisposition("蛊真人", "epub") if !strings.Contains(cd, `filename="book.epub"`) { t.Errorf("%q carries no usable plain filename: a title that reduces to nothing in ASCII would "+ "otherwise name an empty file", cd) } _, params, err := mime.ParseMediaType(cd) if err != nil { t.Fatalf("%q does not parse: %v", cd, err) } if params["filename"] != "蛊真人.epub" { t.Errorf("the extended form decoded to %q", params["filename"]) } // A separator in a title must not survive into either form: it would name a path. slashed := exportDisposition("a/b", "txt") if strings.Contains(slashed, "a/b") || !strings.Contains(slashed, "%2F") { t.Errorf("a title with a separator was not escaped: %q", slashed) } } // A deployment that declares no format mounts none of the three routes, and the canon's shape for // an unbuilt route is the guarded 404 — never a 500 from a nil service. // // Mutation caught: mounting the export routes unconditionally. func TestADeploymentThatBuildsNoExportsServesTheGuarded404(t *testing.T) { h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Runs: &fakeRuns{}}) for _, tc := range []struct{ method, path string }{ {"POST", "/v0/books/bk_1/exports"}, {"GET", "/v0/books/bk_1/exports/exp_1"}, {"GET", "/v0/books/bk_1/exports/exp_1/content"}, } { w := call(t, h, tc.method, tc.path, `{"format":"epub"}`) if w.Code != http.StatusNotFound { t.Errorf("%s %s answered %d on a deployment with no export door, want 404", tc.method, tc.path, w.Code) } } } // The THIRD creating call takes `Idempotency-Key`, and the property that matters is the canon's own // sentence: «Repeating with the same `Idempotency-Key` returns the original `202` and `Location` // rather than building a second copy». A door that only replayed the BODY would hand a client the // right export id in a response whose `Location` header is gone — and `Location` is what the canon // says the client polls. // // Mutation caught: passing "" instead of `loc` to key.complete (the replay loses its address), or // dropping beginIdempotent from createExport altogether (every retry builds another book). func TestARepeatedExportRequestReplaysTheFirstAnswerAndBuildsNothingNew(t *testing.T) { keys := newFakeKeys() e := &fakeExports{formats: []string{"epub"}, created: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportPending}} h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Runs: &fakeRuns{}, Exports: e, Keys: keys}) first := exportPost(t, h, "one-per-gesture", `{"format":"epub"}`) if first.Code != http.StatusAccepted { t.Fatalf("status %d: %s", first.Code, first.Body) } again := exportPost(t, h, "one-per-gesture", `{"format":"epub"}`) if again.Code != http.StatusAccepted { t.Fatalf("the replay answered %d: %s", again.Code, again.Body) } if got, want := again.Header().Get("Location"), first.Header().Get("Location"); got != want { t.Errorf("the replay's Location is %q and the first answer's was %q", got, want) } if again.Body.String() != first.Body.String() { t.Errorf("the replay answered a different body:\n%s\n%s", first.Body, again.Body) } if e.creates != 1 { t.Errorf("the service built %d exports under one key", e.creates) } } // The same key with a DIFFERENT request is `409 key_reused`, and the fingerprint that decides it is // this request's own bytes. Pinned because the tempting fingerprint — the route pattern, or the // book id — would make two legitimate formats of one book collide under one key. func TestOneKeyOnTwoDifferentFormatsIsAConflictAndNotASecondBuild(t *testing.T) { keys := &scopedKeys{seen: map[string][]byte{}} e := &fakeExports{formats: []string{"epub", "txt"}, created: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportPending}} h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Runs: &fakeRuns{}, Exports: e, Keys: keys}) if w := exportPost(t, h, "same-key", `{"format":"epub"}`); w.Code != http.StatusAccepted { t.Fatalf("status %d: %s", w.Code, w.Body) } w := exportPost(t, h, "same-key", `{"format":"txt"}`) if w.Code != http.StatusConflict { t.Fatalf("a different request under the same key answered %d: %s", w.Code, w.Body) } if got := decode(t, w); got["code"] != "idempotency_conflict" { t.Errorf("code %v, want idempotency_conflict", got["code"]) } if e.creates != 1 { t.Errorf("the service built %d exports; the conflicting one must not have been built", e.creates) } } // Two DIFFERENT books under one key are two different requests, because the scope is // (principal, method, PATH). Without the path in the scope, a client that reuses one key across its // library gets the first book's export address for the second book's export. func TestOneKeyOnTwoBooksIsTwoRequestsBecauseThePathIsPartOfTheScope(t *testing.T) { keys := newFakeKeys() e := &fakeExports{formats: []string{"epub"}, created: pgstore.Export{ID: "exp_1", BookID: "bk_1", Format: "epub", State: pgstore.ExportPending}} h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Runs: &fakeRuns{}, Exports: e, Keys: keys}) if w := exportPostTo(t, h, "/v0/books/bk_1/exports", "shared", `{"format":"epub"}`); w.Code != http.StatusAccepted { t.Fatalf("first: %d %s", w.Code, w.Body) } e.created = pgstore.Export{ID: "exp_2", BookID: "bk_2", Format: "epub", State: pgstore.ExportPending} w := exportPostTo(t, h, "/v0/books/bk_2/exports", "shared", `{"format":"epub"}`) if w.Code != http.StatusAccepted { t.Fatalf("a second book under the same key answered %d: %s", w.Code, w.Body) } if got, want := w.Header().Get("Location"), "/v0/books/bk_2/exports/exp_2"; got != want { t.Errorf("Location %q, want %q — the second book got the first one's address", got, want) } if len(keys.claims) != 2 || keys.claims[0].Path == keys.claims[1].Path { t.Errorf("the two claims share a path: %+v", keys.claims) } } // scopedKeys is the store's OWN rule, which the shared fake does not model: a claim is keyed by // (principal, method, path, key) and a second claim on that scope with a DIFFERENT fingerprint is // `key_reused`. Written here because the answer under test is produced by that rule and by nothing // on this side — a fake that keys on the fingerprint too can never say "reused". type scopedKeys struct{ seen map[string][]byte } func (k *scopedKeys) scope(key pgstore.IdempotencyKey) string { return key.UserID + "|" + key.Method + "|" + key.Path + "|" + key.Key } func (k *scopedKeys) ClaimIdempotency(_ context.Context, key pgstore.IdempotencyKey, _ time.Time) (*pgstore.IdempotentResponse, pgstore.ClaimToken, error) { if fp, ok := k.seen[k.scope(key)]; ok { if !bytes.Equal(fp, key.Fingerprint) { return nil, "", pgstore.ErrKeyReused } return &pgstore.IdempotentResponse{Status: http.StatusAccepted}, "", nil } k.seen[k.scope(key)] = key.Fingerprint return nil, pgstore.NewClaimToken(), nil } func (k *scopedKeys) CompleteIdempotency(context.Context, pgstore.IdempotencyKey, pgstore.ClaimToken, pgstore.IdempotentResponse, time.Time) error { return nil } func (k *scopedKeys) ReleaseIdempotency(context.Context, pgstore.IdempotencyKey, pgstore.ClaimToken) error { return nil } func exportPost(t *testing.T, h http.Handler, key, body string) *httptest.ResponseRecorder { t.Helper() return exportPostTo(t, h, "/v0/books/bk_1/exports", key, body) } func exportPostTo(t *testing.T, h http.Handler, path, key, body string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest("POST", path, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-TM-Client", "probe") r.Header.Set("Authorization", "Bearer t") r.Header.Set("Idempotency-Key", key) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w }