mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:51:30 +00:00
9e6c57673e
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/.
298 lines
10 KiB
Go
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
|
|
}
|