package httpapi import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/mithral/vellum/internal/core" "github.com/mithral/vellum/migrations" ) func newTestServer(t *testing.T) *Server { t.Helper() db, err := core.OpenDB(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("OpenDB: %v", err) } t.Cleanup(func() { _ = db.Close() }) log := slog.New(slog.NewTextHandler(io.Discard, nil)) if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil { t.Fatalf("Migrate: %v", err) } return New(db, log, "test") } func TestHealth(t *testing.T) { srv := newTestServer(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode body: %v", err) } if body["status"] != "ok" { t.Errorf("status = %v, want ok", body["status"]) } if body["version"] != "test" { t.Errorf("version = %v, want test", body["version"]) } } func TestNotFoundIsJSON(t *testing.T) { srv := newTestServer(t) // API paths must never fall back to the SPA shell. rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", rec.Code) } if ct := rec.Header().Get("Content-Type"); ct == "" { t.Error("missing Content-Type on 404") } var body map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("404 body is not JSON: %v", err) } } func TestRequestIDHeaderAndLog(t *testing.T) { srv := newTestServer(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) srv.Handler().ServeHTTP(rec, req) if rec.Header().Get("X-Request-ID") == "" { t.Error("X-Request-ID header not set") } // A caller-supplied ID must be preserved. rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodGet, "/healthz", nil) req2.Header.Set("X-Request-ID", "abc123") srv.Handler().ServeHTTP(rec2, req2) if got := rec2.Header().Get("X-Request-ID"); got != "abc123" { t.Errorf("X-Request-ID = %q, want abc123", got) } } func TestRecoveryMiddleware(t *testing.T) { srv := newTestServer(t) srv.Handle("GET /panic", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") })) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/panic", nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", rec.Code) } // Security headers must survive the recovered panic, which is only true // because the security middleware sits outside recovery in Handler. if rec.Header().Get("Content-Security-Policy") == "" { t.Error("security headers missing on recovered panic 500") } } func TestSecurityHeaders(t *testing.T) { srv := newTestServer(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/healthz", nil) srv.Handler().ServeHTTP(rec, req) for header, want := range map[string]string{ "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "no-referrer", } { if got := rec.Header().Get(header); got != want { t.Errorf("%s = %q, want %q", header, got, want) } } if rec.Header().Get("Content-Security-Policy") == "" { t.Error("Content-Security-Policy header not set") } } // flushRecorder is a ResponseWriter that records whether Flush reached it. type flushRecorder struct { *httptest.ResponseRecorder flushed bool } func (f *flushRecorder) Flush() { f.flushed = true } func TestStatusRecorderForwardsFlush(t *testing.T) { srv := newTestServer(t) var sawFlusher bool srv.Handle("GET /flush", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f, ok := w.(http.Flusher) sawFlusher = ok if ok { f.Flush() } })) rec := &flushRecorder{ResponseRecorder: httptest.NewRecorder()} req := httptest.NewRequest(http.MethodGet, "/flush", nil) srv.Handler().ServeHTTP(rec, req) if !sawFlusher { t.Error("writer passed to handler does not implement http.Flusher") } if !rec.flushed { t.Error("Flush was not forwarded to the underlying writer") } } // readFromRecorder records whether ReadFrom reached it. type readFromRecorder struct { *httptest.ResponseRecorder readFrom bool } func (r *readFromRecorder) ReadFrom(src io.Reader) (int64, error) { r.readFrom = true return io.Copy(r.ResponseRecorder, src) } func TestStatusRecorderForwardsReadFrom(t *testing.T) { srv := newTestServer(t) srv.Handle("GET /copy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if rf, ok := w.(io.ReaderFrom); ok { _, _ = rf.ReadFrom(strings.NewReader("payload")) } })) rec := &readFromRecorder{ResponseRecorder: httptest.NewRecorder()} req := httptest.NewRequest(http.MethodGet, "/copy", nil) srv.Handler().ServeHTTP(rec, req) if !rec.readFrom { t.Error("ReadFrom was not forwarded to the underlying writer") } if got := rec.Body.String(); got != "payload" { t.Errorf("body = %q, want payload", got) } } func TestStatusRecorderIgnoresSecondWriteHeader(t *testing.T) { rec := &statusRecorder{ResponseWriter: httptest.NewRecorder(), status: http.StatusOK} rec.WriteHeader(http.StatusCreated) rec.WriteHeader(http.StatusTeapot) // must be ignored, not logged as superfluous if rec.status != http.StatusCreated { t.Errorf("status = %d, want 201", rec.status) } }