169 lines
6.7 KiB
Go
169 lines
6.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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, CeilingChapters: 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", "revision", "status", "verify_bank", "ceiling_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(`{"verify_bank":false,"ceiling_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
|
|
}
|