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"]) } } func TestPanicBecomesAProblem(t *testing.T) { var logs bytes.Buffer inner := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") }) h := Recover(slog.New(slog.NewJSONHandler(&logs, nil)))(inner) w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v0/books", nil)) assertProblem(t, w, http.StatusInternalServerError) } 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) } }