diff --git a/internal/api/handler/auth_breakglass_test.go b/internal/api/handler/auth_breakglass_test.go new file mode 100644 index 0000000..0184ffb --- /dev/null +++ b/internal/api/handler/auth_breakglass_test.go @@ -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) + } +} diff --git a/internal/api/handler/coverage_fill_test.go b/internal/api/handler/coverage_fill_test.go new file mode 100644 index 0000000..b5ef716 --- /dev/null +++ b/internal/api/handler/coverage_fill_test.go @@ -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") + } +} diff --git a/internal/auth/breakglass/coverage_fill_test.go b/internal/auth/breakglass/coverage_fill_test.go new file mode 100644 index 0000000..face92c --- /dev/null +++ b/internal/auth/breakglass/coverage_fill_test.go @@ -0,0 +1,137 @@ +package breakglass + +import ( + "context" + "errors" + "testing" + + bgdomain "github.com/certctl-io/certctl/internal/auth/breakglass/domain" +) + +// Coverage fill — v2.1.0 release gate Phase 3. +// +// Targets: +// +// - Service.List — was 0% pre-fill (added at Phase 7.5 of Bundle 2 +// for the admin "list break-glass actors" surface). Exercises the +// ErrDisabled fail-closed branch + the repo-error wrap + the +// happy path. +// - Service.RemoveCredential repo-error branch. +// - Service.Unlock repo-error branch. +// +// These are the smallest additions that lift the package back across +// the 90 % per-package floor for the v2.1.0 release gate. + +func TestService_List_DisabledReturnsErrDisabled(t *testing.T) { + svc, _, _, _ := newSvc(t, false /* enabled */) + got, err := svc.List(context.Background()) + if !errors.Is(err, ErrDisabled) { + t.Fatalf("expected ErrDisabled when disabled, got %v", err) + } + if got != nil { + t.Errorf("expected nil slice when disabled, got %v", got) + } +} + +func TestService_List_Enabled_EmptyAndPopulated(t *testing.T) { + svc, repo, _, _ := newSvc(t, true /* enabled */) + + // Empty case. + got, err := svc.List(context.Background()) + if err != nil { + t.Fatalf("List (empty): %v", err) + } + if len(got) != 0 { + t.Errorf("expected 0 rows, got %d", len(got)) + } + + // Seed two rows via SetPassword (which exercises the repo Create + // path); List then returns both. Order is repo-defined. + if _, err := svc.SetPassword(context.Background(), "u-admin", "alice", "StrongPW123!"); err != nil { + t.Fatalf("SetPassword alice: %v", err) + } + if _, err := svc.SetPassword(context.Background(), "u-admin", "bob", "StrongPW123!"); err != nil { + t.Fatalf("SetPassword bob: %v", err) + } + got, err = svc.List(context.Background()) + if err != nil { + t.Fatalf("List (populated): %v", err) + } + if len(got) != 2 { + t.Errorf("expected 2 rows, got %d", len(got)) + } + // Sanity-check: rows must carry the persisted ActorIDs. + have := map[string]bool{} + for _, r := range got { + have[r.ActorID] = true + } + if !have["alice"] || !have["bob"] { + t.Errorf("expected both 'alice' and 'bob' in list; got actor IDs %v", have) + } + _ = repo +} + +// TestService_List_RepoErrorWraps verifies the err-wrap branch by +// forcing a stub repo to return an error from List. +func TestService_List_RepoErrorWraps(t *testing.T) { + svc, repo, _, _ := newSvc(t, true /* enabled */) + // Inject a List-failing stub by replacing the repo's behavior; + // stubRepo's List doesn't have an injectable error, so use a + // minimal local wrapper. + wrapped := &listErrRepo{inner: repo, err: errors.New("boom")} + svc.repo = wrapped + + got, err := svc.List(context.Background()) + if err == nil { + t.Fatalf("expected wrap error, got nil") + } + if got != nil { + t.Errorf("expected nil rows on err, got %v", got) + } +} + +// listErrRepo wraps stubRepo and returns a configured error from List. +type listErrRepo struct { + inner *stubRepo + err error +} + +func (r *listErrRepo) Create(ctx context.Context, c *bgdomain.BreakglassCredential) error { + return r.inner.Create(ctx, c) +} +func (r *listErrRepo) GetByActor(ctx context.Context, actorID, tenantID string) (*bgdomain.BreakglassCredential, error) { + return r.inner.GetByActor(ctx, actorID, tenantID) +} +func (r *listErrRepo) UpdatePasswordHash(ctx context.Context, actorID, tenantID, newHash string) error { + return r.inner.UpdatePasswordHash(ctx, actorID, tenantID, newHash) +} +func (r *listErrRepo) IncrementFailure(ctx context.Context, actorID, tenantID string, threshold, durationSec int) (*bgdomain.BreakglassCredential, error) { + return r.inner.IncrementFailure(ctx, actorID, tenantID, threshold, durationSec) +} +func (r *listErrRepo) ResetFailureCount(ctx context.Context, actorID, tenantID string) error { + return r.inner.ResetFailureCount(ctx, actorID, tenantID) +} +func (r *listErrRepo) Delete(ctx context.Context, actorID, tenantID string) error { + return r.inner.Delete(ctx, actorID, tenantID) +} +func (r *listErrRepo) List(_ context.Context, _ string) ([]*bgdomain.BreakglassCredential, error) { + return nil, r.err +} + +// TestService_RemoveCredential_DisabledReturnsErrDisabled exercises +// the fail-closed branch in RemoveCredential (previously uncovered). +func TestService_RemoveCredential_DisabledReturnsErrDisabled(t *testing.T) { + svc, _, _, _ := newSvc(t, false /* enabled */) + if err := svc.RemoveCredential(context.Background(), "u-admin", "alice"); !errors.Is(err, ErrDisabled) { + t.Errorf("expected ErrDisabled, got %v", err) + } +} + +// TestService_Unlock_DisabledReturnsErrDisabled exercises the +// fail-closed branch in Unlock (previously uncovered). +func TestService_Unlock_DisabledReturnsErrDisabled(t *testing.T) { + svc, _, _, _ := newSvc(t, false /* enabled */) + if err := svc.Unlock(context.Background(), "u-admin", "alice"); !errors.Is(err, ErrDisabled) { + t.Errorf("expected ErrDisabled, got %v", err) + } +} diff --git a/internal/auth/oidc/coverage_fill_test.go b/internal/auth/oidc/coverage_fill_test.go new file mode 100644 index 0000000..4ebbe55 --- /dev/null +++ b/internal/auth/oidc/coverage_fill_test.go @@ -0,0 +1,244 @@ +package oidc + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Coverage fill — v2.1.0 release gate Phase 3. +// +// Targets two service-level functions added by post-merge fixes that +// shipped without unit tests: +// +// - Service.JWKSStatus — added in audit 2026-05-10 MED-7 closure +// (per-provider JWKS counters + cache state). +// - Service.TestDiscovery — added in audit 2026-05-10 MED-5 closure +// (dry-run /api/v1/auth/oidc/test endpoint). + +// TestJWKSStatus_ReturnsLoadError_WhenProviderUnknown asserts that +// JWKSStatus forwards the getOrLoad error verbatim when the requested +// providerID is not in the repo. This is the entry-point fail-closed +// branch. +func TestJWKSStatus_ReturnsLoadError_WhenProviderUnknown(t *testing.T) { + svc := newServiceForUnitTest(t) + snap, err := svc.JWKSStatus(context.Background(), "rp-does-not-exist") + if err == nil { + t.Fatalf("expected error for unknown provider, got nil") + } + if snap != nil { + t.Errorf("expected nil snapshot on error, got %+v", snap) + } +} + +// TestJWKSStatus_ReturnsSnapshot_AfterAuthRequestPopulatesEntry pre- +// warms the provider cache via HandleAuthRequest (which calls +// getOrLoad → populates s.cache) and then asserts JWKSStatus returns +// a non-nil snapshot reflecting the entry's stats. +func TestJWKSStatus_ReturnsSnapshot_AfterAuthRequestPopulatesEntry(t *testing.T) { + idp := newMockIdP(t) + svc, _ := newServiceWithProviderAndPL(t, idp.URL(), "rp-jwks-status") + // Pre-warm the cache. + if _, _, _, err := svc.HandleAuthRequest(context.Background(), "rp-jwks-status", "10.0.0.1", "test/1.0"); err != nil { + t.Fatalf("HandleAuthRequest: %v", err) + } + snap, err := svc.JWKSStatus(context.Background(), "rp-jwks-status") + if err != nil { + t.Fatalf("JWKSStatus: %v", err) + } + if snap == nil { + t.Fatalf("expected non-nil snapshot") + } + // CurrentKIDs is intentionally empty (go-oidc doesn't expose its + // JWKS cache). Test the shape rather than the kids. + if snap.CurrentKIDs == nil { + t.Errorf("CurrentKIDs must be non-nil (empty slice OK)") + } +} + +// TestTestDiscovery_DiscoveryFailure_ReturnsErrorsSlice points +// TestDiscovery at a URL that doesn't serve a discovery doc; the +// function MUST return res with DiscoverySucceeded=false and a +// non-empty Errors slice, and a nil err (per the documented "non- +// fatal at this layer; per-leg failure carried in res.Errors" +// contract). +func TestTestDiscovery_DiscoveryFailure_ReturnsErrorsSlice(t *testing.T) { + svc := newServiceForUnitTest(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + res, err := svc.TestDiscovery(context.Background(), srv.URL) + if err != nil { + t.Fatalf("TestDiscovery (non-fatal): %v", err) + } + if res == nil { + t.Fatalf("expected non-nil result") + } + if res.DiscoverySucceeded { + t.Errorf("expected DiscoverySucceeded=false when discovery doc is missing") + } + if len(res.Errors) == 0 { + t.Errorf("expected non-empty Errors slice") + } + if !strings.Contains(strings.Join(res.Errors, "|"), "discovery fetch failed") { + t.Errorf("expected 'discovery fetch failed' in errors; got %v", res.Errors) + } +} + +// TestTestDiscovery_HappyPath_AgainstMockIdP exercises the +// success path: discovery doc fetch, claims parse, alg-downgrade +// check (RS256 → not denied), JWKS reachability. +func TestTestDiscovery_HappyPath_AgainstMockIdP(t *testing.T) { + idp := newMockIdP(t) + svc := newServiceForUnitTest(t) + + res, err := svc.TestDiscovery(context.Background(), idp.URL()) + if err != nil { + t.Fatalf("TestDiscovery: %v", err) + } + if !res.DiscoverySucceeded { + t.Errorf("expected DiscoverySucceeded=true") + } + if res.IssuerEcho != idp.URL() { + t.Errorf("expected IssuerEcho=%q, got %q", idp.URL(), res.IssuerEcho) + } + if res.AuthorizationURL == "" || res.TokenURL == "" { + t.Errorf("expected non-empty AuthorizationURL+TokenURL; got %q / %q", res.AuthorizationURL, res.TokenURL) + } + if !res.JWKSReachable { + t.Errorf("expected JWKSReachable=true; got Errors=%v", res.Errors) + } + if len(res.SupportedAlgValues) == 0 { + t.Errorf("expected non-empty SupportedAlgValues") + } + // Mock IdP advertises RS256; no downgrade-defense trip. + for _, e := range res.Errors { + if strings.Contains(e, "alg-downgrade defense tripped") { + t.Errorf("unexpected alg-downgrade trip: %s", e) + } + } +} + +// TestTestDiscovery_AlgDowngradeDetected runs against a stub IdP that +// advertises HS256 in id_token_signing_alg_values_supported. The +// function MUST flag the downgrade attack vector in res.Errors but +// MUST NOT short-circuit (per-leg observability is the contract). +func TestTestDiscovery_AlgDowngradeDetected(t *testing.T) { + svc := newServiceForUnitTest(t) + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"HS256", "RS256"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"keys":[]}`)) + }) + + res, err := svc.TestDiscovery(context.Background(), srv.URL) + if err != nil { + t.Fatalf("TestDiscovery: %v", err) + } + if !res.DiscoverySucceeded { + t.Errorf("expected DiscoverySucceeded=true; got Errors=%v", res.Errors) + } + found := false + for _, e := range res.Errors { + if strings.Contains(e, "alg-downgrade defense tripped") && strings.Contains(e, "HS256") { + found = true + break + } + } + if !found { + t.Errorf("expected alg-downgrade-tripped:HS256 in errors; got %v", res.Errors) + } +} + +// TestTestDiscovery_MissingJWKSURI surfaces the "discovery doc omits +// jwks_uri" branch. +func TestTestDiscovery_MissingJWKSURI(t *testing.T) { + svc := newServiceForUnitTest(t) + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "id_token_signing_alg_values_supported": []string{"RS256"}, + // jwks_uri intentionally omitted + }) + }) + + res, err := svc.TestDiscovery(context.Background(), srv.URL) + if err != nil { + t.Fatalf("TestDiscovery: %v", err) + } + if res.JWKSReachable { + t.Errorf("expected JWKSReachable=false when jwks_uri is missing") + } + found := false + for _, e := range res.Errors { + if strings.Contains(e, "omits jwks_uri") { + found = true + } + } + if !found { + t.Errorf("expected 'omits jwks_uri' in errors; got %v", res.Errors) + } +} + +// TestTestDiscovery_JWKSFetchFails covers the jwksReachable error +// branch (non-2xx JWKS response). +func TestTestDiscovery_JWKSFetchFails(t *testing.T) { + svc := newServiceForUnitTest(t) + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + defer srv.Close() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "jwks_uri": srv.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal", http.StatusInternalServerError) + }) + + res, err := svc.TestDiscovery(context.Background(), srv.URL) + if err != nil { + t.Fatalf("TestDiscovery: %v", err) + } + if res.JWKSReachable { + t.Errorf("expected JWKSReachable=false on 500") + } + found := false + for _, e := range res.Errors { + if strings.Contains(e, "JWKS endpoint returned non-200") { + found = true + } + } + if !found { + t.Errorf("expected 'JWKS endpoint returned non-200' in errors; got %v", res.Errors) + } +} diff --git a/internal/auth/session/coverage_fill_test.go b/internal/auth/session/coverage_fill_test.go new file mode 100644 index 0000000..8914e23 --- /dev/null +++ b/internal/auth/session/coverage_fill_test.go @@ -0,0 +1,89 @@ +package session + +import ( + "crypto/hmac" + "crypto/sha256" + "testing" +) + +// Coverage fill — v2.1.0 release gate Phase 3. +// +// Three previously-uncovered surfaces: +// +// - SetTrustedProxies (cmd/server config wire) +// - ComputeCookieHMAC (pre-login cookie verifier helper) +// - DecryptKeyMaterial (pre-login HMAC-key derive) +// +// Each is a thin wrapper called by main.go or the pre-login flow that +// never exits through a unit-test fixture. The tests below run them +// directly so the coverage gate stops flagging the package. + +func TestSetTrustedProxies_RoundTrip(t *testing.T) { + t.Parallel() //nolint:paralleltest // shared package-level state + // Snapshot + restore so concurrent tests don't observe the override. + prev := trustedProxyCIDRs + defer func() { trustedProxyCIDRs = prev }() + + want := []string{"10.0.0.0/8", "192.0.2.1"} + SetTrustedProxies(want) + if len(trustedProxyCIDRs) != len(want) { + t.Fatalf("expected %d entries, got %d", len(want), len(trustedProxyCIDRs)) + } + for i, c := range want { + if trustedProxyCIDRs[i] != c { + t.Errorf("entry %d: got %q, want %q", i, trustedProxyCIDRs[i], c) + } + } + + // Empty slice clears. + SetTrustedProxies(nil) + if len(trustedProxyCIDRs) != 0 { + t.Errorf("expected nil/empty after clear; got %v", trustedProxyCIDRs) + } +} + +func TestComputeCookieHMAC_Deterministic(t *testing.T) { + t.Parallel() + key := []byte("a-32-byte-key-for-hmac-test-pad!") + mac1 := ComputeCookieHMAC("ses-1", "actor-1", key) + mac2 := ComputeCookieHMAC("ses-1", "actor-1", key) + if !hmac.Equal(mac1, mac2) { + t.Errorf("HMAC must be deterministic for the same inputs") + } + // Length is sha256.Size. + if len(mac1) != sha256.Size { + t.Errorf("expected len=%d (sha256), got %d", sha256.Size, len(mac1)) + } + // Differing id2 changes the HMAC. + if hmac.Equal(mac1, ComputeCookieHMAC("ses-1", "actor-2", key)) { + t.Errorf("HMAC must differ when actor changes") + } + // Differing id1 changes the HMAC. + if hmac.Equal(mac1, ComputeCookieHMAC("ses-2", "actor-1", key)) { + t.Errorf("HMAC must differ when session changes") + } +} + +func TestDecryptKeyMaterial_RoundTrip(t *testing.T) { + t.Parallel() + // encryptKeyMaterial + decryptKeyMaterial are the pair; round-trip + // asserts the public DecryptKeyMaterial wrapper does not bypass + // the decryption path. + plaintext := []byte("plain-32-byte-key-for-hmac-pad!!") + const passphrase = "test-passphrase-for-key-encrypt" + ct, err := encryptKeyMaterial(plaintext, passphrase) + if err != nil { + t.Fatalf("encryptKeyMaterial: %v", err) + } + got, err := DecryptKeyMaterial(ct, passphrase) + if err != nil { + t.Fatalf("DecryptKeyMaterial: %v", err) + } + if string(got) != string(plaintext) { + t.Errorf("decrypt mismatch: got %q, want %q", got, plaintext) + } + // Wrong passphrase → error (forwarded from decryptKeyMaterial). + if _, err := DecryptKeyMaterial(ct, "wrong-passphrase"); err == nil { + t.Errorf("expected error with wrong passphrase, got nil") + } +}