textmachine/platform/internal/httpapi/intake_test.go

402 lines
15 KiB
Go

package httpapi
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"textmachine/platform/internal/books"
"textmachine/platform/internal/pgstore"
)
// The upload is the only streaming route on this surface, and the only one whose body limit is not
// the default. Both facts are asserted here on the REAL handler chain — through New, with the guard,
// the CSRF layer and the per-route cap in place — because a limit tested on a bare handler is a
// limit tested somewhere other than where it applies (the shape of PD-46).
type fakeIntake struct {
book pgstore.Book
err error
got books.Intake
// read is what the service actually pulled out of the part, which is how a test tells "the file
// was streamed" from "the handler read it into memory and handed over a copy"; readErr is what
// the read ENDED with, which is how the real intake decides to undo an upload.
read int64
readErr error
// accepted counts the calls, which is how a test tells a replay from a second upload.
accepted int
}
func (f *fakeIntake) Accept(_ context.Context, in books.Intake) (pgstore.Book, error) {
f.accepted++
f.got = in
if in.File != nil {
n, err := io.Copy(io.Discard, in.File)
f.read, f.readErr = n, err
if err != nil {
return pgstore.Book{}, err
}
}
if f.err != nil {
return pgstore.Book{}, f.err
}
return f.book, nil
}
// form builds a multipart body. The order of the parts is the caller's on purpose: the file MUST
// come last, and the test that says so builds it the other way round.
func form(t *testing.T, file string, size int, fieldsFirst bool) (string, io.Reader) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
writeFields := func() {
// `title` is REQUIRED by the form and the empty string is its declared "name it from the
// file" (canon §BookIntake); `genre` left the form in 0.3.0.
for k, v := range map[string]string{"title": "", "source_lang": "zh", "target_lang": "ru"} {
if err := w.WriteField(k, v); err != nil {
t.Fatal(err)
}
}
}
if fieldsFirst {
writeFields()
}
part, err := w.CreateFormFile("file", file)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(bytes.Repeat([]byte("a"), size)); err != nil {
t.Fatal(err)
}
if !fieldsFirst {
writeFields()
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return w.FormDataContentType(), &buf
}
func upload(t *testing.T, h http.Handler, contentType string, body io.Reader) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", "/v0/books", body)
r.Header.Set("Content-Type", contentType)
r.Header.Set("Authorization", "Bearer token")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
func TestAnAcceptedUploadAnswersWithTheBookTheContractDescribes(t *testing.T) {
in := &fakeIntake{book: pgstore.Book{
ID: "bk_7c1", Title: "蛊真人", SourceLang: "zh", TargetLang: "ru",
Status: "parsing", AddedAt: time.Unix(0, 0).UTC(),
}}
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in})
ct, body := form(t, "蛊真人.txt", 1024, true)
w := upload(t, h, ct, body)
if w.Code != http.StatusCreated {
t.Fatalf("status %d: %s", w.Code, w.Body)
}
got := decode(t, w)
// Every field the contract marks required on Book, present on the intake's own response.
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 := got[k]; !ok {
t.Errorf("the created book is missing the required field %q", k)
}
}
if got["status"] != "parsing" {
t.Errorf("status %v, want the state the book is actually in", got["status"])
}
if in.got.SourceLang != "zh" || in.got.TargetLang != "ru" {
t.Errorf("the form did not reach the service: %+v", in.got)
}
if in.got.Filename != "蛊真人.txt" {
t.Errorf("filename %q did not reach the service", in.got.Filename)
}
if in.read != 1024 {
t.Errorf("the service was handed %d bytes of the file, want 1024", in.read)
}
}
// PD-72, and the reason the register kept the row open until this route landed: the per-route cap is
// only OBSERVABLE once a route asks for more than the default, and the test has to arrive with it.
func TestAnUploadLargerThanTheRouteAllowsIsRefusedAsTooLarge(t *testing.T) {
in := &fakeIntake{}
h := v0ServerWith(t, Deps{
Library: &fakeLibrary{}, Intake: in,
Upload: UploadLimits{MaxBytes: 4096, Deadline: time.Minute},
})
ct, body := form(t, "book.txt", 16*1024, true)
w := upload(t, h, ct, body)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status %d, want 413: %s", w.Code, w.Body)
}
if ct := w.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("content-type %q, want application/problem+json", ct)
}
problem := decode(t, w)
if problem["status"] != float64(http.StatusRequestEntityTooLarge) || problem["title"] == "" {
t.Errorf("problem body %v", problem)
}
// And a body UNDER the limit goes through the same route untouched: a cap that refuses
// everything is not a cap.
ct2, body2 := form(t, "book.txt", 1024, true)
if w := upload(t, h, ct2, body2); w.Code != http.StatusCreated {
t.Fatalf("a 1 KiB upload under a 4 KiB cap: %d %s", w.Code, w.Body)
}
}
// The other half of the same property (PD-35): raising the limit for ONE route must not raise it for
// the rest of the surface.
func TestTheUploadLimitBelongsToTheUploadRouteAlone(t *testing.T) {
h := v0ServerWith(t, Deps{
Library: &fakeLibrary{}, Runs: &fakeRuns{}, Intake: &fakeIntake{},
Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute},
})
big := strings.Repeat("a", DefaultMaxBody+1)
w := call(t, h, "POST", "/v0/books/bk_1/runs", `{"stop_for_signing":false,"chapters":1,"pad":"`+big+`"}`)
if w.Code == http.StatusAccepted {
t.Fatal("a run request larger than the default body cap was accepted: the upload's limit leaked onto every route")
}
}
// A deployment with nowhere to put a file mounts no upload route, exactly like every other contract
// route it cannot serve — and an anonymous caller still meets 401 before 404.
func TestWithoutAnIntakeTheUploadRouteIsAGuarded404(t *testing.T) {
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}})
ct, body := form(t, "book.txt", 16, true)
if w := upload(t, h, ct, body); w.Code != http.StatusNotFound {
t.Fatalf("status %d, want 404 where no intake is configured", w.Code)
}
r := httptest.NewRequest("POST", "/v0/books", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("an anonymous caller got %d, want 401 before anything else", w.Code)
}
}
// A part after the file is REFUSED and never ignored (canon §createBook — 0.2.3 legalised losing an
// optional one silently), and the refusal UNDOES the upload: answering 400 while the book stayed in
// the library would leave the user with an error and a book.
//
// Mutation caught: reporting the trailing part without failing the read; dropping the errors[] item.
func TestAPartAfterTheFileIsRefusedAndTheBookIsNotKept(t *testing.T) {
in := &fakeIntake{}
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in})
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for _, kv := range [][2]string{{"title", ""}, {"source_lang", "zh"}, {"target_lang", "ru"}} {
if err := w.WriteField(kv[0], kv[1]); err != nil {
t.Fatal(err)
}
}
part, err := w.CreateFormFile("file", "book.txt")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(bytes.Repeat([]byte("a"), 64)); err != nil {
t.Fatal(err)
}
if err := w.WriteField("late", "1"); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
got := upload(t, h, w.FormDataContentType(), &buf)
if got.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400: %s", got.Code, got.Body)
}
body := decode(t, got)
items, _ := body["errors"].([]any)
if len(items) != 1 {
t.Fatalf("errors: %v", body["errors"])
}
item, _ := items[0].(map[string]any)
if item["pointer"] != "/late" || item["code"] != "missing_or_late" {
t.Errorf("the offending part is not named: %v", item)
}
// The intake saw a FAILED read, which is what makes it remove the row and the directory it had
// already written.
if in.readErr == nil {
t.Error("the upload was accepted whole; the book would stay in the library after a 400")
}
}
// The wire rule a streaming reader forces: the languages have to be known before the file part is
// handed over, so a form that sends them afterwards is refused rather than half-processed.
func TestAFormWhoseFileComesBeforeItsFieldsIsRefused(t *testing.T) {
in := &fakeIntake{}
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in})
ct, body := form(t, "book.txt", 64, false)
w := upload(t, h, ct, body)
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400: %s", w.Code, w.Body)
}
if in.read != 0 {
t.Errorf("the file was streamed before the form was known to be usable (%d bytes)", in.read)
}
}
func TestAnUploadWithNoFileAtAllIsRefused(t *testing.T) {
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{}})
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("source_lang", "zh"); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400", got.Code)
}
// And a body that is not multipart at all.
r := httptest.NewRequest("POST", "/v0/books", strings.NewReader(`{"not":"multipart"}`))
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer token")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, r)
if rec.Code != http.StatusBadRequest {
t.Fatalf("a JSON body on the intake route: %d, want 400", rec.Code)
}
}
// A field the contract does not name is IGNORED, like an unknown JSON property: inside 0.x a minor
// bump is where optional fields appear, and refusing the upload would break a client generated
// against a later version for something it was free to ignore.
func TestAnUnknownFormFieldDoesNotRefuseTheUpload(t *testing.T) {
in := &fakeIntake{book: pgstore.Book{ID: "bk_1", Status: "parsing"}}
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in})
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for _, kv := range [][2]string{{"source_lang", "zh"}, {"target_lang", "ru"}, {"title", "as yet unratified"}} {
if err := w.WriteField(kv[0], kv[1]); err != nil {
t.Fatal(err)
}
}
part, err := w.CreateFormFile("file", "b.txt")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("text")); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusCreated {
t.Fatalf("status %d, want 201: an unknown field must not refuse the upload", got.Code)
}
}
// A form field long enough to be a payload of its own is refused rather than truncated.
func TestAFormFieldThatIsAPayloadIsRefused(t *testing.T) {
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{},
Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute}})
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("title", strings.Repeat("g", maxIntakeField+1)); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
if got := upload(t, h, w.FormDataContentType(), &buf); got.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400", got.Code)
}
}
// The service's own refusals reach the wire as the contract's codes and never as engine or database
// text (§Problem).
func TestWhatTheIntakeRefusesBecomesAProductPhrase(t *testing.T) {
cases := []struct {
err error
want int
}{
{fmt.Errorf("%w: the languages must be codes", books.ErrBadIntake), http.StatusBadRequest},
{pgstore.ErrNoAccount, http.StatusNotFound},
{errors.New("pgstore: connection refused: dial tcp 127.0.0.1:5432"), http.StatusInternalServerError},
}
for _, tc := range cases {
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{err: tc.err}})
ct, body := form(t, "b.txt", 16, true)
w := upload(t, h, ct, body)
if w.Code != tc.want {
t.Errorf("%v -> %d, want %d", tc.err, w.Code, tc.want)
}
if strings.Contains(w.Body.String(), "pgstore") || strings.Contains(w.Body.String(), "5432") {
t.Errorf("the response carries internals: %s", w.Body)
}
}
}
// FP5-8(в) (acceptance): the cap allowed one part past its own number. The assertion is not the
// status — a form that simply runs out of parts is a 400 too, so a status-only test says nothing —
// but that the part BEHIND the cap is never handed to the intake. The file sits at exactly
// maxIntakeParts+1 here: with the cap off by one it is reached, accepted and streamed, which is the
// work the cap exists to refuse.
func TestAFormWithTooManyPartsIsRefusedAtTheCap(t *testing.T) {
in := &fakeIntake{book: pgstore.Book{ID: "bk_cap"}}
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: in,
Upload: UploadLimits{MaxBytes: 8 << 20, Deadline: time.Minute}})
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range map[string]string{"source_lang": "zh", "target_lang": "ru"} {
if err := w.WriteField(k, v); err != nil {
t.Fatal(err)
}
}
for i := range maxIntakeParts - 2 {
if err := w.WriteField(fmt.Sprintf("filler-%d", i), "x"); err != nil {
t.Fatal(err)
}
}
fw, err := w.CreateFormFile("file", "book.txt")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write(bytes.Repeat([]byte("x"), 1024)); err != nil {
t.Fatal(err)
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
got := upload(t, h, w.FormDataContentType(), &buf)
if got.Code != http.StatusBadRequest {
t.Fatalf("status %d with %d parts, want 400 at the cap of %d", got.Code, maxIntakeParts+1, maxIntakeParts)
}
if in.read != 0 {
t.Fatalf("the file behind the cap was streamed anyway: %d bytes", in.read)
}
}
// FP5-6 (acceptance): a body that outlives the route's own deadline is the client being slow, not
// this service being broken — and a 500 tells the client the opposite of the truth about retrying.
func TestAnUploadThatOutlivesItsDeadlineIsNotAnInternalError(t *testing.T) {
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{err: os.ErrDeadlineExceeded}})
ct, body := form(t, "book.txt", 64, true)
w := upload(t, h, ct, body)
if w.Code == http.StatusInternalServerError {
t.Fatalf("an expired upload deadline answered 500: %s", w.Body)
}
if w.Code != http.StatusRequestTimeout {
t.Fatalf("status %d, want 408", w.Code)
}
if w.Header().Get("Content-Type") != "application/problem+json" {
t.Errorf("content-type %q", w.Header().Get("Content-Type"))
}
}