package service import ( "context" "errors" "io" "log/slog" "sync" "testing" "time" "github.com/certctl-io/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 }