test(coverage): backfill 5 packages to clear v2.1.0 release-gate Phase 3 floors

Phase 3 of /Users/shankar/Desktop/cowork/v2.1.0-release-gate.md surfaced
four packages below their coverage floors. All four are regressions from
new code shipped in the audit-2026-05-10/11 fix bundles that didn't get
per-function tests:

  internal/auth/breakglass    87.5% -> 93.3% (floor: 90%)
    + List (was 0%) — 3 tests (disabled, empty+populated, repo err)
    + RemoveCredential, Unlock disabled-branch tests

  internal/auth/oidc          89.4% -> 95.4% (floor: 90%)
    + JWKSStatus (was 0%) — 2 tests (unknown provider, after AuthRequest)
    + TestDiscovery (was 0%) — 5 tests (discovery failure, happy path,
      HS256 alg-downgrade detected, missing jwks_uri, JWKS 500 fetch)

  internal/auth/session       89.9% -> 94.4% (floor: 90%)
    + SetTrustedProxies (was 0%) — round-trip + clear
    + ComputeCookieHMAC (was 0%) — determinism + key/inputs differ
    + DecryptKeyMaterial (was 0%) — round-trip + wrong-passphrase

  internal/api/handler        73.2% -> 75.5% (floor: 75%)
    + 6 auth_breakglass handler funcs (were all 0%) — 14 tests
      (disabled/404, invalid JSON, empty fields, service err, happy
      path with cookies, admin endpoints, ListCredentials no
      password_hash on the wire)
    + WithPermissionChecker setter test (was 0%, Bundle 2 MED-2)
    + NewAdminCRLCacheServiceImpl + CacheRows (were 0%) — 3 tests
    + itoaForRetryAfter + challengeURLBuilder ACME helpers (were 0%) —
      4 tests

All five coverage gates green:

  internal/service                                    72.7% (floor: 70%)
  internal/api/handler                                75.5% (floor: 75%)
  internal/api/middleware                             67.9% (floor: 30%)
  internal/auth                                       93.3% (floor: 85%)
  internal/service/auth                               91.8% (floor: 85%)
  internal/auth/oidc                                  95.4% (floor: 90%)
  internal/auth/oidc/groupclaim                      100.0% (floor: 95%)
  internal/auth/oidc/domain                           97.6% (floor: 90%)
  internal/auth/session                               94.4% (floor: 90%)
  internal/auth/session/domain                        98.3% (floor: 90%)
  internal/auth/breakglass                            93.3% (floor: 90%)
  internal/auth/breakglass/domain                    100.0% (floor: 90%)
  internal/auth/user/domain                           96.2% (floor: 90%)
  (and 6 more — all green)

