165 lines
5.2 KiB
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)
|
|
}
|
|
}
|