Files
kandev/apps/backend/internal/backendapp/health_test.go
T

421 lines
16 KiB
Go

package backendapp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
authhttpmw "github.com/kandev/kandev/internal/auth/httpmw"
"github.com/kandev/kandev/internal/persistence/requiredstores"
"github.com/kandev/kandev/internal/startup"
"github.com/kandev/kandev/internal/system/info"
)
// setReadyForTest flips the package-level readiness flag consulted by
// readyHandler and restores its prior value on cleanup, so tests don't leak
// state into each other or into TestMain-driven suites.
func setReadyForTest(t *testing.T, value bool) {
t.Helper()
prev := ready.Load()
ready.Store(value)
t.Cleanup(func() { ready.Store(prev) })
}
// TestHealthHandlerBodyIncludesVersionRegardlessOfReadiness covers AC-19/20
// of the 2026-08-22 liveness/readiness-split amendment
// (docs/specs/health-endpoint-version/spec.md): /health is a pure liveness
// probe, so its 200 body (status/service/mode/version, no other keys) is
// identical whether or not the `ready` flag has flipped. The old ready/
// starting two-shape split (superseded AC-1..7) now lives on readyHandler,
// covered by TestReadyHandlerBodyShapesByReadiness below.
func TestHealthHandlerBodyIncludesVersionRegardlessOfReadiness(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, readyState := range []bool{true, false} {
setReadyForTest(t, readyState)
router := gin.New()
router.GET("/health", healthHandler(routeParams{version: "1.2.3"}))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("ready=%v: status = %d, want %d", readyState, recorder.Code, http.StatusOK)
}
var body map[string]interface{}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("ready=%v: decode body: %v", readyState, err)
}
if body[versionFieldKey] != "1.2.3" {
t.Fatalf("ready=%v: version = %v, want 1.2.3", readyState, body[versionFieldKey])
}
if body["status"] != "ok" || body["service"] != kandevName || body["mode"] != "websocket+http" {
t.Fatalf("ready=%v: unexpected body: %#v", readyState, body)
}
if len(body) != 4 {
t.Fatalf("ready=%v: body keys = %#v, want exactly status/service/mode/version", readyState, body)
}
}
}
// TestReadyHandlerBodyShapesByReadiness covers AC-16..19: readyHandler now
// owns the two-shape ready/starting split that healthHandler used to have —
// 503 with status="starting" (no mode key) before ready, 200 with
// status="ok" (no mode key) once ready — always carrying version either way.
func TestReadyHandlerBodyShapesByReadiness(t *testing.T) {
gin.SetMode(gin.TestMode)
setReadyForTest(t, false)
router := gin.New()
router.GET("/ready", readyHandler(routeParams{version: "1.2.3"}))
startingRec := httptest.NewRecorder()
router.ServeHTTP(startingRec, httptest.NewRequest(http.MethodGet, "/ready", nil))
if startingRec.Code != http.StatusServiceUnavailable {
t.Fatalf("starting status = %d, want %d", startingRec.Code, http.StatusServiceUnavailable)
}
var startingBody map[string]interface{}
if err := json.Unmarshal(startingRec.Body.Bytes(), &startingBody); err != nil {
t.Fatalf("decode starting body: %v", err)
}
if startingBody[versionFieldKey] != "1.2.3" {
t.Fatalf("starting version = %v, want 1.2.3", startingBody[versionFieldKey])
}
if startingBody["status"] != startingStatus || startingBody["service"] != kandevName {
t.Fatalf("unexpected starting body: %#v", startingBody)
}
if len(startingBody) != 3 {
t.Fatalf("starting body keys = %#v, want exactly status/service/version", startingBody)
}
setReadyForTest(t, true)
readyRec := httptest.NewRecorder()
router.ServeHTTP(readyRec, httptest.NewRequest(http.MethodGet, "/ready", nil))
if readyRec.Code != http.StatusOK {
t.Fatalf("ready status = %d, want %d", readyRec.Code, http.StatusOK)
}
var readyBody map[string]interface{}
if err := json.Unmarshal(readyRec.Body.Bytes(), &readyBody); err != nil {
t.Fatalf("decode ready body: %v", err)
}
if readyBody[versionFieldKey] != "1.2.3" {
t.Fatalf("ready version = %v, want 1.2.3", readyBody[versionFieldKey])
}
if readyBody["status"] != "ok" || readyBody["service"] != kandevName {
t.Fatalf("unexpected ready body: %#v", readyBody)
}
if len(readyBody) != 3 {
t.Fatalf("ready body keys = %#v, want exactly status/service/version", readyBody)
}
}
// TestReadyHandlerIncludesStartupSnapshot covers the AC-PLATFORM-STARTUP-
// PROGRESS-005 requirement that /ready carry the startup snapshot in both
// its starting 503 and its ready 200 bodies, never 404, whenever a
// *startup.Reporter is wired into routeParams.
func TestReadyHandlerIncludesStartupSnapshot(t *testing.T) {
gin.SetMode(gin.TestMode)
reporter := startup.New(testLogger(t))
reporter.Set(startup.RecoveringSessions)
setReadyForTest(t, false)
router := gin.New()
router.GET("/ready", readyHandler(routeParams{version: "1.2.3", progress: reporter}))
startingRec := httptest.NewRecorder()
router.ServeHTTP(startingRec, httptest.NewRequest(http.MethodGet, "/ready", nil))
var startingBody map[string]interface{}
if err := json.Unmarshal(startingRec.Body.Bytes(), &startingBody); err != nil {
t.Fatalf("decode starting body: %v", err)
}
startingSnap, ok := startingBody["startup"].(map[string]interface{})
if !ok {
t.Fatalf("starting body missing \"startup\" snapshot: %#v", startingBody)
}
if startingSnap["phase"] != string(startup.RecoveringSessions) {
t.Fatalf("starting startup.phase = %v, want %v", startingSnap["phase"], startup.RecoveringSessions)
}
setReadyForTest(t, true)
readyRec := httptest.NewRecorder()
router.ServeHTTP(readyRec, httptest.NewRequest(http.MethodGet, "/ready", nil))
var readyBody map[string]interface{}
if err := json.Unmarshal(readyRec.Body.Bytes(), &readyBody); err != nil {
t.Fatalf("decode ready body: %v", err)
}
readySnap, ok := readyBody["startup"].(map[string]interface{})
if !ok {
t.Fatalf("ready body missing \"startup\" snapshot: %#v", readyBody)
}
if readySnap["phase"] != string(startup.RecoveringSessions) {
t.Fatalf("ready startup.phase = %v, want %v", readySnap["phase"], startup.RecoveringSessions)
}
}
func TestReadyHandlerReportsPersistenceFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
setReadyForTest(t, true)
tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{
ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"},
Sweep: startup.StepStoresRepositories,
}})
if err != nil {
t.Fatalf("NewTracker: %v", err)
}
if err := tracker.RecordSuccess("task"); err != nil {
t.Fatalf("RecordSuccess: %v", err)
}
if err := tracker.RecordProbe("task", errTestPersistence); err != nil {
t.Fatalf("RecordProbe: %v", err)
}
router := gin.New()
router.GET("/ready", readyHandler(routeParams{
version: "1.2.3",
persistenceHealth: requiredstores.NewHealth(tracker, nil, nil),
}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/ready", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("persistence status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode persistence body: %v", err)
}
if body["reason"] != "persistence" {
t.Fatalf("persistence reason = %v, want persistence", body["reason"])
}
if body["store_ids"].([]interface{})[0] != "task" {
t.Fatalf("persistence store_ids = %v, want task", body["store_ids"])
}
}
var errTestPersistence = persistenceTestError("probe failed")
type persistenceTestError string
func (e persistenceTestError) Error() string { return string(e) }
// TestHealthHandlerDesktopTokenHeaderAlwaysSetReadyEndpointNever covers AC-21:
// the desktop health-token header is a /health-only concern, unaffected by
// the liveness/readiness split — /health sets it on every request regardless
// of readiness, and /ready never sets it at all.
func TestHealthHandlerDesktopTokenHeaderAlwaysSetReadyEndpointNever(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv(desktopHealthTokenEnv, " desktop-token ")
router := gin.New()
router.GET("/health", healthHandler(routeParams{version: "1.2.3"}))
router.GET("/ready", readyHandler(routeParams{version: "1.2.3"}))
for _, readyState := range []bool{false, true} {
setReadyForTest(t, readyState)
healthRec := httptest.NewRecorder()
router.ServeHTTP(healthRec, httptest.NewRequest(http.MethodGet, "/health", nil))
if got := healthRec.Header().Get(desktopHealthTokenHeader); got != "desktop-token" {
t.Fatalf("ready=%v: /health %s header = %q, want desktop-token", readyState, desktopHealthTokenHeader, got)
}
readyRec := httptest.NewRecorder()
router.ServeHTTP(readyRec, httptest.NewRequest(http.MethodGet, "/ready", nil))
if got := readyRec.Header().Get(desktopHealthTokenHeader); got != "" {
t.Fatalf("ready=%v: /ready %s header = %q, want absent", readyState, desktopHealthTokenHeader, got)
}
}
}
// TestHealthHandlerVersionMatchesSystemInfoVersion covers AC-10: /health and
// /api/v1/system/info are fed the exact same build-version string
// (backendapp.Version, sampled after setBuildInfo — see main.go:801,1830), so
// this pins that both handlers surface it byte-identically rather than each
// having its own notion of "version".
func TestHealthHandlerVersionMatchesSystemInfoVersion(t *testing.T) {
gin.SetMode(gin.TestMode)
setReadyForTest(t, true)
const sameProcessVersion = "9.9.9-test"
healthRouter := gin.New()
healthRouter.GET("/health", healthHandler(routeParams{version: sameProcessVersion}))
healthRec := httptest.NewRecorder()
healthRouter.ServeHTTP(healthRec, httptest.NewRequest(http.MethodGet, "/health", nil))
var healthBody map[string]interface{}
if err := json.Unmarshal(healthRec.Body.Bytes(), &healthBody); err != nil {
t.Fatalf("decode health body: %v", err)
}
infoSvc := info.NewService(sameProcessVersion, "commit", "buildtime")
infoRouter := gin.New()
infoRouter.GET("/info", info.Handler(infoSvc))
infoRec := httptest.NewRecorder()
infoRouter.ServeHTTP(infoRec, httptest.NewRequest(http.MethodGet, "/info", nil))
var infoBody info.Response
if err := json.Unmarshal(infoRec.Body.Bytes(), &infoBody); err != nil {
t.Fatalf("decode info body: %v", err)
}
if healthBody[versionFieldKey] != infoBody.Version {
t.Fatalf("health version = %v, system/info version = %v, want equal", healthBody[versionFieldKey], infoBody.Version)
}
}
// TestSetBuildInfoVersionDefaultAndInjection covers AC-8, AC-9, and AC-11: an
// unstamped build keeps the compiled-in "dev" default, a non-empty ldflag
// value overwrites it, and an empty injected value is ignored rather than
// clearing the version to "".
func TestSetBuildInfoVersionDefaultAndInjection(t *testing.T) {
prevVersion, prevCommit, prevBuildTime := Version, Commit, BuildTime
t.Cleanup(func() { Version, Commit, BuildTime = prevVersion, prevCommit, prevBuildTime })
// AC-8: the compiled-in default (before this test or anything else
// mutates the package var) must actually be "dev". Asserted against
// prevVersion, captured above before any mutation in this test —
// asserting post-mutation would only prove setBuildInfo doesn't clobber
// an already-set value, not that the real default is "dev".
if prevVersion != "dev" {
t.Fatalf("compiled-in default Version = %q, want dev", prevVersion)
}
Version = "dev"
setBuildInfo(BuildInfo{})
if Version != "dev" {
t.Fatalf("Version after empty BuildInfo = %q, want dev default retained", Version)
}
if Version == "" {
t.Fatal("Version must never become empty")
}
setBuildInfo(BuildInfo{Version: "2.3.4"})
if Version != "2.3.4" {
t.Fatalf("Version after injected BuildInfo = %q, want 2.3.4", Version)
}
setBuildInfo(BuildInfo{Version: ""})
if Version != "2.3.4" {
t.Fatalf("Version after empty ldflag = %q, want prior value 2.3.4 retained", Version)
}
}
// TestHealthHandlerFallsBackToPackageVersionWhenParamEmpty covers AC-11
// against the production wiring gap: every other test in this file passes an
// explicit routeParams{version: "..."}, so none of them would notice if the
// one-line "version: Version" field assignment that wires routeParams.version
// to the package-level Version var (main.go, in the registerRoutes call built
// by run()) were ever deleted — /health would then silently serve
// "version":"" in production. Standing up run()'s full DI graph just to
// exercise that single field is out of proportion to this change (see the
// spec's Implementation Notes). Instead, healthHandler falls back to the
// package-level Version whenever routeParams.version is unset, so the never-
// empty guarantee holds at the handler layer regardless of what the caller
// wires up.
func TestHealthHandlerFallsBackToPackageVersionWhenParamEmpty(t *testing.T) {
gin.SetMode(gin.TestMode)
prevVersion := Version
t.Cleanup(func() { Version = prevVersion })
Version = "9.9.9-fallback-test"
router := gin.New()
router.GET("/health", healthHandler(routeParams{}))
for _, tc := range []struct {
name string
ready bool
}{
{name: "ready", ready: true},
{name: startingStatus, ready: false},
} {
t.Run(tc.name, func(t *testing.T) {
setReadyForTest(t, tc.ready)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
var body map[string]interface{}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body[versionFieldKey] != "9.9.9-fallback-test" {
t.Fatalf("version = %v, want fallback to package-level Version %q", body[versionFieldKey], "9.9.9-fallback-test")
}
})
}
}
// TestHealthHandlerAuthEnabledServesVersionWithoutCredential covers AC-12 and
// AC-13: with the auth feature flag on and no credential presented, /health
// must still return its full body (including version) rather than 401/403,
// and the readiness status code/field must be unchanged by auth being on.
func TestHealthHandlerAuthEnabledServesVersionWithoutCredential(t *testing.T) {
gin.SetMode(gin.TestMode)
setReadyForTest(t, true)
svc := newSSOTestAuthService(t)
if _, _, err := svc.Setup(context.Background(), "admin@example.com", "adminpass123", "Admin", "", ""); err != nil {
t.Fatalf("Setup admin: %v", err)
}
router := gin.New()
router.Use(authhttpmw.Middleware(svc))
router.GET("/health", healthHandler(routeParams{version: "1.2.3"}))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/health", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (no auth challenge on /health)", recorder.Code, http.StatusOK)
}
var body map[string]interface{}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body[versionFieldKey] != "1.2.3" {
t.Fatalf("version = %v, want 1.2.3", body[versionFieldKey])
}
if body["status"] != "ok" {
t.Fatalf("status field = %v, want ok", body["status"])
}
}
// TestReadyHandlerAuthEnabledServesWithoutCredential covers AC-22: /ready
// must be on the unauthenticated allowlist (mirrored at the middleware layer
// by TestEnabledModeAllowlistMatrix/ready), so it never returns 401/403 to
// an unauthenticated k8s readinessProbe or e2e fixture poll even with the
// auth feature flag on.
func TestReadyHandlerAuthEnabledServesWithoutCredential(t *testing.T) {
gin.SetMode(gin.TestMode)
setReadyForTest(t, true)
svc := newSSOTestAuthService(t)
if _, _, err := svc.Setup(context.Background(), "admin@example.com", "adminpass123", "Admin", "", ""); err != nil {
t.Fatalf("Setup admin: %v", err)
}
router := gin.New()
router.Use(authhttpmw.Middleware(svc))
router.GET("/ready", readyHandler(routeParams{version: "1.2.3"}))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/ready", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (no auth challenge on /ready)", recorder.Code, http.StatusOK)
}
var body map[string]interface{}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body[versionFieldKey] != "1.2.3" {
t.Fatalf("version = %v, want 1.2.3", body[versionFieldKey])
}
if body["status"] != "ok" {
t.Fatalf("status field = %v, want ok", body["status"])
}
}