textmachine/platform/internal/httpapi/server_test.go

299 lines
11 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"textmachine/platform/internal/auth"
)
type prober struct{ err error }
func (p prober) Ready(context.Context) error { return p.err }
type liveSessions struct{}
func (liveSessions) Lookup(context.Context, []byte, time.Time) (auth.Session, error) {
return auth.Session{UserID: "u1", IdleExpiresAt: time.Now().Add(time.Hour), AbsoluteExpiresAt: time.Now().Add(time.Hour)}, nil
}
func (liveSessions) Touch(context.Context, []byte, time.Time, time.Duration) error { return nil }
type deadSessions struct{}
func (deadSessions) Lookup(context.Context, []byte, time.Time) (auth.Session, error) {
return auth.Session{}, auth.ErrNoSession
}
func (deadSessions) Touch(context.Context, []byte, time.Time, time.Duration) error { return nil }
func newServer(t *testing.T, db Prober, sessions auth.SessionStore) (http.Handler, *bytes.Buffer) {
t.Helper()
var logs bytes.Buffer
h, err := New(Deps{
Log: slog.New(slog.NewJSONHandler(&logs, nil)),
DB: db,
Auth: &auth.Authenticator{
Sessions: sessions,
IdleTTL: time.Hour,
Deny: ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"),
},
})
if err != nil {
t.Fatal(err)
}
return h, &logs
}
func TestHealthzIsIndependentOfTheDatabase(t *testing.T) {
h, _ := newServer(t, prober{err: errors.New("down")}, deadSessions{})
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
// PT-34 and the no-store rule are applied to every response, not per handler.
if got := w.Header().Get("X-Robots-Tag"); !strings.Contains(got, "noindex") {
t.Fatalf("X-Robots-Tag = %q", got)
}
if got := w.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q", got)
}
if w.Header().Get("X-Request-Id") == "" {
t.Fatal("no request id")
}
}
func TestReadyzFollowsTheDatabase(t *testing.T) {
for name, tc := range map[string]struct {
db Prober
want int
}{
"healthy": {prober{}, http.StatusOK},
"unreachable": {prober{err: errors.New("down")}, http.StatusServiceUnavailable},
"not configured": {nil, http.StatusServiceUnavailable},
} {
t.Run(name, func(t *testing.T) {
h, _ := newServer(t, tc.db, deadSessions{})
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if w.Code != tc.want {
t.Fatalf("status = %d, want %d", w.Code, tc.want)
}
})
}
}
func TestAPISubtreeIsGuardedBeforeItIsRouted(t *testing.T) {
h, logs := newServer(t, prober{}, deadSessions{})
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v0/books", nil))
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401: an anonymous caller must not be able to map the surface", w.Code)
}
assertProblem(t, w, http.StatusUnauthorized)
// A rejected request must still be logged: an invisible 401 is an invisible brute force.
if !strings.Contains(logs.String(), `"status":401`) {
t.Fatalf("denial not logged: %s", logs.String())
}
}
func TestAuthenticatedUnknownRouteIs404Problem(t *testing.T) {
h, _ := newServer(t, prober{}, liveSessions{})
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v0/nothing-here", nil)
r.Header.Set("Authorization", "Bearer t")
h.ServeHTTP(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d", w.Code)
}
assertProblem(t, w, http.StatusNotFound)
}
func TestAccessLogNamesTheRouteNotThePath(t *testing.T) {
h, logs := newServer(t, prober{}, deadSessions{})
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/healthz", nil))
var line map[string]any
if err := json.Unmarshal(bytes.TrimSpace(logs.Bytes()), &line); err != nil {
t.Fatalf("log line: %v (%q)", err, logs.String())
}
if line["route"] != "GET /healthz" {
t.Fatalf("route = %v; the pattern is what keeps ids out of the log", line["route"])
}
if line["status"] != float64(http.StatusOK) {
t.Fatalf("status = %v", line["status"])
}
}
// A panic becomes a 500 problem+json, and its ERROR line names the ROUTE.
//
// The second half is PD-83 — the half of PD-3 that was never pinned. AccessLog's route discipline
// had a test; Recover's did not, so putting r.URL.Path back into the panic line left the battery
// green. That line is the one an operator reads, quotes into a ticket and pastes into a search box:
// a book id in it travels further than one in an INFO line, not less far.
// Mutation caught: logging r.URL.Path instead of routeOf(r).
func TestPanicBecomesAProblemAndNamesTheRoute(t *testing.T) {
panics := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") })
mux := http.NewServeMux()
mux.Handle("GET /v0/books/{book}", panics)
for name, tc := range map[string]struct {
inner http.Handler
target, wantRoute string
}{
"behind the mux": {mux, "/v0/books/bk-private-42", "GET /v0/books/{book}"},
// Recover also wraps handlers no mux ever routed, and the answer there must be a constant
// rather than the path the request happens to carry.
"never routed": {panics, "/nothing/bk-private-42", "(unmatched)"},
} {
t.Run(name, func(t *testing.T) {
var logs bytes.Buffer
h := Recover(slog.New(slog.NewJSONHandler(&logs, nil)))(tc.inner)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.target, nil))
assertProblem(t, w, http.StatusInternalServerError)
var line map[string]any
if err := json.Unmarshal(bytes.TrimSpace(logs.Bytes()), &line); err != nil {
t.Fatalf("log line: %v (%q)", err, logs.String())
}
if line["route"] != tc.wantRoute {
t.Fatalf("route = %v, want %q", line["route"], tc.wantRoute)
}
// Asserted over the WHOLE line, stack included: the id must not reach the log by any key.
if strings.Contains(logs.String(), "bk-private-42") {
t.Fatalf("a book id reached the panic line:\n%s", logs.String())
}
})
}
}
func TestServerRefusesToBuildWithoutAnAuthenticator(t *testing.T) {
if _, err := New(Deps{Log: slog.Default()}); err == nil {
t.Fatal("the API subtree may not be mounted unguarded")
}
}
func assertProblem(t *testing.T, w *httptest.ResponseRecorder, status int) {
t.Helper()
if got := w.Header().Get("Content-Type"); got != "application/problem+json" {
t.Fatalf("content-type = %q", got)
}
var p Problem
if err := json.Unmarshal(w.Body.Bytes(), &p); err != nil {
t.Fatalf("body: %v (%q)", err, w.Body.String())
}
if p.Status != status || p.Title == "" {
t.Fatalf("problem = %+v", p)
}
// Engine vocabulary must never reach a client (contract §2.12).
if strings.Contains(p.Detail, "sqlite") || strings.Contains(p.Detail, "pgx") {
t.Fatalf("internals leaked into detail: %q", p.Detail)
}
}
// stubLogin stands in for the sign-in flow: it only has to prove that the mount is wired the way
// the flow expects — starting a login without a session, ending one only with a session.
type stubLogin struct{}
func (stubLogin) Routes(guard func(http.Handler) http.Handler) http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /auth/login", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusSeeOther)
}))
mux.Handle("POST /auth/logout", guard(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})))
return mux
}
// The sign-in subtree is half-guarded on purpose, and the halves must not swap: the endpoint that
// CREATES a session cannot require one, and the endpoint that ends a session must.
// Mutation caught: wrapping the whole subtree in the guard, or mounting it without one.
func TestSignInSubtreeIsGuardedInHalves(t *testing.T) {
var logs bytes.Buffer
h, err := New(Deps{
Log: slog.New(slog.NewJSONHandler(&logs, nil)),
Login: stubLogin{},
Auth: &auth.Authenticator{
Sessions: deadSessions{},
IdleTTL: time.Hour,
Deny: ProblemHandler(http.StatusUnauthorized, "Session missing or invalid"),
},
})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("starting a login = %d, want 303: it cannot require the session it is about to create", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("signing out without a session = %d, want 401", rec.Code)
}
// And the subtree is under the CSRF check: a cross-site POST must not reach it.
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
req.Header.Set("Sec-Fetch-Site", "cross-site")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("cross-site sign-out = %d, want 403", rec.Code)
}
}
// PD-53. The body cap is registered per ROUTE and never as a blanket layer above them, and the
// third case below is the measured reason: http.MaxBytesReader wrapping an already wrapped body
// keeps the TIGHTER limit, so a route could never raise its own above a shared default. Reintroduce
// an outer LimitBody and the upload route silently gets the small cap instead of its own.
// Mutation caught: adding a blanket LimitBody in New; removing LimitBody from guard.
func TestBodyCapIsPerRouteBecauseNestingOnlyTightens(t *testing.T) {
drain := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := io.Copy(io.Discard, r.Body); err != nil {
WriteProblem(w, http.StatusRequestEntityTooLarge, "Request body too large", "")
return
}
w.WriteHeader(http.StatusOK)
})
for name, tc := range map[string]struct {
h http.Handler
body int
want int
}{
"at the cap": {LimitBody(10)(drain), 10, http.StatusOK},
"over the cap": {LimitBody(10)(drain), 11, http.StatusRequestEntityTooLarge},
"a route with its own larger cap gets it": {LimitBody(1000)(drain), 11, http.StatusOK},
"a larger cap nested inside a smaller one does NOT raise it": {
LimitBody(10)(LimitBody(1000)(drain)), 11, http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v0/books", bytes.NewReader(make([]byte, tc.body)))
tc.h.ServeHTTP(w, r)
if w.Code != tc.want {
t.Fatalf("status = %d, want %d", w.Code, tc.want)
}
})
}
}
// The default is for contract routes carrying JSON of a few kilobytes. A band rather than the exact
// number: what matters is that nobody quietly turns the shared default into an upload allowance —
// the upload route is supposed to register its own, larger cap (PD-35).
func TestDefaultBodyCapStaysAContractSizedNumber(t *testing.T) {
if DefaultMaxBody < 64<<10 || DefaultMaxBody > 4<<20 {
t.Fatalf("DefaultMaxBody = %d: outside the band a contract route needs; an upload route registers its own cap",
DefaultMaxBody)
}
}