Per CLAUDE.md operating rule: 'Lowering a floor REQUIRES corresponding
code-side test work — never lower the gate to make CI green.' The
floors stay at their committed values; the new tests close the gap.
This commit is contained in:
shankar0123
2026-05-11 14:12:11 +00:00
parent 8aeeec93c0
commit 80cbd2db59
5 changed files with 956 additions and 0 deletions
@@ -0,0 +1,316 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/certctl-io/certctl/internal/auth/breakglass"
bgdomain "github.com/certctl-io/certctl/internal/auth/breakglass/domain"
)
// Coverage fill — v2.1.0 release gate Phase 3.
//
// Handler-level tests for the Phase 7.5 break-glass HTTP surface.
// Bundle 2 originally shipped these endpoints with service-level
// tests only; the 6 0%-handler functions dragged the internal/api/
// handler average below its 75 floor. This file backfills the
// canonical positive + negative cases at the handler layer.
// =============================================================================
// Fake BreakglassService.
// =============================================================================
type fakeBreakglassSvc struct {
enabled bool
// Per-method return shapes. Tests set the field they care about.
setPasswordRes *breakglass.SetPasswordResult
setPasswordErr error
authRes *breakglass.AuthenticateResult
authErr error
unlockErr error
removeErr error
listOut []*bgdomain.BreakglassCredential
listErr error
// Captured args (for assertions).
gotSetCaller, gotSetTarget, gotSetPass string
gotAuthActor, gotAuthPass, gotAuthIP, gotAuthUA string
gotUnlockCaller, gotUnlockTarget string
gotRemoveCaller, gotRemoveTarget string
}
func (f *fakeBreakglassSvc) Enabled() bool { return f.enabled }
func (f *fakeBreakglassSvc) SetPassword(ctx context.Context, caller, target, pw string) (*breakglass.SetPasswordResult, error) {
f.gotSetCaller, f.gotSetTarget, f.gotSetPass = caller, target, pw
return f.setPasswordRes, f.setPasswordErr
}
func (f *fakeBreakglassSvc) Authenticate(ctx context.Context, actor, pw, ip, ua string) (*breakglass.AuthenticateResult, error) {
f.gotAuthActor, f.gotAuthPass, f.gotAuthIP, f.gotAuthUA = actor, pw, ip, ua
return f.authRes, f.authErr
}
func (f *fakeBreakglassSvc) Unlock(ctx context.Context, caller, target string) error {
f.gotUnlockCaller, f.gotUnlockTarget = caller, target
return f.unlockErr
}
func (f *fakeBreakglassSvc) RemoveCredential(ctx context.Context, caller, target string) error {
f.gotRemoveCaller, f.gotRemoveTarget = caller, target
return f.removeErr
}
func (f *fakeBreakglassSvc) List(ctx context.Context) ([]*bgdomain.BreakglassCredential, error) {
return f.listOut, f.listErr
}
func newBreakglassHandlerWithFake(t *testing.T, enabled bool) (*AuthBreakglassHandler, *fakeBreakglassSvc) {
t.Helper()
svc := &fakeBreakglassSvc{enabled: enabled}
attrs := SessionCookieAttrs{Secure: true, SameSite: http.SameSiteLaxMode}
return NewAuthBreakglassHandler(svc, attrs), svc
}
// =============================================================================
// 1. Public login endpoint.
// =============================================================================
func TestBreakglassLogin_DisabledReturns404(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, false /* disabled */)
body := bytes.NewBufferString(`{"actor_id":"alice","password":"hunter2!!"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/breakglass/login", body)
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("disabled service must yield 404 (surface invisibility); got %d", rec.Code)
}
}
func TestBreakglassLogin_InvalidJSONReturns401(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, true)
req := httptest.NewRequest(http.MethodPost, "/auth/breakglass/login", bytes.NewBufferString("not-json"))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("invalid JSON must map to 401 (NOT 400); got %d", rec.Code)
}
}
func TestBreakglassLogin_EmptyFieldsReturns401(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, true)
req := httptest.NewRequest(http.MethodPost, "/auth/breakglass/login", bytes.NewBufferString(`{"actor_id":"","password":""}`))
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("empty actor/password must map to 401; got %d", rec.Code)
}
}
func TestBreakglassLogin_ServiceErrorReturns401(t *testing.T) {
h, svc := newBreakglassHandlerWithFake(t, true)
svc.authErr = errors.New("locked")
body := bytes.NewBufferString(`{"actor_id":"alice","password":"wrong"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/breakglass/login", body)
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("auth error must map to 401; got %d", rec.Code)
}
if svc.gotAuthActor != "alice" {
t.Errorf("expected actor=alice; got %q", svc.gotAuthActor)
}
}
func TestBreakglassLogin_SuccessSetsCookies(t *testing.T) {
h, svc := newBreakglassHandlerWithFake(t, true)
svc.authRes = &breakglass.AuthenticateResult{CookieValue: "ses-1.abc", CSRFToken: "csrf-xyz"}
body := bytes.NewBufferString(`{"actor_id":"alice","password":"hunter2!!"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/breakglass/login", body)
rec := httptest.NewRecorder()
h.Login(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("expected 204; got %d (body=%s)", rec.Code, rec.Body.String())
}
res := rec.Result()
defer res.Body.Close()
gotSession, gotCSRF := false, false
for _, c := range res.Cookies() {
if strings.Contains(c.Name, "session") || strings.Contains(c.Name, "Session") {
gotSession = true
}
if strings.Contains(c.Name, "csrf") || strings.Contains(c.Name, "CSRF") {
gotCSRF = true
}
}
if !gotSession {
t.Errorf("expected session cookie")
}
if !gotCSRF {
t.Errorf("expected CSRF cookie")
}
}
// =============================================================================
// 2. Admin endpoints — no caller context = 401.
// =============================================================================
func TestBreakglassSetPassword_NoCallerReturns401(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, true)
body := bytes.NewBufferString(`{"actor_id":"alice","password":"StrongPW123!"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/breakglass/credentials", body)
rec := httptest.NewRecorder()
h.SetPassword(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("missing actor ctx must yield 401; got %d", rec.Code)
}
}
func TestBreakglassSetPassword_DisabledReturns404(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, false)
body := bytes.NewBufferString(`{"actor_id":"alice","password":"StrongPW123!"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/breakglass/credentials", body)
req = withAuthCtx(req, "admin", "User")
rec := httptest.NewRecorder()
h.SetPassword(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("disabled must yield 404; got %d", rec.Code)
}
}
func TestBreakglassSetPassword_InvalidJSONReturns400(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, true)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/breakglass/credentials", bytes.NewBufferString("nope"))
req = withAuthCtx(req, "admin", "User")
rec := httptest.NewRecorder()
h.SetPassword(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("invalid JSON must map to 400 on admin endpoint; got %d", rec.Code)
}
}
func TestBreakglassSetPassword_HappyPath(t *testing.T) {
h, svc := newBreakglassHandlerWithFake(t, true)
svc.setPasswordRes = &breakglass.SetPasswordResult{}
body := bytes.NewBufferString(`{"actor_id":"alice","password":"StrongPW123!"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/breakglass/credentials", body)
req = withAuthCtx(req, "admin", "User")
rec := httptest.NewRecorder()
h.SetPassword(rec, req)
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK && rec.Code != http.StatusNoContent {
t.Errorf("expected 2xx; got %d (body=%s)", rec.Code, rec.Body.String())
}
if svc.gotSetTarget != "alice" {
t.Errorf("expected target=alice; got %q", svc.gotSetTarget)
}
if svc.gotSetCaller != "admin" {
t.Errorf("expected caller=admin; got %q", svc.gotSetCaller)
}
}
func TestBreakglassUnlock_DisabledReturns404(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, false)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/breakglass/credentials/alice/unlock", nil)
req = withAuthCtx(req, "admin", "User")
rec := httptest.NewRecorder()
h.Unlock(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("disabled must yield 404; got %d", rec.Code)
}
}
func TestBreakglassUnlock_NoActorReturns401(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, true)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/breakglass/credentials/alice/unlock", nil)
rec := httptest.NewRecorder()
h.Unlock(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("missing actor ctx must yield 401; got %d", rec.Code)
}
}
func TestBreakglassRemove_DisabledReturns404(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, false)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/breakglass/credentials/alice", nil)
req = withAuthCtx(req, "admin", "User")
rec := httptest.NewRecorder()
h.Remove(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("disabled must yield 404; got %d", rec.Code)
}
}
func TestBreakglassRemove_NoActorReturns401(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, true)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/breakglass/credentials/alice", nil)
rec := httptest.NewRecorder()
h.Remove(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("missing actor ctx must yield 401; got %d", rec.Code)
}
}
// ListCredentials surfaces the read side.
func TestBreakglassListCredentials_DisabledReturns404(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, false)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/breakglass/credentials", nil)
req = withAuthCtx(req, "admin", "User")
rec := httptest.NewRecorder()
h.ListCredentials(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("disabled must yield 404; got %d", rec.Code)
}
}
// ListCredentials does not re-check the actor context — the auth
// gate sits at the router/middleware layer via rbacGate. So a missing
// actor ctx here just means the test fixture wasn't authenticated;
// the handler itself returns 200 with the body content. The test
// pins this contract so a future refactor that adds a handler-level
// actor check will trip this case.
func TestBreakglassListCredentials_NoActorCtxStillReturns200(t *testing.T) {
h, _ := newBreakglassHandlerWithFake(t, true)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/breakglass/credentials", nil)
rec := httptest.NewRecorder()
h.ListCredentials(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("handler-only path returns 200 (router rbacGate is the auth gate); got %d", rec.Code)
}
}
func TestBreakglassListCredentials_HappyPath(t *testing.T) {
h, svc := newBreakglassHandlerWithFake(t, true)
svc.listOut = []*bgdomain.BreakglassCredential{
{ActorID: "alice", TenantID: "t-default"},
{ActorID: "bob", TenantID: "t-default"},
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/breakglass/credentials", nil)
req = withAuthCtx(req, "admin", "User")
rec := httptest.NewRecorder()
h.ListCredentials(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("expected 200; got %d (body=%s)", rec.Code, rec.Body.String())
}
// Body should be JSON with both actors. We don't assume the exact
// envelope shape; just check the names appear and the password
// hashes are NOT present in the wire response.
body := rec.Body.String()
if !strings.Contains(body, "alice") || !strings.Contains(body, "bob") {
t.Errorf("expected both actors in body; got: %s", body)
}
// The PasswordHash field carries json:"-" so the encoded value
// must NEVER contain the hash. The field name "password_hash" or
// any Argon2id PHC prefix is the signal.
if strings.Contains(body, "password_hash") || strings.Contains(body, "$argon2") {
t.Errorf("password hashes must NOT appear in wire response; got: %s", body)
}
// Defensive — confirm it's valid JSON.
var anyResp interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &anyResp); err != nil {
t.Errorf("response body must be valid JSON: %v", err)
}
}
+170
View File
@@ -0,0 +1,170 @@
package handler
import (
"context"
"errors"
"net/http/httptest"
"strings"
"testing"
"github.com/certctl-io/certctl/internal/domain"
)
// Coverage fill — v2.1.0 release gate Phase 3.
//
// A handful of constructor + setter + small-method functions added in
// recent fix bundles shipped without tests. The package-average
// floor (75%) trips because each 0%-function drags the script's
// per-function average down. The tests below cover the easy ones to
// lift the average back across.
// =============================================================================
// auth_session_oidc.go — WithPermissionChecker setter (added in MED-2).
// =============================================================================
type fakeOIDCPermChecker struct{}
func (f *fakeOIDCPermChecker) CheckPermission(_ context.Context, _, _, _, _, _ string, _ *string) (bool, error) {
return true, nil
}
func TestAuthSessionOIDCHandler_WithPermissionChecker_ReturnsSelfAndSetsField(t *testing.T) {
h := &AuthSessionOIDCHandler{}
got := h.WithPermissionChecker(&fakeOIDCPermChecker{})
if got != h {
t.Errorf("WithPermissionChecker must return receiver for chaining; got %p, want %p", got, h)
}
if h.checker == nil {
t.Errorf("WithPermissionChecker must install the checker; got nil")
}
}
// =============================================================================
// admin_crl_cache.go — NewAdminCRLCacheServiceImpl + CacheRows (added by
// the CRL-cache admin panel; never had handler-layer tests).
// =============================================================================
type fakeCRLCacheRepo struct {
getErr error
}
func (f *fakeCRLCacheRepo) Get(_ context.Context, _ string) (*domain.CRLCacheEntry, error) {
return nil, f.getErr
}
func (f *fakeCRLCacheRepo) Put(_ context.Context, _ *domain.CRLCacheEntry) error {
return nil
}
func (f *fakeCRLCacheRepo) NextCRLNumber(_ context.Context, _ string) (int64, error) {
return 1, nil
}
func (f *fakeCRLCacheRepo) RecordGenerationEvent(_ context.Context, _ *domain.CRLGenerationEvent) error {
return nil
}
func (f *fakeCRLCacheRepo) ListGenerationEvents(_ context.Context, _ string, _ int) ([]*domain.CRLGenerationEvent, error) {
return nil, nil
}
func TestNewAdminCRLCacheServiceImpl_ConstructsWithDefaults(t *testing.T) {
repo := &fakeCRLCacheRepo{}
idsFn := func() []string { return []string{"iss-1", "iss-2"} }
svc := NewAdminCRLCacheServiceImpl(repo, idsFn)
if svc == nil {
t.Fatalf("NewAdminCRLCacheServiceImpl returned nil")
}
if svc.cacheRepo == nil || svc.issuerIDs == nil || svc.now == nil {
t.Errorf("constructor must wire all fields; got cacheRepo=%v issuerIDs!=nil=%v now!=nil=%v",
svc.cacheRepo, svc.issuerIDs != nil, svc.now != nil)
}
if svc.eventLimit != 5 {
t.Errorf("expected default eventLimit=5; got %d", svc.eventLimit)
}
}
func TestAdminCRLCacheServiceImpl_CacheRows_EmptyIssuerListYieldsEmptyResult(t *testing.T) {
svc := NewAdminCRLCacheServiceImpl(&fakeCRLCacheRepo{}, func() []string { return nil })
rows, err := svc.CacheRows(context.Background())
if err != nil {
t.Fatalf("CacheRows on empty issuer list: %v", err)
}
if len(rows) != 0 {
t.Errorf("expected 0 rows for empty issuer list; got %d", len(rows))
}
}
// =============================================================================
// acme.go small helpers — itoaForRetryAfter + challengeURLBuilder.
// These are pure-helper functions added to the ACME surface; tested
// here to lift the package-average over the 75 floor.
// =============================================================================
func TestItoaForRetryAfter(t *testing.T) {
cases := []struct {
in int
want string
}{
{0, "0"},
{1, "1"},
{42, "42"},
{-5, "-5"},
{12345, "12345"},
}
for _, c := range cases {
got := itoaForRetryAfter(c.in)
if got != c.want {
t.Errorf("itoaForRetryAfter(%d) = %q, want %q", c.in, got, c.want)
}
}
}
func TestChallengeURLBuilder_ProfilePrefixAndHTTPS(t *testing.T) {
req := httptest.NewRequest("GET", "https://certctl.local/acme/profile/p1/order", nil)
req.TLS = nil // simulate HTTP
req.Host = "x" // override
h := ACMEHandler{}
build := h.challengeURLBuilder(req, "p1")
got := build("chal-abc")
if !strings.HasPrefix(got, "http://x/acme/profile/p1/challenge/") {
t.Errorf("unexpected URL: %q", got)
}
if !strings.HasSuffix(got, "/chal-abc") {
t.Errorf("unexpected URL suffix: %q", got)
}
}
func TestChallengeURLBuilder_NoProfileFallsBackToShortPath(t *testing.T) {
req := httptest.NewRequest("GET", "http://certctl.local/acme/order", nil)
req.Host = "y"
h := ACMEHandler{}
build := h.challengeURLBuilder(req, "")
got := build("chal-1")
if !strings.Contains(got, "/acme/challenge/chal-1") {
t.Errorf("expected /acme/challenge/chal-1 fallback; got %q", got)
}
if strings.Contains(got, "/profile/") {
t.Errorf("must NOT contain /profile/ when profileID is empty; got %q", got)
}
}
func TestAdminCRLCacheServiceImpl_CacheRows_PerIssuerErrorSurfacesAsEvent(t *testing.T) {
svc := NewAdminCRLCacheServiceImpl(
&fakeCRLCacheRepo{getErr: errors.New("lookup failed")},
func() []string { return []string{"iss-broken"} },
)
rows, err := svc.CacheRows(context.Background())
if err != nil {
t.Fatalf("CacheRows must NOT short-circuit on per-issuer failure: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row; got %d", len(rows))
}
if rows[0].IssuerID != "iss-broken" {
t.Errorf("expected issuer-id passthrough; got %q", rows[0].IssuerID)
}
if len(rows[0].RecentEvents) == 0 {
t.Fatalf("expected at least 1 RecentEvent for the lookup failure")
}
ev := rows[0].RecentEvents[0]
if ev.Succeeded {
t.Errorf("expected Succeeded=false on lookup failure")
}
}