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) } } 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) } req := httptest.NewRequest(http.MethodGet, "/no-such-route", nil) rec := httptest.NewRecorder() srv.router.ServeHTTP(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) } }