222 lines
9.9 KiB
Go
222 lines
9.9 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The words a resume of a run under a stop puts ON THE WIRE, written out as LITERALS.
|
|
//
|
|
// ⛔ THE LITERALS ARE THE POINT. An assertion that compares the body to the same Go constant it was
|
|
// rendered from is self-consistent at any value — rename both and the module stays green, which is
|
|
// the class PD-1 names and which this zone measured on its own new values on 11.09. These two words
|
|
// are what a generated client matches on, so they are spelled here the way a client spells them.
|
|
//
|
|
// ⚠ There is no canon enum to check the cause against, and that is the contract's own decision rather
|
|
// than a gap: `ErrorCause` is declared «NOT a closed vocabulary … so a value added here is not a
|
|
// contract change». The ROOT code is enumerated, and `run_not_resumable` is the one this answer keeps.
|
|
func TestAResumeOfARunUnderAStopSaysRunNotResumableWithStopRequested(t *testing.T) {
|
|
h := v0Server(t, &fakeLibrary{}, &fakeRuns{err: runs.ErrStopRequested})
|
|
w := call(t, h, "POST", "/v0/runs/run_9/resume", "")
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("status %d, want 409: %s", w.Code, w.Body)
|
|
}
|
|
got := decode(t, w)
|
|
if got["code"] != "run_not_resumable" {
|
|
t.Errorf("code = %v, want %q", got["code"], "run_not_resumable")
|
|
}
|
|
cause, _ := got["cause"].(map[string]any)
|
|
if cause == nil || cause["code"] != "stop_requested" {
|
|
// ⚠ ONE ASSERTION AND NOT TWO. The first version followed this with a loop checking the cause
|
|
// was not `ceiling_reached` or `credit_unavailable` — unreachable by construction, because the
|
|
// line above has already stopped the test unless the cause is exactly `stop_requested`. Dead
|
|
// code that reads like a second guarantee is worse than no second guarantee (acceptance F6):
|
|
// what those neighbours are actually told apart from is pinned where they are ANSWERED, in
|
|
// runs.TestResumeOverAnEmptyAccountSaysTheCreditIsUnavailableAndNotTheCeiling.
|
|
t.Fatalf("cause = %v, want {code: stop_requested}: the remedies of this code are opposite — a new "+
|
|
"run, money, or a moment's wait — and the word is the whole of what says which", got["cause"])
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|