mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:01:36 +00:00
80cbd2db59
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.
317 lines
12 KiB
Go
317 lines
12 KiB
Go
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)
|
|
}
|
|
}
|