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 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] }