346 lines
12 KiB
Go
346 lines
12 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".
|
|
read int64
|
|
}
|
|
|
|
func (f *fakeIntake) Accept(_ context.Context, in books.Intake) (pgstore.Book, error) {
|
|
f.got = in
|
|
if in.File != nil {
|
|
n, err := io.Copy(io.Discard, in.File)
|
|
f.read = n
|
|
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() {
|
|
for k, v := range map[string]string{"source_lang": "zh", "target_lang": "ru", "genre": "xianxia"} {
|
|
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", Genre: "xianxia",
|
|
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", "title", "source_lang", "target_lang", "status", "chapter_count",
|
|
"added_at", "progress", "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" || in.got.Genre != "xianxia" {
|
|
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", `{"verify_bank":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)
|
|
}
|
|
}
|
|
|
|
// 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("genre", 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"))
|
|
}
|
|
}
|