442 lines
16 KiB
Go
442 lines
16 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"))
|
|
}
|
|
}
|
|
|
|
// The two refusals the intake's own cut produces reach the wire as the contract's `400` with an item
|
|
// naming the `file` part — and each with ITS OWN code, because the remedies are opposite: a different
|
|
// file, versus waiting for the delivery structure (backlog rows 283/325).
|
|
//
|
|
// Both are asserted, and by code rather than by count: the mapping is a two-line switch, and a test
|
|
// content with "some 400 came back" passes with the two codes swapped.
|
|
func TestTheIntakesOwnRefusalsReachTheWireWithTheirOwnCodes(t *testing.T) {
|
|
for _, c := range []struct {
|
|
err error
|
|
code string
|
|
}{
|
|
{books.ErrNoBookInSource, ItemNoBook},
|
|
{books.ErrStructureNotDeliverable, ItemNoChapterStructure},
|
|
} {
|
|
t.Run(c.code, func(t *testing.T) {
|
|
h := v0ServerWith(t, Deps{Library: &fakeLibrary{}, Intake: &fakeIntake{err: c.err}})
|
|
ct, body := form(t, "蛊真人.txt", 64, true)
|
|
w := upload(t, h, ct, body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status %d, want 400: %s", w.Code, w.Body)
|
|
}
|
|
got := decode(t, w)
|
|
if got["code"] != string(CodeInvalidRequest) {
|
|
t.Errorf("root code %v, want %q — no new ErrorCode is minted for these", got["code"], CodeInvalidRequest)
|
|
}
|
|
items, _ := got["errors"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("errors carries %d items, want exactly the one naming the file part: %s", len(items), w.Body)
|
|
}
|
|
item, _ := items[0].(map[string]any)
|
|
if item["code"] != c.code {
|
|
t.Errorf("item code %v, want %q: the two refusals are different answers to the user", item["code"], c.code)
|
|
}
|
|
if item["pointer"] != "/file" {
|
|
t.Errorf("item pointer %v, want /file — the part the refusal is about", item["pointer"])
|
|
}
|
|
})
|
|
}
|
|
}
|