package httpapi import ( "bufio" "bytes" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "strings" "testing" "time" "textmachine/platform/internal/metrics" "textmachine/platform/internal/pgstore" "textmachine/platform/internal/runs" ) // The stop and resume handles answer 202 with a Run, and the run they act on is the one in the path. func TestStopAndResumeAnswerWithTheRunTheContractDescribes(t *testing.T) { for _, tc := range []struct { path string seen func(*fakeRuns) string }{ {"stop", func(f *fakeRuns) string { return f.stopped }}, {"resume", func(f *fakeRuns) string { return f.resumed }}, } { rn := &fakeRuns{run: pgstore.Run{ ID: "run_9", Revision: 12, Status: "stopped", VerifyBank: true, OrderedChapters: ptr(100), StartedAt: time.Unix(0, 0).UTC(), }} w := call(t, v0Server(t, &fakeLibrary{}, rn), "POST", "/v0/runs/run_9/"+tc.path, "") if w.Code != http.StatusAccepted { t.Fatalf("%s: status %d, want 202: %s", tc.path, w.Code, w.Body) } got := decode(t, w) for _, k := range []string{"id", "book_id", "revision", "status", "stop_for_signing", "ordered_chapters", "delivered_chapters", "paused_reason", "started_at"} { if _, ok := got[k]; !ok { t.Errorf("%s: the Run is missing the required field %q", tc.path, k) } } if got["paused_reason"] != nil { t.Errorf("%s: paused_reason is %v, want null outside a pause", tc.path, got["paused_reason"]) } if id := tc.seen(rn); id != "run_9" { t.Errorf("%s: the service was asked about %q, not the run in the path", tc.path, id) } } } // The four ways these two calls fail, each mapped to the code the contract names. 404 and 409 are // the pair that matters: a run of another account is NOT FOUND, and one that cannot be acted on is a // conflict — swapping them either tells a stranger the run exists or tells an owner theirs does not. func TestTheControlHandlesMapEveryRefusalToItsContractCode(t *testing.T) { cases := []struct { err error want int }{ {pgstore.ErrNoRun, http.StatusNotFound}, {fmt.Errorf("%w: it is ready", runs.ErrNotStoppable), http.StatusConflict}, {fmt.Errorf("%w: the glossary is not signed", runs.ErrNotResumable), http.StatusConflict}, {errors.New("pgstore: dial tcp 127.0.0.1:5432: connection refused"), http.StatusInternalServerError}, } for _, tc := range cases { h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: tc.err}) for _, path := range []string{"stop", "resume"} { w := call(t, h, "POST", "/v0/runs/run_9/"+path, "") if w.Code != tc.want { t.Errorf("%s on %v: %d, want %d", path, tc.err, w.Code, tc.want) } if w.Header().Get("Content-Type") != "application/problem+json" { t.Errorf("%s: content-type %q", path, w.Header().Get("Content-Type")) } if bytes.Contains(w.Body.Bytes(), []byte("5432")) { t.Errorf("%s: the response carries internals: %s", path, w.Body) } } } } // Both handles END or RESTART a paid run, so both are unsafe requests on the cookie path and both // need the CSRF header. The audit of P4 called this the weightiest of its findings: the check // survives everything else being right. func TestTheControlHandlesRequireTheCSRFHeaderOnTheCookiePath(t *testing.T) { h := v0Server(t, &fakeLibrary{}, &fakeRuns{}) for _, path := range []string{"/v0/runs/run_9/stop", "/v0/runs/run_9/resume", "/v0/books"} { r := httptest.NewRequest("POST", path, nil) r.AddCookie(&http.Cookie{Name: "__Host-tm_session", Value: "token"}) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusForbidden { t.Errorf("%s without X-TM-Client: %d, want 403", path, w.Code) } } } // The upload's own deadline, on a REAL server: a book is minutes of a domestic uplink and the // server's ReadTimeout — the whole of what bounds a half-fed request — covers the entire body. // // The negative half is the point of the test. The same slow body on a route that does NOT extend the // deadline is cut off, which is what proves the extension is doing the work rather than the timeout // being generous. func TestTheUploadRouteExtendsItsOwnReadDeadlineAndOnlyItsOwn(t *testing.T) { in := &fakeIntake{book: pgstore.Book{ID: "bk_1", Status: "parsing"}} h := v0ServerWith(t, Deps{ Library: &fakeLibrary{}, Runs: &fakeRuns{}, Intake: in, Upload: UploadLimits{MaxBytes: 1 << 20, Deadline: 10 * time.Second}, // With the telemetry wrapper in the chain, exactly as the daemon builds it. It is here on // purpose: http.ResponseController walks the wrappers through Unwrap, and a wrapper that // forgets it turns the deadline extension into a silent no-op — the upload would then die at // the server's own ReadTimeout and nothing in this file would notice. Observe: metrics.New().Middleware(), }) to := fastTimeouts() // Read: 250ms — far less than the body below takes to arrive ln := start(t, h, to) ct, body := form(t, "book.txt", 2048, true) raw, err := io.ReadAll(body) if err != nil { t.Fatal(err) } if code := slowPost(t, ln, "/v0/books", ct, raw); code != http.StatusCreated { t.Fatalf("a slow upload answered %d, want 201: the route's own deadline did not apply", code) } // The same shape of body on the JSON route keeps the server's short ReadTimeout and dies with it. payload := append([]byte(`{"stop_for_signing":false,"chapters":1,"pad":"`), append(bytes.Repeat([]byte("a"), 2048), []byte(`"}`)...)...) if code := slowPost(t, ln, "/v0/books/bk_1/runs", "application/json", payload); code == http.StatusAccepted { t.Fatal("a slow request on an ordinary route was served: the upload's deadline leaked onto it") } } // slowPost sends a request whose body arrives in two halves a second apart, which is a slow client // and not a malicious one: the bytes keep coming, just not fast enough for a 250ms deadline. func slowPost(t *testing.T, ln net.Listener, path, contentType string, body []byte) int { t.Helper() var d net.Dialer conn, err := d.DialContext(t.Context(), "tcp", ln.Addr().String()) if err != nil { t.Fatalf("dial: %v", err) } defer conn.Close() head := fmt.Sprintf("POST %s HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer token\r\n"+ "Content-Type: %s\r\nContent-Length: %d\r\n\r\n", path, contentType, len(body)) if _, err := conn.Write([]byte(head)); err != nil { t.Fatalf("write head: %v", err) } half := len(body) / 2 if _, err := conn.Write(body[:half]); err != nil { t.Fatalf("write first half: %v", err) } time.Sleep(time.Second) if _, err := conn.Write(body[half:]); err != nil { // A server that gave up has closed the connection; that is an answer too. return 0 } if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { t.Fatal(err) } resp, err := http.ReadResponse(bufio.NewReader(conn), nil) if err != nil { return 0 } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) return resp.StatusCode } // A run request whose body is over the route's cap is `400`, and the reason is the canon's own // response list for this operation: 400/401/403/404/409/503, no 413. `payload_too_large` is defined // as "over `intake_max_bytes`" — the UPLOAD's bound — and the other JSON write already answered 400 // for the same condition, so one of the two was lying about what the request had done wrong. // // Mutation caught: routing the read failure back through uploadFailed. func TestAnOverlongRunRequestIsInvalidAndNotTooLarge(t *testing.T) { h := v0Server(t, &fakeLibrary{}, &fakeRuns{run: pgstore.Run{ID: "run_1", BookID: "bk_1"}}) body := `{"stop_for_signing":false,"chapters":1,"pad":"` + strings.Repeat("x", DefaultMaxBody+1) + `"}` w := call(t, h, "POST", "/v0/books/bk_1/runs", body) if w.Code == http.StatusRequestEntityTooLarge { t.Fatalf("an over-long run request answered 413, a code this operation does not declare: %s", w.Body) } if w.Code != http.StatusBadRequest || !contains(w.Body.String(), `"invalid_request"`) { t.Errorf("an over-long run request answered %d %s", w.Code, w.Body) } }