package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"go.bigb.es/auxilia/culpa"
"sourcecraft.dev/bigbes/lethe/internal/config"
"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
"sourcecraft.dev/bigbes/lethe/internal/platform/health"
"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
authpkg "sourcecraft.dev/bigbes/lethe/internal/server/auth"
)
// newTestServer wires a Server with hand-constructed dependencies so unit
// tests do not need to spin up the steward graph.
func newTestServer(t *testing.T, bind string) *Server {
t.Helper()
logger := &observability.Logger{Cfg: config.LoggingConfig{Level: "info", Format: "json"}}
if err := logger.Init(context.Background()); err != nil {
t.Fatalf("logger.Init: %v", err)
}
metrics := &observability.Metrics{}
if err := metrics.Init(context.Background()); err != nil {
t.Fatalf("metrics.Init: %v", err)
}
return &Server{
Cfg: config.ServerConfig{
Bind: bind,
ShutdownGrace: 5 * time.Second,
},
Log: logger,
Metrics: metrics,
Health: &health.Set{},
Auth: &authpkg.Authenticator{},
Ingest: &ingest.Handler{},
Sessions: &session.Handler{},
}
}
func TestServerInit_RejectsNonLoopbackBind(t *testing.T) {
s := newTestServer(t, "0.0.0.0:8080")
err := s.Init(context.Background())
if err == nil {
t.Fatalf("Init: expected error for non-loopback bind")
}
var cd culpa.CodeDetail
if !culpa.FindDetail(err, &cd) {
t.Fatalf("Init: expected culpa CodeDetail; got %v", err)
}
if cd.Code != "CONFIG_INVALID" {
t.Errorf("Init: code = %v; want CONFIG_INVALID", cd.Code)
}
}
func TestServerInit_AcceptsLoopback(t *testing.T) {
s := newTestServer(t, "127.0.0.1:0")
if err := s.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
if s.router == nil {
t.Fatalf("Init: router not built")
}
}
func TestRouter_RecoveryTurnsPanicInto500Problem(t *testing.T) {
s := newTestServer(t, "127.0.0.1:0")
if err := s.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
// Mount a panicking route on the router *after* Init wired the
// middleware chain so the recovery middleware sits in front of it.
s.router.Get("/boom", func(http.ResponseWriter, *http.Request) {
panic("kaboom")
})
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d; want 500", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Errorf("Content-Type = %q; want application/problem+json", ct)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
if body["status"].(float64) != 500 {
t.Errorf("body.status = %v; want 500", body["status"])
}
if body["detail"] != "internal server error" {
t.Errorf("body.detail = %v; want sanitized message", body["detail"])
}
}
func TestRouter_RequestIDInResponseAndContext(t *testing.T) {
s := newTestServer(t, "127.0.0.1:0")
if err := s.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
var fromCtx string
s.router.Get("/probe", func(w http.ResponseWriter, r *http.Request) {
fromCtx = observability.RequestIDFrom(r.Context())
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
headerID := rec.Header().Get("X-Request-ID")
if headerID == "" {
t.Fatalf("X-Request-ID header missing")
}
if fromCtx == "" {
t.Fatalf("request id missing from context")
}
if headerID != fromCtx {
t.Errorf("ctx id %q != header id %q", fromCtx, headerID)
}
}
func TestRouter_HealthzReturnsOK(t *testing.T) {
s := newTestServer(t, "127.0.0.1:0")
if err := s.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
if rec.Body.String() != "ok" {
t.Errorf("body = %q; want ok", rec.Body.String())
}
}
func TestRouter_ReadyzAllOKWithEmptyChecks(t *testing.T) {
s := newTestServer(t, "127.0.0.1:0")
if err := s.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
}
func TestRouter_MetricsExposesPrometheus(t *testing.T) {
s := newTestServer(t, "127.0.0.1:0")
if err := s.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
if got := rec.Body.String(); got == "" {
t.Errorf("metrics body empty")
}
}
// TestRouter_APIv1MountsAuthMiddleware is a smoke test: the auth middleware
// is the identity passthrough in Phase 5, but the /api/v1 group must still
// wire it in so Phase 6's replacement immediately takes effect on every
// route registered by the ingest/session handlers.
func TestRouter_APIv1MountsAuthMiddleware(t *testing.T) {
s := newTestServer(t, "127.0.0.1:0")
if err := s.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
// Hang a probe route off the /api/v1 group via the same chi router.
s.router.Route("/api/v1/probe", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/probe", nil)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, req)
if rec.Code != http.StatusTeapot {
t.Fatalf("status = %d; want 418", rec.Code)
}
}
// TestRouter_NotFoundReturnsProblemJSON verifies that the chi router's
// NotFound handler is correctly wired to return RFC 7807 problem+json.
// The notFoundHandler is invoked directly (the SPA GET catch-all and chi's
// MethodNotAllowed logic cover all observable routing paths in practice, so
// NotFound only fires when called explicitly or by future route changes).
func TestRouter_NotFoundReturnsProblemJSON(t *testing.T) {
srv := newTestServer(t, "127.0.0.1:0")
if err := srv.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
// Call notFoundHandler directly: it is the registered handler and its
// output must be RFC 7807 problem+json regardless of routing context.
req := httptest.NewRequest(http.MethodGet, "/irrelevant", nil)
rec := httptest.NewRecorder()
notFoundHandler(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d, want 404", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Fatalf("Content-Type: got %q, want application/problem+json", ct)
}
var p map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("body is not JSON: %v", err)
}
if got, _ := p["code"].(string); got != "NOT_FOUND" {
t.Fatalf("code: got %q, want NOT_FOUND", got)
}
}
func TestRouter_MethodNotAllowedReturnsProblemJSON(t *testing.T) {
srv := newTestServer(t, "127.0.0.1:0")
if err := srv.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/healthz", nil)
rec := httptest.NewRecorder()
srv.router.ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status: got %d, want 405", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/problem+json" {
t.Fatalf("Content-Type: got %q, want application/problem+json", ct)
}
var p map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("body is not JSON: %v", err)
}
if got, _ := p["code"].(string); got != "METHOD_NOT_ALLOWED" {
t.Fatalf("code: got %q, want METHOD_NOT_ALLOWED", got)
}
}
// TestRouter_ServesSPAAtRoot verifies that GET / returns 200 text/html with
// the embedded SPA markup.
func TestRouter_ServesSPAAtRoot(t *testing.T) {
srv := newTestServer(t, "127.0.0.1:0")
if err := srv.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
srv.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if ct == "" || ct[:9] != "text/html" {
t.Errorf("Content-Type: got %q, want text/html prefix", ct)
}
body := rec.Body.String()
if len(body) == 0 {
t.Fatal("body is empty")
}
// The placeholder or the built SPA should contain a <title> element.
if !contains(body, "<title>") {
t.Errorf("body does not contain <title>; got first 200 chars: %q", truncate(body, 200))
}
}
// TestRouter_SPAFallbackForNonAPIPath verifies that a non-existent UI path
// receives the SPA index.html (200 text/html) rather than a 404.
func TestRouter_SPAFallbackForNonAPIPath(t *testing.T) {
srv := newTestServer(t, "127.0.0.1:0")
if err := srv.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/session/foo/bar/baz", nil)
rec := httptest.NewRecorder()
srv.router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200 (SPA fallback)", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if ct == "" || ct[:9] != "text/html" {
t.Errorf("Content-Type: got %q, want text/html prefix", ct)
}
}
// TestRouter_APIPathsBypassSPA verifies that /api/v1/* paths are auth-gated
// and never served the SPA shell.
func TestRouter_APIPathsBypassSPA(t *testing.T) {
srv := newTestServer(t, "127.0.0.1:0")
if err := srv.Init(context.Background()); err != nil {
t.Fatalf("Init: %v", err)
}
// No Remote-User header → auth middleware should return 401 problem+json.
req := httptest.NewRequest(http.MethodGet, "/api/v1/sessions", nil)
rec := httptest.NewRecorder()
srv.router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want 401", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if ct != "application/problem+json" {
t.Errorf("Content-Type: got %q, want application/problem+json", ct)
}
// Must not be HTML.
body := rec.Body.String()
if contains(body, "<html") || contains(body, "<title>") {
t.Errorf("got HTML body for API route; SPA handler must not intercept /api/v1/*")
}
}
// contains is a simple substring check to keep test assertions readable.
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
func() bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}())
}
// truncate returns at most n bytes of s for diagnostic messages.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}