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