textmachine/platform/internal/httpapi/server_test.go

165 lines
5.2 KiB
Go

package httpapi
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"textmachine/platform/internal/auth"
)
type prober struct{ err error }
func (p prober) Ping(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)
}
}