mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:01:31 +00:00
60a589ab96
Closes the 5 gaps the post-Phase-5 audit flagged on dev/auth-bundle-1.
C1: cmd/server/main.go now selects auth.NewDemoModeAuth() when
CERTCTL_AUTH_TYPE=none and falls back to auth.NewAuthWithNamedKeys
otherwise. Pre-closure, the no-op pass-through that
NewAuthWithNamedKeys returns for empty keys would have left
ActorIDKey / ActorTypeKey / TenantIDKey unpopulated and 401'd
every Phase-3.5 rbacGate-wrapped admin route + every Phase-4
RBAC handler in demo deployments. NewDemoModeAuth injects the
synthetic 'actor-demo-anon' actor seeded by migration 000029,
which holds r-admin at global scope.
C2: backfillNamedKeyActorRoles startup hook (cmd/server/auth_backfill.go)
iterates CERTCTL_API_KEYS_NAMED entries (and legacy
CERTCTL_AUTH_SECRET synthesized fallbacks) and grants r-admin
or r-viewer to each via authActorRoleRepo.Grant before the
HTTP server starts accepting requests. Idempotent via
ON CONFLICT DO NOTHING in the repo. Failures log a warning but
are non-fatal — the server still starts and the operator can
fix grants via /v1/auth/keys. Helper extracted from main.go so
the role-mapping invariant is pinned by 4 focused unit tests
(admin->r-admin, non-admin->r-viewer, empty no-op,
grant-error non-fatal, nil-logger safe).
M1: HealthHandler.AuthCheck now returns actor_id, actor_type,
tenant_id, roles, effective_permissions, and admin_via_role
when the optional AuthCheckResolver is wired (production path:
authCheckResolverAdapter wraps the postgres ActorRoleRepository
in main.go). Nil resolver preserves the legacy {status, user,
admin} contract for back-compat with pre-Bundle-1 GUIs and
test fixtures. Adds 2 regression tests + 1 fake resolver shim.
M2: refreshes the stale 'Admin gate: every method calls
auth.IsAdmin first' comment on IntermediateCAHandler — the gate
moved to router.go::rbacGate via auth.RequirePermission
middleware in Phase 3.5; the new comment block points readers
there.
M4: 11 RBAC routes (auth/me, auth/permissions, 5 role lifecycle,
2 role-permission grant/revoke, 2 actor-role grant/revoke) added
to api/openapi.yaml under the [Auth] tag with operationIds and
shared AuthRole / AuthRolePermission schemas. AuthCheck path
extended with the Bundle-1 enrichment fields. The 11 entries
removed from openapi_parity_test.go::SpecParityExceptions.
Tests: go vet + staticcheck + go test -short -count=1 green
across cmd/server/, internal/auth/, internal/api/router/, and
internal/api/handler/. New tests: 4 backfill unit tests,
2 AuthCheck M1 enrichment tests, 1 demo-mode + rbacGate chain
integration test (TestRBACGate_DemoModeChainReachesHandler).
Branch SECURITY.md (cowork/auth-bundle-1-SECURITY.md, not part
of this commit) captures the full posture of dev/auth-bundle-1
as of this closure for the operator's pre-merge review.
570 lines
19 KiB
Go
570 lines
19 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/auth"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
_ "github.com/lib/pq" // Bundle-5 / H-006: postgres driver for /ready DB-probe regression test
|
|
)
|
|
|
|
func TestHealth_ReturnsOK(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/health", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.Health(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("Health handler returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
// Check content type
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
// Check response body
|
|
var result map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["status"] != "healthy" {
|
|
t.Errorf("status = %q, want healthy", result["status"])
|
|
}
|
|
}
|
|
|
|
func TestHealth_MethodNotAllowed(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, "/health", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.Health(w, req)
|
|
|
|
if status := w.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("Health handler returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestReady_ReturnsOK(t *testing.T) {
|
|
// Bundle-5 / H-006: nil DB is the legacy/no-db deploy path; Ready degrades
|
|
// to 200 with {"db":"not_configured"} so existing test fixtures keep working.
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/ready", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.Ready(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("Ready handler returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
// Check content type
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
// Check response body
|
|
var result map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["status"] != "ready" {
|
|
t.Errorf("status = %q, want ready", result["status"])
|
|
}
|
|
if result["db"] != "not_configured" {
|
|
t.Errorf("db = %q, want not_configured", result["db"])
|
|
}
|
|
}
|
|
|
|
func TestReady_MethodNotAllowed(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodDelete, "/ready", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.Ready(w, req)
|
|
|
|
if status := w.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("Ready handler returned status %d, want %d", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestAuthInfo_ReturnsAuthType_APIKey(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/auth/info", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.AuthInfo(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("AuthInfo handler returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["auth_type"] != "api-key" {
|
|
t.Errorf("auth_type = %q, want api-key", result["auth_type"])
|
|
}
|
|
|
|
if required, ok := result["required"].(bool); !ok || !required {
|
|
t.Errorf("required = %v, want true", result["required"])
|
|
}
|
|
}
|
|
|
|
func TestAuthInfo_ReturnsAuthType_None(t *testing.T) {
|
|
handler := NewHealthHandler("none", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/auth/info", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.AuthInfo(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("AuthInfo handler returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["auth_type"] != "none" {
|
|
t.Errorf("auth_type = %q, want none", result["auth_type"])
|
|
}
|
|
|
|
if required, ok := result["required"].(bool); !ok || required {
|
|
t.Errorf("required = %v, want false", result["required"])
|
|
}
|
|
}
|
|
|
|
// G-1 (P1): the prior `TestAuthInfo_ReturnsAuthType_JWT` asserted the
|
|
// handler echoed "jwt" — using the silent-auth-downgrade value as a
|
|
// test fixture, which baked the lie into the regression suite. The
|
|
// test is removed because "jwt" is now rejected at config-load time
|
|
// (see internal/config/config_test.go::TestValidate_JWTAuth_RejectedDedicated)
|
|
// and never reaches this handler. The pre-existing
|
|
// `TestAuthInfo_ReturnsAuthType_APIKey` above (line ~107) covers the
|
|
// api-key happy path; nothing else needs replacing here.
|
|
|
|
func TestAuthCheck_ReturnsOK(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.AuthCheck(w, req)
|
|
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Errorf("AuthCheck handler returned status %d, want %d", status, http.StatusOK)
|
|
}
|
|
|
|
// Check content type
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
// Check response body — mixed-value map (string + bool) post-Phase B.4.
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["status"] != "authenticated" {
|
|
t.Errorf("status = %q, want authenticated", result["status"])
|
|
}
|
|
}
|
|
|
|
func TestAuthCheck_MethodNotAllowed(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req, err := http.NewRequest(http.MethodPost, "/api/v1/auth/check", nil)
|
|
if err != nil {
|
|
t.Fatalf("NewRequest failed: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.AuthCheck(w, req)
|
|
|
|
// AuthCheck doesn't explicitly check method, so it will return 200
|
|
// But let's verify the response is still correct
|
|
if status := w.Code; status != http.StatusOK {
|
|
t.Logf("AuthCheck returned status %d (note: method not enforced in handler)", status)
|
|
}
|
|
}
|
|
|
|
// --- M-003 (Phase B.4): /auth/check surfaces admin flag + user identity ---
|
|
|
|
// TestAuthCheck_AdminCaller_ReportsAdminTrue confirms that when the auth
|
|
// middleware sets AdminKey{}=true (i.e., named key was admin-tagged), the
|
|
// /auth/check endpoint reports admin=true so the GUI can show admin-only
|
|
// affordances.
|
|
func TestAuthCheck_AdminCaller_ReportsAdminTrue(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
|
|
ctx := context.WithValue(req.Context(), auth.AdminKey{}, true)
|
|
ctx = context.WithValue(ctx, auth.UserKey{}, "ops-admin")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.AuthCheck(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["status"] != "authenticated" {
|
|
t.Errorf("status = %q, want authenticated", result["status"])
|
|
}
|
|
admin, ok := result["admin"].(bool)
|
|
if !ok {
|
|
t.Fatalf("admin field missing or wrong type: %T", result["admin"])
|
|
}
|
|
if !admin {
|
|
t.Errorf("admin = false, want true")
|
|
}
|
|
if result["user"] != "ops-admin" {
|
|
t.Errorf("user = %q, want ops-admin", result["user"])
|
|
}
|
|
}
|
|
|
|
// TestAuthCheck_NonAdminCaller_ReportsAdminFalse pins the negative case: the
|
|
// auth middleware has stored AdminKey{}=false (non-admin named key) — the
|
|
// endpoint must report admin=false so the GUI hides admin-only affordances.
|
|
func TestAuthCheck_NonAdminCaller_ReportsAdminFalse(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
|
|
ctx := context.WithValue(req.Context(), auth.AdminKey{}, false)
|
|
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.AuthCheck(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
admin, ok := result["admin"].(bool)
|
|
if !ok {
|
|
t.Fatalf("admin field missing or wrong type: %T", result["admin"])
|
|
}
|
|
if admin {
|
|
t.Errorf("admin = true, want false")
|
|
}
|
|
if result["user"] != "alice" {
|
|
t.Errorf("user = %q, want alice", result["user"])
|
|
}
|
|
}
|
|
|
|
// TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin covers the
|
|
// CERTCTL_AUTH_TYPE=none deployment, where the auth middleware doesn't set
|
|
// any keys. Response must still be well-formed with empty user + admin=false.
|
|
func TestAuthCheck_NoAuthContext_DefaultsToEmptyUserAndFalseAdmin(t *testing.T) {
|
|
handler := NewHealthHandler("none", nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.AuthCheck(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if result["status"] != "authenticated" {
|
|
t.Errorf("status = %q, want authenticated", result["status"])
|
|
}
|
|
admin, ok := result["admin"].(bool)
|
|
if !ok {
|
|
t.Fatalf("admin field missing or wrong type: %T", result["admin"])
|
|
}
|
|
if admin {
|
|
t.Errorf("admin = true for no-auth context, want false")
|
|
}
|
|
if result["user"] != "" {
|
|
t.Errorf("user = %q, want empty string", result["user"])
|
|
}
|
|
}
|
|
|
|
// fakeAuthCheckResolver is a tiny in-memory stand-in for the postgres
|
|
// ActorRoleRepository so the M1 enrichment can be tested without a DB.
|
|
type fakeAuthCheckResolver struct {
|
|
roles []*authdomain.ActorRole
|
|
perms []repository.EffectivePermission
|
|
err error
|
|
}
|
|
|
|
func (f fakeAuthCheckResolver) ListRoles(_ context.Context, _ string, _ domain.ActorType, _ string) ([]*authdomain.ActorRole, error) {
|
|
return f.roles, f.err
|
|
}
|
|
func (f fakeAuthCheckResolver) EffectivePermissions(_ context.Context, _ string, _ domain.ActorType, _ string) ([]repository.EffectivePermission, error) {
|
|
return f.perms, f.err
|
|
}
|
|
|
|
// TestAuthCheck_M1_ResolverEnrichesResponseWithRolesAndPerms is the
|
|
// Bundle 1 Phase 3 closure (M1) regression: when HealthHandler.Resolver
|
|
// is wired, the response includes actor_id / actor_type / tenant_id /
|
|
// roles / effective_permissions / admin_via_role. The legacy `admin`
|
|
// boolean is preserved for back-compat with pre-Bundle-1 GUIs.
|
|
func TestAuthCheck_M1_ResolverEnrichesResponseWithRolesAndPerms(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
scopeID := "profile-prod"
|
|
handler.Resolver = fakeAuthCheckResolver{
|
|
roles: []*authdomain.ActorRole{
|
|
{ActorID: "alice", RoleID: authdomain.RoleIDAdmin, TenantID: authdomain.DefaultTenantID},
|
|
{ActorID: "alice", RoleID: authdomain.RoleIDOperator, TenantID: authdomain.DefaultTenantID},
|
|
},
|
|
perms: []repository.EffectivePermission{
|
|
{PermissionName: "cert.bulk_revoke", ScopeType: authdomain.ScopeTypeGlobal},
|
|
{PermissionName: "cert.issue", ScopeType: authdomain.ScopeTypeProfile, ScopeID: &scopeID},
|
|
},
|
|
}
|
|
|
|
ctx := context.Background()
|
|
ctx = context.WithValue(ctx, auth.ActorIDKey{}, "alice")
|
|
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey")
|
|
ctx = context.WithValue(ctx, auth.TenantIDKey{}, "t-default")
|
|
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
|
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.AuthCheck(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected status 200, got %d", w.Code)
|
|
}
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
|
|
if result["actor_id"] != "alice" {
|
|
t.Errorf("actor_id = %v, want alice", result["actor_id"])
|
|
}
|
|
if result["actor_type"] != "APIKey" {
|
|
t.Errorf("actor_type = %v, want APIKey", result["actor_type"])
|
|
}
|
|
if result["tenant_id"] != "t-default" {
|
|
t.Errorf("tenant_id = %v, want t-default", result["tenant_id"])
|
|
}
|
|
if result["admin_via_role"] != true {
|
|
t.Errorf("admin_via_role = %v, want true (alice holds r-admin)", result["admin_via_role"])
|
|
}
|
|
roles, ok := result["roles"].([]any)
|
|
if !ok || len(roles) != 2 {
|
|
t.Fatalf("roles = %v, want 2-element slice", result["roles"])
|
|
}
|
|
perms, ok := result["effective_permissions"].([]any)
|
|
if !ok || len(perms) != 2 {
|
|
t.Fatalf("effective_permissions = %v, want 2-element slice", result["effective_permissions"])
|
|
}
|
|
first := perms[0].(map[string]any)
|
|
if first["permission"] != "cert.bulk_revoke" || first["scope_type"] != "global" {
|
|
t.Errorf("perm[0] = %v, want cert.bulk_revoke/global", first)
|
|
}
|
|
second := perms[1].(map[string]any)
|
|
if second["permission"] != "cert.issue" || second["scope_type"] != "profile" || second["scope_id"] != "profile-prod" {
|
|
t.Errorf("perm[1] = %v, want cert.issue/profile/profile-prod", second)
|
|
}
|
|
}
|
|
|
|
// TestAuthCheck_M1_NilResolverPreservesLegacyShape pins backwards
|
|
// compatibility: when no resolver is wired, the response keeps the
|
|
// original {status, user, admin} contract that pre-Bundle-1 GUIs key
|
|
// off. New keys (actor_id, roles, ...) must be absent.
|
|
func TestAuthCheck_M1_NilResolverPreservesLegacyShape(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil) // Resolver left nil
|
|
|
|
ctx := context.Background()
|
|
ctx = context.WithValue(ctx, auth.ActorIDKey{}, "alice")
|
|
ctx = context.WithValue(ctx, auth.ActorTypeKey{}, "APIKey")
|
|
ctx = context.WithValue(ctx, auth.UserKey{}, "alice")
|
|
ctx = context.WithValue(ctx, auth.AdminKey{}, true)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/check", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.AuthCheck(w, req)
|
|
|
|
var result map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
for _, k := range []string{"actor_id", "actor_type", "tenant_id", "roles", "effective_permissions", "admin_via_role"} {
|
|
if _, present := result[k]; present {
|
|
t.Errorf("%s should be absent in legacy (nil resolver) response, got %v", k, result[k])
|
|
}
|
|
}
|
|
if result["admin"] != true || result["user"] != "alice" {
|
|
t.Errorf("legacy fields not preserved: admin=%v user=%v", result["admin"], result["user"])
|
|
}
|
|
}
|
|
|
|
// --- Bundle-5 / H-006: /ready DB-probe regression coverage ---
|
|
|
|
// TestReady_DBPingSuccess_Returns200WithReachable confirms that when the
|
|
// injected *sql.DB ping succeeds, /ready surfaces 200 + db=reachable.
|
|
//
|
|
// We use sqlmock-equivalent technique: open a sql.DB against the sqlite-in-mem
|
|
// driver via sql.Open("sqlite-not-real", ":memory:")? No — simpler: use
|
|
// the standard library's sql.OpenDB with a custom Connector. To keep this
|
|
// test stdlib-only and offline, we use sql.Open with the real Postgres driver
|
|
// against an unreachable address and assert 503; for the success path we
|
|
// accept that the integration test under //go:build integration covers it.
|
|
// For Bundle-5 unit coverage, the no-op-DB and unreachable-DB paths are the
|
|
// pinnable contract.
|
|
func TestReady_DBPingSuccess_PassthroughViaTimeout(t *testing.T) {
|
|
// This test exercises the timeout-clamp path: a stub *sql.DB whose
|
|
// PingContext blocks forever, with a 50ms ReadyProbeTimeout, MUST return
|
|
// 503 db_unavailable within the timeout window — proving the
|
|
// context.WithTimeout clamp is honoured.
|
|
//
|
|
// We simulate "blocking forever" by giving the handler a very short
|
|
// timeout and a DB whose ping will fail fast (using lib/pq against a
|
|
// closed loopback port, which produces a "connection refused" — same
|
|
// 503 codepath).
|
|
t.Skip("integration-style test; covered by deploy/test/integration_test.go (//go:build integration). " +
|
|
"Unit-test path covers nil-DB + ping-failure shapes below.")
|
|
}
|
|
|
|
// TestReady_DBPingFailure_Returns503 confirms that when the injected DB's
|
|
// PingContext returns an error, /ready surfaces 503 + db_unavailable + the
|
|
// (sanitized) error string. This is the load-bearing readiness signal for
|
|
// k8s — drains traffic so users don't hit a broken instance.
|
|
func TestReady_DBPingFailure_Returns503(t *testing.T) {
|
|
// Unreachable Postgres URL — connect attempt fails fast with
|
|
// "connection refused" (or DNS error in CI). We don't run the full
|
|
// handshake; we just require PingContext to return SOME error inside
|
|
// the configured timeout.
|
|
//
|
|
// Open lazily via sql.Open (no immediate connect); PingContext is what
|
|
// triggers the actual TCP attempt.
|
|
db, err := sql.Open("postgres", "postgres://127.0.0.1:1/nonexistent?sslmode=disable&connect_timeout=1")
|
|
if err != nil {
|
|
t.Skipf("postgres driver unavailable in this build: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
handler := NewHealthHandler("api-key", db)
|
|
handler.ReadyProbeTimeout = 200 * time.Millisecond
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ready", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.Ready(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("Ready handler returned %d, want %d", w.Code, http.StatusServiceUnavailable)
|
|
}
|
|
|
|
var result map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
if result["status"] != "db_unavailable" {
|
|
t.Errorf("status = %q, want db_unavailable", result["status"])
|
|
}
|
|
if result["error"] == "" {
|
|
t.Errorf("error field empty; expected sanitized DB-error string")
|
|
}
|
|
}
|
|
|
|
// TestReady_NilDB_Returns200NotConfigured pins the "no-DB-wired" degraded
|
|
// path — used by integration test fixtures that don't spin a Postgres pool.
|
|
// /ready stays 200 + db=not_configured so probes still succeed.
|
|
func TestReady_NilDB_Returns200NotConfigured(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/ready", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.Ready(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Ready handler returned %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
var result map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode: %v", err)
|
|
}
|
|
if result["status"] != "ready" {
|
|
t.Errorf("status = %q, want ready", result["status"])
|
|
}
|
|
if result["db"] != "not_configured" {
|
|
t.Errorf("db = %q, want not_configured", result["db"])
|
|
}
|
|
}
|
|
|
|
// TestHealth_NilDB_Returns200 pins the contract: /health stays shallow even
|
|
// with no DB pool wired. k8s liveness probe must NOT restart pods for DB
|
|
// hiccups — that's readiness's job.
|
|
func TestHealth_NilDB_Returns200(t *testing.T) {
|
|
handler := NewHealthHandler("api-key", nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.Health(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("Health handler returned %d, want %d", w.Code, http.StatusOK)
|
|
}
|
|
var result map[string]string
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode: %v", err)
|
|
}
|
|
if result["status"] != "healthy" {
|
|
t.Errorf("status = %q, want healthy", result["status"])
|
|
}
|
|
}
|