Files
certctl/internal/service/ocsp_response_cache_real_test.go
T
shankar0123 9e6c57673e test(service): coverage uplift for production hardening II + adjacent helpers (R-CI-extended floor)
CI's R-CI-extended coverage gate failed on 2025-04-30: service-layer
coverage was 68.7% vs the 70% floor. The drag was from new files
(internal/service/ocsp_counters.go, ocsp_response_cache.go,
export_audit_actions.go) that shipped without enough direct tests
to keep the package above the floor.

NEW internal/service/ocsp_counters_test.go (4 tests):
  - TestOCSPCounters_NewIsZero — fresh counter snapshot is all zero
  - TestOCSPCounters_EveryIncTicksItsLabel — table-driven test
    pinning every Inc* method to its label string + the no-cross-
    bleed invariant. Critical for Phase 8 Prometheus exposer
    contract: a typo in either side would silently drop the
    counter from /metrics/prometheus.
  - TestOCSPCounters_SnapshotIsCopy — mutating the returned map
    doesn't affect the underlying counters
  - TestOCSPCounters_ConcurrentTicksRace — race-detector smoke
    against sync/atomic primitives

NEW internal/service/ocsp_response_cache_real_test.go (10 tests):
  - HappyPath_CachesAfterMiss — first fetch live-signs + writes
    cache row; second fetch hits cache
  - CacheWriteFailureIsNonFatal — putErrorRepo simulates disk full;
    response still returned (fail-soft contract)
  - StaleEntryRegenerates — entries with next_update in the past
    trigger re-sign on next fetch
  - InvalidateOnRevoke — pin the load-bearing security wire
  - InvalidateOnRevoke_DeleteFailureSurfacesError — error-path
    coverage for the delete branch
  - CountByIssuer + NilRepoReturnsEmpty
  - CAOperationsSvc.GetOCSPResponseWithNonce_CacheDispatchHit pins
    the nil-nonce → cache dispatch wire
  - CAOperationsSvc.GetOCSPResponseWithNonce_NonceBypassesCache
    pins the nonce-bearing → live-sign bypass wire (cache stays
    empty)
  - RevocationSvc.SetOCSPCacheInvalidator_WireConnects pins the
    setter through to the wired interface

NEW internal/service/coverage_extras_test.go (~12 tests) targets the
0%-coverage chunks adjacent to the bundle's modified files so the
package as a whole stays above the floor:
  - cert-export typed audit emission (Phase 7) round-trip with
    detail-map inspection (has_private_key + actor_kind + cipher pin)
  - PKCS12CipherModernAES256 pinned-value test (drift catches a
    future go-pkcs12 default change)
  - audit.ListAuditEvents + GetAuditEvent (handler-interface methods
    that were at 0%)
  - certificate.ListCertificatesWithFilter (M20 filter delegate)
  - discovery.{ListScans,GetScan,GetDiscoverySummary} (delegates)
  - health_check.{Update,SetNotificationService} delegates + audit
  - est.{deterministicSerial,zeroizeBytes,zeroizeKey} pure helpers
    + the live RSA + ECDSA key-zeroize branches

Sandbox total: 67.6% → 69.9% (+2.3pp). The live keygen branches
in zeroizeKey skip in the sandbox when crypto/rand isn't available
but run on CI, so the CI total should land above the 70% floor with
a small buffer.

Pre-commit verification: go build ./... clean; go test -short
-count=1 green for ./internal/service/.
2026-04-30 06:22:06 +00:00

298 lines
10 KiB
Go

