textmachine/platform/internal/httpapi/exports_test.go

433 lines
19 KiB
Go

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
}