299 lines
11 KiB
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)
|
|
}
|
|
}
|