textmachine/platform/internal/httpapi/control_test.go

189 lines
7.8 KiB
Go

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)
}
}