textmachine/platform/internal/httpapi/v0_test.go

738 lines
32 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"textmachine/platform/internal/auth"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/pricing"
"textmachine/platform/internal/runner"
"textmachine/platform/internal/runs"
)
// These tests assert the WIRE, field for field, because the wire is the contract (openapi 0.2.0) and
// a projection is exactly the kind of code that drifts from it silently: nothing in Go fails when a
// json tag is misspelled, and the client that breaks is in another repository.
type fakeLibrary struct {
lib pgstore.Library
book pgstore.Book
run *pgstore.Run
usage pgstore.Usage
chapters pgstore.ChapterPage
units pgstore.UnitPage
notes pgstore.NotePage
bank pgstore.BankPage
stream pgstore.StreamState
frames []pgstore.Frame
// onSecondRead mutates the state the SECOND time a stream asks for it, which is how a test says
// "this changed while the client was connected".
onSecondRead func(*pgstore.StreamState)
streamReads int
// limit records the page size the handler passed DOWN, so a test can observe the clamp rather
// than only the status a refusal would have changed.
limit int
err error
}
func (f *fakeLibrary) ListBooks(context.Context, string, int, string) (pgstore.Library, error) {
return f.lib, f.err
}
func (f *fakeLibrary) GetBook(context.Context, string, string) (pgstore.Book, *pgstore.Run, error) {
return f.book, f.run, f.err
}
func (f *fakeLibrary) ReadUsage(context.Context, string) (pgstore.Usage, error) {
return f.usage, f.err
}
func (f *fakeLibrary) ListChapters(_ context.Context, _, _ string, limit int, _ string) (pgstore.ChapterPage, error) {
f.limit = limit
return f.chapters, f.err
}
func (f *fakeLibrary) ListUnits(context.Context, string, string, string, int, string) (pgstore.UnitPage, error) {
return f.units, f.err
}
func (f *fakeLibrary) ListNotes(context.Context, string, string, int, string, *int64) (pgstore.NotePage, error) {
return f.notes, f.err
}
func (f *fakeLibrary) ListBank(context.Context, string, string, int, string, *int64) (pgstore.BankPage, error) {
return f.bank, f.err
}
func (f *fakeLibrary) ReadStream(context.Context, string, string) (pgstore.StreamState, error) {
f.streamReads++
if f.streamReads == 2 && f.onSecondRead != nil {
f.onSecondRead(&f.stream)
}
return f.stream, f.err
}
// The `after` is honoured rather than ignored: a fake that answered the same frames on every poll
// would hide exactly the bug a stream test is looking for — a watermark that does not move.
func (f *fakeLibrary) ReadFrames(_ context.Context, _ string, after int64, limit int) ([]pgstore.Frame, error) {
var out []pgstore.Frame
for _, fr := range f.frames {
if fr.Position > after && len(out) < limit {
out = append(out, fr)
}
}
return out, f.err
}
type fakeRuns struct {
bounds runs.Options
run pgstore.Run
err error
got runs.StartRequest
// stopped and resumed record which run each control handle was called for, so a test can assert
// that the path parameter reaches the service rather than only that the status code is right.
stopped string
resumed string
}
func (f *fakeRuns) Bounds(context.Context, string, string) (runs.Options, error) {
return f.bounds, f.err
}
func (f *fakeRuns) Start(_ context.Context, in runs.StartRequest) (pgstore.Run, error) {
f.got = in
return f.run, f.err
}
func (f *fakeRuns) Stop(_ context.Context, _, runID string) (pgstore.Run, error) {
f.stopped = runID
return f.run, f.err
}
func (f *fakeRuns) Resume(_ context.Context, _, runID string) (pgstore.Run, error) {
f.resumed = runID
return f.run, f.err
}
func v0Server(t *testing.T, lib Library, rn Runs) http.Handler {
t.Helper()
return v0ServerWith(t, Deps{Library: lib, Runs: rn})
}
// v0ServerWith builds the real handler chain with whatever this test wants mounted on it. Every
// wire test goes through New — not through a handler in isolation — because the guard, the body cap
// and the security headers are part of the surface being asserted.
func v0ServerWith(t *testing.T, d Deps) http.Handler {
t.Helper()
d.Log = slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
if d.Auth == nil {
// Filled in rather than forced, so a test about what happens to an ALREADY authenticated
// caller can bring its own store — the guard's verdict and the session's later life are two
// different questions (PD-379).
d.Auth = &auth.Authenticator{
Sessions: liveSessions{}, IdleTTL: time.Hour,
Deny: ProblemHandler(CodeUnauthenticated),
}
}
h, err := New(d)
if err != nil {
t.Fatal(err)
}
return h
}
func call(t *testing.T, h http.Handler, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body == "" {
r = httptest.NewRequest(method, path, nil)
} else {
r = httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
}
r.Header.Set("Authorization", "Bearer token")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func decode(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("body %q: %v", w.Body.String(), err)
}
return m
}
func TestTheLibraryResponseCarriesEveryRequiredField(t *testing.T) {
lib := &fakeLibrary{lib: pgstore.Library{
Revision: 1841,
Books: []pgstore.Book{{
ID: "bk_7c1", Revision: 1840, Title: "蛊真人", SourceLang: "zh", TargetLang: "ru",
Status: "translating", StructureVersion: 3, ChapterCount: 500, ChaptersDone: 7,
CharacterCount: 23_000_000, NoteCount: 3, AddedAt: time.Unix(0, 0).UTC(),
}},
}}
w := call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books", "")
if w.Code != http.StatusOK {
t.Fatalf("status %d: %s", w.Code, w.Body)
}
got := decode(t, w)
for _, k := range []string{"revision", "next_cursor", "books"} {
if _, ok := got[k]; !ok {
t.Errorf("Library is missing the required field %q", k)
}
}
// next_cursor is present on EVERY list response, and null on the last page: introducing it later
// would silently cut the tail off a client that does not read the field.
if got["next_cursor"] != nil {
t.Errorf("next_cursor on the last page is %v, want null", got["next_cursor"])
}
books, _ := got["books"].([]any)
if len(books) != 1 {
t.Fatalf("books: %v", got["books"])
}
book, _ := books[0].(map[string]any)
for _, k := range []string{"id", "revision", "title", "source_lang", "target_lang", "status",
"reject_reason", "structure_version", "chapter_count", "chapters_done", "character_count",
"added_at", "note_count"} {
if _, ok := book[k]; !ok {
t.Errorf("Book is missing the required field %q", k)
}
}
// ⚠ The bar is the RUN's since 0.3.0, and the book's own figure is chapters_done. A `progress`
// on the library row would be the pipeline's phases back on the wire.
if _, ok := book["progress"]; ok {
t.Error("the library row carries a progress bar; the bar belongs to the run")
}
if book["chapters_done"] != float64(7) || book["structure_version"] != float64(3) {
t.Errorf("book row: %v", book)
}
// Not a wave name, not an engine word anywhere on THIS wire — the library row, which carries no
// bar by construction. (`Progress.stage` on the RUN legally says drafting/editing since 0.6.0;
// this list guards the surface that must stay free of all of it.)
for _, leak := range []string{"draft", "edit", "wave", "stage", "mined", "ruby", "genre"} {
if bytes.Contains(w.Body.Bytes(), []byte(leak)) {
t.Errorf("the pipeline's vocabulary reached the wire: %q in %s", leak, w.Body)
}
}
// Money never crosses this boundary in any form (D39.84): not a sum, not a rate, not a ceiling
// in dollars.
if bytes.Contains(w.Body.Bytes(), []byte("usd")) || bytes.Contains(w.Body.Bytes(), []byte("micro")) {
t.Errorf("a money field reached the wire: %s", w.Body)
}
}
// Absent, not zero: the screen must render without an estimate rather than show "0 s left".
func TestAnAbsentEtaTravelsAsNullAndNotAsZero(t *testing.T) {
lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1"}, run: &pgstore.Run{ID: "run_1", Status: "translating"}}
got := decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books/bk_1", ""))
run, _ := got["run"].(map[string]any)
progress, _ := run["progress"].(map[string]any)
if v, ok := progress["eta_seconds"]; !ok || v != nil {
t.Errorf("eta_seconds = %v (present: %v), want an explicit null", v, ok)
}
}
func TestTheBookCardCarriesItsRunOrAnExplicitNull(t *testing.T) {
lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1", Revision: 12}}
w := call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books/bk_1", "")
got := decode(t, w)
if v, ok := got["run"]; !ok || v != nil {
t.Errorf("run = %v (present: %v), want an explicit null for a book that never ran", v, ok)
}
if got["revision"] != float64(12) {
t.Errorf("a card without a run carries revision %v, want the book's own", got["revision"])
}
finished := time.Unix(0, 0).UTC()
lib.book.Revision = 1900
lib.run = &pgstore.Run{ID: "run_1", BookID: "bk_1", Revision: 12, Status: "paused", VerifyBank: true,
CeilingChapters: 100, Progress: pgstore.Progress{Done: 40, Total: 100},
PausedReason: "credit_exhausted", StartedAt: finished, FinishedAt: &finished}
got = decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books/bk_1", ""))
run, _ := got["run"].(map[string]any)
for _, k := range []string{"id", "book_id", "revision", "status", "stop_for_signing",
"stop_requested", "ceiling_chapters", "progress", "paused_reason", "failure_reason",
"started_at", "finished_at"} {
if _, ok := run[k]; !ok {
t.Errorf("Run is missing the required field %q", k)
}
}
if run["paused_reason"] != "credit_exhausted" || run["status"] != "paused" {
t.Errorf("a paused run: %v", run)
}
// `stop_requested` is a TOTAL function: no stop is `false`, never an absent field. It answers
// what no status can — a stop asked for during translation can meet the run reaching the bank
// signature, and `awaiting_bank` then offers to continue on a click that meant "stop".
if run["stop_requested"] != false {
t.Errorf("a run nobody stopped reports stop_requested=%v", run["stop_requested"])
}
lib.run.StopRequested = true
asked := decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books/bk_1", ""))["run"].(map[string]any)
if asked["stop_requested"] != true {
t.Errorf("a run the user asked to stop reports stop_requested=%v", asked["stop_requested"])
}
// The BOOK's counter, not the run's: one counter per book (contract §Revision).
if got["revision"] != float64(1900) {
t.Errorf("a card with a run carries revision %v", got["revision"])
}
}
// paused_reason is required and null in every state but `paused`: a client that had to tell "absent"
// from "null" would carry a branch the contract does not describe.
func TestPausedReasonIsNullRatherThanAbsentOnALiveRun(t *testing.T) {
lib := &fakeLibrary{book: pgstore.Book{ID: "bk_1"}, run: &pgstore.Run{ID: "run_1", Status: "translating"}}
got := decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books/bk_1", ""))
run, _ := got["run"].(map[string]any)
v, ok := run["paused_reason"]
if !ok || v != nil {
t.Errorf("paused_reason = %v (present: %v)", v, ok)
}
if v, ok := run["finished_at"]; !ok || v != nil {
t.Errorf("finished_at = %v (present: %v)", v, ok)
}
}
func TestRunOptionsCarriesTheThreeBoundsAndNoArithmeticForTheClient(t *testing.T) {
rn := &fakeRuns{bounds: runs.Options{Ceiling: pricing.Bounds{Min: 1, Max: 166, Default: 166}}}
got := decode(t, call(t, v0Server(t, &fakeLibrary{}, rn), "GET", "/v0/books/bk_1/run-options", ""))
ceiling, _ := got["ceiling"].(map[string]any)
if ceiling["min_chapters"] != float64(1) || ceiling["max_chapters"] != float64(166) ||
ceiling["default_chapters"] != float64(166) {
t.Fatalf("ceiling bounds: %v", ceiling)
}
// The chapters-to-money conversion lives on the platform and is not exposed in ANY form.
if len(ceiling) != 3 {
t.Errorf("CeilingBounds carries %d fields: %v", len(ceiling), ceiling)
}
}
// max_chapters of 0 is legal and means "no run can start at all": the client shows the exhausted
// state instead of a scale.
func TestAnExhaustedAccountGetsAZeroMaximum(t *testing.T) {
rn := &fakeRuns{bounds: runs.Options{Ceiling: pricing.Bounds{Min: 1, Max: 0, Default: 0}}}
got := decode(t, call(t, v0Server(t, &fakeLibrary{}, rn), "GET", "/v0/books/bk_1/run-options", ""))
ceiling, _ := got["ceiling"].(map[string]any)
if ceiling["max_chapters"] != float64(0) || ceiling["default_chapters"] != float64(0) {
t.Errorf("an exhausted account: %v", ceiling)
}
}
// Both fields are REQUIRED, and the reason ceiling_chapters is required is money: a run started
// without a declared ceiling spends past the limit the user is entitled to set beforehand.
func TestStartingARunRequiresBothFieldsAndAnsweredAccepted(t *testing.T) {
rn := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating", CeilingChapters: 100,
VerifyBank: true, StartedAt: time.Unix(0, 0).UTC()}}
h := v0Server(t, &fakeLibrary{}, rn)
for _, body := range []string{`{}`, `{"stop_for_signing":true}`, `{"ceiling_chapters":10}`, `not json`} {
if w := call(t, h, "POST", "/v0/books/bk_1/runs", body); w.Code != http.StatusBadRequest {
t.Errorf("body %s answered %d, want 400", body, w.Code)
}
}
w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":true,"ceiling_chapters":100}`)
if w.Code != http.StatusAccepted {
t.Fatalf("status %d: %s", w.Code, w.Body)
}
if rn.got.CeilingChapters != 100 || !rn.got.VerifyBank || rn.got.BookID != "bk_1" || rn.got.UserID != "u1" {
t.Errorf("the request reached the service as %+v", rn.got)
}
got := decode(t, w)
if got["id"] != "run_1" || got["ceiling_chapters"] != float64(100) {
t.Errorf("the accepted run: %v", got)
}
}
// The contract's status codes, and the one deliberate departure from them, named out loud.
func TestDomainFailuresBecomeTheContractsStatusCodes(t *testing.T) {
cases := []struct {
name string
err error
want int
}{
{"a book that is not this account's", pgstore.ErrNoBook, http.StatusNotFound},
{"a book already being translated", pgstore.ErrRunInFlight, http.StatusConflict},
// 409 and not 400: the request was legal when run-options was read, and a hold taken for
// another book between that read and this call is what moved the bounds.
{"a ceiling that no longer fits", runs.ErrCeilingOutOfBounds, http.StatusConflict},
{"an account that cannot pay", pgstore.ErrInsufficientCredit, http.StatusConflict},
// ⚠ 503 is NOT among the statuses the contract enumerates for this operation. It is used
// because every alternative lies: the request is valid, the object exists, and the state is
// not in conflict — the DEPLOYMENT cannot tell the engine its ceiling (row 145).
{"a deployment that cannot pass a ceiling", runner.ErrCeilingNotWired, http.StatusServiceUnavailable},
}
for _, tc := range cases {
h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: tc.err})
w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"ceiling_chapters":10}`)
if w.Code != tc.want {
t.Errorf("%s: %d, want %d", tc.name, w.Code, tc.want)
}
if ct := w.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("%s: content type %q", tc.name, ct)
}
// Neither title nor detail may carry engine or database text: what a client puts on a screen
// is whatever this says.
if bytes.Contains(w.Body.Bytes(), []byte("pgstore")) || bytes.Contains(w.Body.Bytes(), []byte("runner:")) {
t.Errorf("%s: internals reached the wire: %s", tc.name, w.Body)
}
}
}
func TestUsageCarriesAShareAndItsState(t *testing.T) {
for _, tc := range []struct {
percent int
state string
}{{100, "ok"}, {11, "ok"}, {10, "low"}, {1, "low"}, {0, "low"}} {
lib := &fakeLibrary{usage: pgstore.Usage{RemainingPercent: tc.percent, Spendable: true}}
got := decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/usage", ""))
if got["state"] != tc.state || got["remaining_percent"] != float64(tc.percent) {
t.Errorf("%d%%: %v, want state %q", tc.percent, got, tc.state)
}
// ⚠ `halt_reason` and not `paused_reason`: the ACCOUNT's own vocabulary, typed apart from the
// run's since 0.3.0 — a run stops for reasons that say nothing about the account, and lighting
// an account-wide state from one would tell a user with money that they have none.
if v, ok := got["halt_reason"]; !ok || v != nil {
t.Errorf("%d%%: halt_reason = %v (present %v)", tc.percent, v, ok)
}
if _, ok := got["paused_reason"]; ok {
t.Error("usage carries the RUN's vocabulary")
}
// No window, no resets_at, no sums (contract §Usage).
for _, forbidden := range []string{"resets_at", "windows", "period", "usd", "amount"} {
if _, ok := got[forbidden]; ok {
t.Errorf("usage carries %q", forbidden)
}
}
}
}
// EVERY contract route sits behind the session guard, and an anonymous caller meets 401 before 404:
// the shape of the surface is not public information.
//
// Driven from the route table rather than a literal list, which is the point: the enumeration was a
// literal and stopped naming half the surface the moment routes were added to it.
func TestEveryContractRouteRequiresASession(t *testing.T) {
deps := Deps{
Log: slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)),
Auth: &auth.Authenticator{
Sessions: deadSessions{}, IdleTTL: time.Hour,
Deny: ProblemHandler(CodeUnauthenticated),
},
Library: &fakeLibrary{}, Runs: &fakeRuns{}, Intake: &fakeIntake{},
Upload: UploadLimits{MaxBytes: 1 << 20, Deadline: time.Minute},
}
h, err := New(deps)
if err != nil {
t.Fatal(err)
}
// ⚠ THE FLOOR MOVED 15 → 14, and it is written down rather than quietly lowered: this gate did
// its job — it caught the removal of `POST /books/{bookId}/bank/decisions`, which went with the
// per-term signing model D39.144 abolished (owner's word, 22.08). A floor lowered to match a
// deliberate, ratified shrink is not a gate fitted to green; a floor lowered without one is.
if len(contractSurface) < 14 {
t.Fatalf("the route table lists %d routes: the surface cannot have shrunk", len(contractSurface))
}
for _, r := range contractSurface {
// Path parameters filled in with anything: the guard runs before the handler looks at them.
path := APIPrefix + strings.NewReplacer("{bookId}", "bk_1", "{chapterId}", "ch_1",
"{runId}", "run_1").Replace(r.path)
w := call(t, h, r.method, path, "{}")
if w.Code != http.StatusUnauthorized {
t.Errorf("%s %s answered %d to a caller with no session", r.method, path, w.Code)
}
}
}
// The access log names the ROUTE and never the path: a raw path carries book and run ids, which
// identify a user's library in an operator's index.
func TestTheAccessLogNamesTheRouteOfAContractCall(t *testing.T) {
var logs bytes.Buffer
h, err := New(Deps{
Log: slog.New(slog.NewJSONHandler(&logs, nil)),
Auth: &auth.Authenticator{
Sessions: liveSessions{}, IdleTTL: time.Hour,
Deny: ProblemHandler(CodeUnauthenticated),
},
Library: &fakeLibrary{book: pgstore.Book{ID: "bk_secret"}}, Runs: &fakeRuns{},
})
if err != nil {
t.Fatal(err)
}
call(t, h, "GET", "/v0/books/bk_secret", "")
if !strings.Contains(logs.String(), `"route":"GET /v0/books/{bookId}"`) {
t.Errorf("the access log does not name the route: %s", logs.String())
}
if strings.Contains(logs.String(), "bk_secret") {
t.Errorf("a book id reached the log: %s", logs.String())
}
}
// A cursor the server cannot use is its own duty to reject (canon §NextCursor) — and `limit` is the
// opposite case, written out because the two look alike and the contract treats them as opposites:
// "a deployment that validates this parameter against the schema has to exempt it from rejection"
// (§Limit). A page size is a HINT; a cursor is a position, and a wrong one would answer somebody
// else's window.
//
// Mutation caught: re-introducing a 400 on a limit of any shape.
func TestABadCursorIsRefusedAndABadLimitIsNot(t *testing.T) {
h := v0Server(t, &fakeLibrary{err: pgstore.ErrBadCursor}, &fakeRuns{})
w := call(t, h, "GET", "/v0/books?cursor=nonsense", "")
if w.Code != http.StatusBadRequest {
t.Errorf("a stale cursor answered %d", w.Code)
}
if got := decode(t, w); got["code"] != "invalid_request" {
t.Errorf("a stale cursor: %v", got)
}
ok := v0Server(t, &fakeLibrary{}, &fakeRuns{})
for _, q := range []string{"limit=0", "limit=-1", "limit=abc", "limit=100000"} {
if w := call(t, ok, "GET", "/v0/books?"+q, ""); w.Code != http.StatusOK {
t.Errorf("%s answered %d, want the page it could serve", q, w.Code)
}
}
}
// Without a database there is no library to serve, and the prefix stays a guarded 404 rather than a
// half-mounted surface.
func TestWithoutAReadModelTheVersionedPrefixIsAGuarded404(t *testing.T) {
h, err := New(Deps{
Log: slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)),
Auth: &auth.Authenticator{
Sessions: liveSessions{}, IdleTTL: time.Hour,
Deny: ProblemHandler(CodeUnauthenticated),
},
})
if err != nil {
t.Fatal(err)
}
if w := call(t, h, "GET", "/v0/books", ""); w.Code != http.StatusNotFound {
t.Errorf("status %d, want 404", w.Code)
}
}
// A ceiling below the schema's own minimum is a MALFORMED request, and 409 is defined to mean the
// bounds moved between the run-options read and this call — so a client answered 409 re-reads
// run-options and retries a request that can never succeed.
func TestACeilingBelowTheSchemaMinimumIsARejectedRequestAndNotAMovedBound(t *testing.T) {
rn := &fakeRuns{run: pgstore.Run{ID: "run_1"}}
h := v0Server(t, &fakeLibrary{}, rn)
for _, body := range []string{`{"stop_for_signing":false,"ceiling_chapters":0}`, `{"stop_for_signing":false,"ceiling_chapters":-3}`} {
w := call(t, h, "POST", "/v0/books/bk_1/runs", body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s answered %d, want 400", body, w.Code)
}
}
if rn.got.CeilingChapters != 0 {
t.Errorf("an illegal ceiling reached the service: %+v", rn.got)
}
}
// RunRequest does not close the object, and inside 0.x a minor bump is where optional fields appear:
// a server that refused the whole request would break a client generated against a later 0.x for a
// field it was free to ignore.
func TestAnUnknownRequestPropertyIsIgnoredRatherThanRefused(t *testing.T) {
rn := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating", CeilingChapters: 10}}
w := call(t, v0Server(t, &fakeLibrary{}, rn), "POST", "/v0/books/bk_1/runs",
`{"stop_for_signing":true,"ceiling_chapters":10,"a_field_from_a_later_minor":"x"}`)
if w.Code != http.StatusAccepted {
t.Fatalf("status %d: %s", w.Code, w.Body)
}
if rn.got.CeilingChapters != 10 || !rn.got.VerifyBank {
t.Errorf("the known fields did not survive: %+v", rn.got)
}
}
// ONE counter per book. Reading the card's revision off the RUN row made it lag: a unit_done bumps
// the book and its chapter and not the run, so a client that had applied stream frame id=2 got 0
// back and — obeying the contract — dropped the read.
// ONE counter per book on the wire. ⚠ The RULE moved in P5 and this test moved with it: the store now
// answers a run's revision from its book on every path that hands one out — the card here and the
// stop/resume handles, which have no book to override from — so the pin on the rule itself lives in
// `pgstore.TestEveryRunTheStoreHandsOutCarriesItsBooksRevision` and `runs.TestEveryRunCarryingAnswerUsesTheBooksRevision`.
// What THIS layer owes is that it projects the number it was given and invents nothing, which is
// what a fake store can prove and the rule itself no longer is.
func TestTheCardProjectsTheRevisionTheStoreGaveAndInventsNone(t *testing.T) {
// The two numbers are DIFFERENT on purpose: equal ones cannot tell "projected what it was given"
// from "overrode it with the book's", which is exactly the discriminating power an earlier
// revision of this test lost when the fixture was levelled (found by acceptance).
lib := &fakeLibrary{
book: pgstore.Book{ID: "bk_1", Revision: 41},
run: &pgstore.Run{ID: "run_1", Status: "translating", Revision: 12},
}
got := decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/books/bk_1", ""))
if got["revision"] != float64(41) {
t.Errorf("BookDetail.revision = %v, want the book's 41", got["revision"])
}
run, _ := got["run"].(map[string]any)
if run["revision"] != float64(12) {
t.Errorf("Run.revision = %v, want the 12 the store handed over: this layer projects, it does not decide",
run["revision"])
}
}
// "Exhausted" is a fact about the balance, not about the rounded share: $9 left of a $1000 grant
// floors to 0% while a run can still be started, and the account screen must not contradict the run
// dialog.
func TestASmallRemainderIsLowAndNotExhausted(t *testing.T) {
lib := &fakeLibrary{usage: pgstore.Usage{RemainingPercent: 0, Spendable: true}}
got := decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/usage", ""))
if got["state"] != "low" {
t.Errorf("state %v with money still spendable, want low", got["state"])
}
lib.usage = pgstore.Usage{RemainingPercent: 0, Spendable: false}
got = decode(t, call(t, v0Server(t, lib, &fakeRuns{}), "GET", "/v0/usage", ""))
if got["state"] != "exhausted" {
t.Errorf("state %v with nothing left, want exhausted", got["state"])
}
}
// An instance with a read model and no runner is a read REPLICA, not a broken one: the library is
// not run machinery. It used to answer 404 to the library it was holding, while its own boot line
// said it was serving one.
func TestAnInstanceWithoutARunnerStillServesTheLibrary(t *testing.T) {
h, err := New(Deps{
Log: slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil)),
Auth: &auth.Authenticator{
Sessions: liveSessions{}, IdleTTL: time.Hour,
Deny: ProblemHandler(CodeUnauthenticated),
},
Library: &fakeLibrary{lib: pgstore.Library{Books: []pgstore.Book{{ID: "bk_1"}}}},
})
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/v0/books", "/v0/books/bk_1", "/v0/usage"} {
if w := call(t, h, "GET", path, ""); w.Code != http.StatusOK {
t.Errorf("%s answered %d on a read-only instance, want 200", path, w.Code)
}
}
// The run surface stays a guarded 404 rather than a handler that would fail on every call.
if w := call(t, h, "GET", "/v0/books/bk_1/run-options", ""); w.Code != http.StatusNotFound {
t.Errorf("run-options answered %d with no run lifecycle, want 404", w.Code)
}
if w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"ceiling_chapters":1}`); w.Code != http.StatusNotFound {
t.Errorf("starting a run answered %d with no run lifecycle, want 404", w.Code)
}
}
// The CSRF layer covers the CONTRACT surface, not just /auth/. Without it a cross-site page could
// start a paid run with the browser's ambient cookie — the guard that pins the session check does
// not see this, because a session is present in both cases.
func TestACrossSiteRequestCannotStartARun(t *testing.T) {
rn := &fakeRuns{run: pgstore.Run{ID: "run_1"}}
h := v0Server(t, &fakeLibrary{}, rn)
r := httptest.NewRequest("POST", "/v0/books/bk_1/runs",
strings.NewReader(`{"stop_for_signing":false,"ceiling_chapters":10}`))
r.Header.Set("Content-Type", "application/json")
r.AddCookie(&http.Cookie{Name: "__Host-tm_session", Value: "token"})
r.Header.Set("Sec-Fetch-Site", "cross-site")
r.Header.Set("Origin", "https://evil.example")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("a cross-site run start answered %d, want 403", w.Code)
}
if rn.got.CeilingChapters != 0 {
t.Errorf("the cross-site request reached the service: %+v", rn.got)
}
// A same-origin browser request without the client header is refused for the same reason.
r = httptest.NewRequest("POST", "/v0/books/bk_1/runs",
strings.NewReader(`{"stop_for_signing":false,"ceiling_chapters":10}`))
r.Header.Set("Content-Type", "application/json")
r.AddCookie(&http.Cookie{Name: "__Host-tm_session", Value: "token"})
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code == http.StatusAccepted {
t.Error("a cookie-borne POST without X-TM-Client started a run")
}
}
// A route mounted without its guard must fail CLOSED, and loudly: answering 401 would hide the
// wiring defect, and serving an anonymous caller as user "" would give them somebody's library.
func TestAHandlerWithNoPrincipalFailsClosed(t *testing.T) {
var logs bytes.Buffer
h := &v0{lib: &fakeLibrary{}, runs: &fakeRuns{}, log: slog.New(slog.NewJSONHandler(&logs, nil))}
w := httptest.NewRecorder()
h.listBooks(w, httptest.NewRequest("GET", "/v0/books", nil))
if w.Code != http.StatusInternalServerError {
t.Errorf("a handler with no principal answered %d, want 500", w.Code)
}
}
// Neither title nor detail may carry engine or database text (contract §Problem): what a client puts
// on a screen is whatever this says.
func TestAnInternalErrorNeverPutsItsTextOnTheWire(t *testing.T) {
secret := "pgstore: relation \"books\" does not exist at character 42"
h := v0Server(t, &fakeLibrary{err: errors.New(secret)}, &fakeRuns{})
w := call(t, h, "GET", "/v0/books", "")
if w.Code != http.StatusInternalServerError {
t.Fatalf("status %d", w.Code)
}
if strings.Contains(w.Body.String(), "pgstore") || strings.Contains(w.Body.String(), "relation") {
t.Errorf("the internal message reached the wire: %s", w.Body)
}
}
// The wire's PausedReason vocabulary is the CONTRACT's, not the platform's. The platform now tells a
// halt on its own ceiling from one on the engine's daily limit (pgstore.CeilingPause), and the
// contract has a word for the first and none for the second — so the second travels as null, which
// is the state the contract itself asks a client to render for a reason it does not know.
//
// Found by a live probe, not by reading: on the stand a run stopped by the engine's daily ceiling put
// `daily_ceiling` on the wire, which a client generated against the spec's `enum: [credit_exhausted]`
// would refuse. Register row PD-199 carries the question to the contract's owner.
//
// Mutation caught: projecting r.PausedReason directly.
func TestOnlyTheContractsOwnPausedReasonReachesTheWire(t *testing.T) {
for reason, want := range map[string]*string{
pgstore.PausedCreditExhausted: ptr(pgstore.PausedCreditExhausted),
pgstore.PausedDailyCeiling: nil,
"": nil,
"something_later": nil,
} {
got := projectRun(pgstore.Run{PausedReason: reason}).PausedReason
switch {
case want == nil && got != nil:
t.Errorf("paused_reason %q reached the wire as %q; the contract enumerates one value", reason, *got)
case want != nil && (got == nil || *got != *want):
t.Errorf("paused_reason %q projected as %v, want %q", reason, got, *want)
}
}
}
func ptr[T any](v T) *T { return &v }
// The 0.7.0 re-pass purchase on the wire (canon §RunRequest): `re_pass` without a chapter limit
// reaches the service as the re-pass; the two members together are 400 (mutually exclusive); and
// «nothing to re-pass» answers 409 with its own cause word.
func TestARePassRequestIsItsOwnPurchaseShape(t *testing.T) {
f := &fakeRuns{run: pgstore.Run{ID: "run_1", Status: "translating"}}
h := v0Server(t, &fakeLibrary{}, f)
if w := call(t, h, "POST", "/v0/books/bk_1/runs",
`{"stop_for_signing":false,"re_pass":true}`); w.Code != http.StatusAccepted {
t.Fatalf("a re-pass request answered %d: %s", w.Code, w.Body)
}
if !f.got.RePass || f.got.CeilingChapters != 0 {
t.Fatalf("the service saw %+v, want RePass with no chapters", f.got)
}
if w := call(t, h, "POST", "/v0/books/bk_1/runs",
`{"stop_for_signing":false,"re_pass":true,"ceiling_chapters":3}`); w.Code != http.StatusBadRequest {
t.Fatalf("both purchases at once answered %d, want 400", w.Code)
}
f.err = runs.ErrRePassUnavailable
w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"re_pass":true}`)
if w.Code != http.StatusConflict {
t.Fatalf("nothing-to-re-pass answered %d, want 409", w.Code)
}
if body := w.Body.String(); !strings.Contains(body, `"re_pass_unavailable"`) {
t.Fatalf("the 409 does not carry its own cause: %s", body)
}
}