package service
import (
"context"
"errors"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// Production hardening II Phase 2 — exercise the REAL OCSPResponseCacheService
// (not the test-harness mirror in ocsp_response_cache_test.go) wired against
// a real CAOperationsSvc + mockIssuerConnector. Closes the coverage gap on:
//
// - OCSPResponseCacheService.Get (cache miss → live-sign → write-back)
// - OCSPResponseCacheService.regenerate (singleflight + cache.Put + the
// cache-write-failure log branch)
// - OCSPResponseCacheService.InvalidateOnRevoke (the load-bearing wire
// into the real revocation flow)
// - OCSPResponseCacheService.CountByIssuer
// - CAOperationsSvc.GetOCSPResponseWithNonce dispatch when cache wired
// - CAOperationsSvc.SetOCSPCacheSvc setter
// - RevocationSvc.SetOCSPCacheInvalidator setter + invalidator wire
// silentLogger returns a slog.Logger that discards everything.
func silentLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
}
// putErrorRepo wraps the in-memory cache repo and forces Put to fail.
// Used to exercise the "cache write failed (response still valid)"
// log branch in regenerate.
type putErrorRepo struct {
*fakeOCSPCacheRepo
putErr error
}
func (r *putErrorRepo) Put(ctx context.Context, e *domain.OCSPResponseCacheEntry) error {
if r.putErr != nil {
return r.putErr
}
return r.fakeOCSPCacheRepo.Put(ctx, e)
}
// deleteErrorRepo forces Delete to fail; exercises the invalidate-failure
// log branch in InvalidateOnRevoke.
type deleteErrorRepo struct {
*fakeOCSPCacheRepo
deleteErr error
}
func (r *deleteErrorRepo) Delete(ctx context.Context, issuer, serial string) error {
if r.deleteErr != nil {
return r.deleteErr
}
return r.fakeOCSPCacheRepo.Delete(ctx, issuer, serial)
}
func TestOCSPResponseCacheService_RealGet_HappyPath_CachesAfterMiss(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := newFakeOCSPCacheRepo()
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, NewOCSPCounters(), silentLogger())
// First fetch: cache miss → live-sign via mockIssuerConnector → cache write-back.
der1, err := cache.Get(context.Background(), "iss-local", "deadbeef")
if err != nil {
t.Fatalf("first fetch: %v", err)
}
if len(der1) == 0 {
t.Fatal("expected non-empty DER from live sign")
}
// Cache row now present.
got, _ := cacheRepo.Get(context.Background(), "iss-local", "deadbeef")
if got == nil {
t.Fatal("expected cache row written after miss")
}
// Second fetch: cache hit (returns the same cached bytes).
der2, err := cache.Get(context.Background(), "iss-local", "deadbeef")
if err != nil {
t.Fatalf("second fetch: %v", err)
}
if string(der1) != string(der2) {
t.Errorf("cache returned different bytes than original sign")
}
}
func TestOCSPResponseCacheService_RealGet_CacheWriteFailureIsNonFatal(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := &putErrorRepo{
fakeOCSPCacheRepo: newFakeOCSPCacheRepo(),
putErr: errors.New("disk full simulation"),
}
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, nil, silentLogger())
// Get: live-sign succeeds, cache.Put fails — the response is still
// valid; we just lose the cache benefit on the next request. The
// caller MUST get a successful response.
der, err := cache.Get(context.Background(), "iss-local", "deadbeef")
if err != nil {
t.Fatalf("expected fail-soft on cache write failure, got %v", err)
}
if len(der) == 0 {
t.Fatal("expected non-empty DER even when cache.Put failed")
}
}
func TestOCSPResponseCacheService_RealGet_StaleEntryRegenerates(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := newFakeOCSPCacheRepo()
// Pre-populate with a stale entry.
stale := &domain.OCSPResponseCacheEntry{
IssuerID: "iss-local",
SerialHex: "abcd",
ResponseDER: []byte{0x11},
CertStatus: "good",
ThisUpdate: time.Now().Add(-2 * time.Hour),
NextUpdate: time.Now().Add(-1 * time.Hour),
GeneratedAt: time.Now().Add(-2 * time.Hour),
}
_ = cacheRepo.Put(context.Background(), stale)
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, nil, silentLogger())
der, err := cache.Get(context.Background(), "iss-local", "abcd")
if err != nil {
t.Fatalf("get: %v", err)
}
// Stale entry → re-sign produces fresh bytes (different from the
// pre-populated 0x11 placeholder).
if len(der) == 1 && der[0] == 0x11 {
t.Errorf("stale entry should have triggered re-sign; got pre-populated bytes")
}
}
func TestOCSPResponseCacheService_RealInvalidateOnRevoke(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := newFakeOCSPCacheRepo()
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, nil, silentLogger())
// Pre-populate one row.
_ = cacheRepo.Put(context.Background(), &domain.OCSPResponseCacheEntry{
IssuerID: "iss-local",
SerialHex: "deadbeef",
ResponseDER: []byte{0x42},
CertStatus: "good",
ThisUpdate: time.Now(),
NextUpdate: time.Now().Add(1 * time.Hour),
GeneratedAt: time.Now(),
})
if err := cache.InvalidateOnRevoke(context.Background(), "iss-local", "deadbeef"); err != nil {
t.Fatalf("invalidate: %v", err)
}
got, _ := cacheRepo.Get(context.Background(), "iss-local", "deadbeef")
if got != nil {
t.Errorf("expected cache row deleted after invalidate")
}
}
func TestOCSPResponseCacheService_InvalidateOnRevoke_DeleteFailureSurfacesError(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := &deleteErrorRepo{
fakeOCSPCacheRepo: newFakeOCSPCacheRepo(),
deleteErr: errors.New("delete failed"),
}
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, nil, silentLogger())
err := cache.InvalidateOnRevoke(context.Background(), "iss-local", "deadbeef")
if err == nil {
t.Errorf("expected error when delete fails, got nil")
}
}
func TestOCSPResponseCacheService_RealCountByIssuer(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := newFakeOCSPCacheRepo()
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, nil, silentLogger())
for i, e := range []struct{ iss, ser string }{
{"iss-local", "ser1"},
{"iss-local", "ser2"},
{"iss-other", "ser1"},
} {
_ = i
_ = cacheRepo.Put(context.Background(), &domain.OCSPResponseCacheEntry{
IssuerID: e.iss,
SerialHex: e.ser,
ResponseDER: []byte{0x42},
CertStatus: "good",
ThisUpdate: time.Now(),
NextUpdate: time.Now().Add(1 * time.Hour),
GeneratedAt: time.Now(),
})
}
got, err := cache.CountByIssuer(context.Background())
if err != nil {
t.Fatalf("count: %v", err)
}
if got["iss-local"] != 2 || got["iss-other"] != 1 {
t.Errorf("CountByIssuer = %#v, want iss-local=2 iss-other=1", got)
}
}
func TestOCSPResponseCacheService_NilRepoReturnsEmptyCountByIssuer(t *testing.T) {
cache := NewOCSPResponseCacheService(nil, nil, nil, silentLogger())
got, err := cache.CountByIssuer(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("expected empty map, got %v", got)
}
}
func TestCAOperationsSvc_GetOCSPResponseWithNonce_CacheDispatchHit(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := newFakeOCSPCacheRepo()
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, nil, silentLogger())
caSvc.SetOCSPCacheSvc(cache)
// Nil-nonce request: dispatches through the cache. First call is
// a miss (live-sign + write-back); cache row should appear.
_, err := caSvc.GetOCSPResponseWithNonce(context.Background(), "iss-local", "deadbeef", nil)
if err != nil {
t.Fatalf("first: %v", err)
}
if got, _ := cacheRepo.Get(context.Background(), "iss-local", "deadbeef"); got == nil {
t.Errorf("expected cache row populated after first nil-nonce request")
}
// Second call returns the cached bytes (same content).
der1, _ := caSvc.GetOCSPResponseWithNonce(context.Background(), "iss-local", "deadbeef", nil)
der2, _ := caSvc.GetOCSPResponseWithNonce(context.Background(), "iss-local", "deadbeef", nil)
if string(der1) != string(der2) {
t.Errorf("repeated cached fetches returned different bytes")
}
}
func TestCAOperationsSvc_GetOCSPResponseWithNonce_NonceBypassesCache(t *testing.T) {
caSvc, _, _, _ := newCAOperationsSvcTestWithIssuer()
cacheRepo := newFakeOCSPCacheRepo()
cache := NewOCSPResponseCacheService(cacheRepo, caSvc, nil, silentLogger())
caSvc.SetOCSPCacheSvc(cache)
// Nonce-bearing request: bypasses the cache entirely. After the
// call, the cache row should still NOT be populated.
nonce := []byte{0xaa, 0xbb}
_, err := caSvc.GetOCSPResponseWithNonce(context.Background(), "iss-local", "deadbeef", nonce)
if err != nil {
t.Fatalf("nonce request: %v", err)
}
if got, _ := cacheRepo.Get(context.Background(), "iss-local", "deadbeef"); got != nil {
t.Errorf("nonce-bearing live-sign should NOT write to cache; found row %#v", got)
}
}
func TestRevocationSvc_SetOCSPCacheInvalidator_WireConnects(t *testing.T) {
// The wire under test: SetOCSPCacheInvalidator stores the invalidator
// on the service such that subsequent revoke flows can call it.
// We verify the wire is connected by directly invoking the stored
// invalidator (the full revoke flow needs a live cert + repo
// pipeline that's covered elsewhere).
fake := &fakeInvalidator{}
svc := NewRevocationSvc(nil, nil, nil)
svc.SetOCSPCacheInvalidator(fake)
if err := svc.ocspCacheInvalidator.InvalidateOnRevoke(context.Background(), "iss-local", "ff"); err != nil {
t.Fatalf("invalidate: %v", err)
}
if fake.calls != 1 {
t.Errorf("expected 1 InvalidateOnRevoke call, got %d", fake.calls)
}
if fake.lastIssuer != "iss-local" || fake.lastSerial != "ff" {
t.Errorf("invalidator received wrong args: issuer=%q serial=%q",
fake.lastIssuer, fake.lastSerial)
}
}
type fakeInvalidator struct {
mu sync.Mutex
calls int
lastIssuer string
lastSerial string
}
func (f *fakeInvalidator) InvalidateOnRevoke(_ context.Context, issuerID, serialHex string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
f.lastIssuer = issuerID
f.lastSerial = serialHex
return nil
}