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,"ceiling_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")) } }