F-001/F-002/F-003: CRL prefix-scan, digest error sanitization, ctx-aware sleeps

F-001 (P3): GenerateDERCRL scoped to issuer via composite index
  - Add RevocationRepository.ListByIssuer leveraging migration 000012's
    idx_certificate_revocations_issuer_serial composite index as a
    prefix-scan target. Previously CAOperationsSvc.GenerateDERCRL called
    ListAll() and filtered by IssuerID in Go — O(total revocations)
    regardless of how many revocations belonged to the target issuer.
  - Rewrite GenerateDERCRL to call ListByIssuer(ctx, issuerID) so PostgreSQL
    drives a prefix scan of the composite index. Drops the in-memory filter.
  - New regression test in ca_operations_test.go asserts the CRL hot path
    invokes ListByIssuer exactly once and never ListAll, and that the
    issuerID is threaded through correctly.

F-002 (P3): digest.go admin-auth endpoints no longer leak internal errors
  - PreviewDigest (GET /api/v1/digest/preview) and SendDigest
    (POST /api/v1/digest/send) previously wrote err.Error() into the HTTP
    response body on 500s. Replace with slog.Error server-side logging plus
    a generic "internal error" response body, matching the house pattern
    in certificates.go and export.go.

F-003 (P4): three blocking time.Sleep sites now honor ctx cancellation
  - internal/connector/issuer/acme/acme.go:672 (DNS-01 propagation wait)
    now runs under a select{case <-ctx.Done(): CleanUp + return ctx.Err();
    case <-time.After(d):} so graceful shutdown doesn't get stuck behind
    the propagation delay.
  - internal/connector/issuer/acme/acme.go:786 (dns-persist-01 propagation
    wait) same pattern, returns ctx.Err() on cancel.
  - cmd/agent/main.go:272 (polling backoff inside the heartbeat loop) now
    wraps the sleep in select{case <-ctx.Done(): continue; case <-time.After(backoff):}
    so the outer <-ctx.Done() case on the parent loop fires cleanly.

Verification: build, vet, and race-enabled short tests green across all
55+ packages. govulncheck reports zero vulnerabilities in the code path.
No migration needed — F-001 reuses the existing 000012 composite index.
No frontend changes.
This commit is contained in:
shankar0123
2026-04-20 16:51:52 +00:00
parent 55ce86b132
commit 4e5522a999
9 changed files with 165 additions and 16 deletions
+67
View File
@@ -75,6 +75,73 @@ func TestCAOperationsSvc_GenerateDERCRL_Success(t *testing.T) {
t.Logf("DER CRL generated successfully: %d bytes", len(crl))
}
// TestCAOperationsSvc_GenerateDERCRL_UsesListByIssuer_NotListAll guards F-001.
// Before the fix, GenerateDERCRL called revocationRepo.ListAll(ctx) and filtered
// results in Go (if rev.IssuerID != issuerID { continue }). That was O(N) in the
// size of the entire revocation table and did not scale as revocations piled up
// across many issuers. Migration 000012 added the composite index
// idx_certificate_revocations_issuer_serial(issuer_id, serial_number), which is
// a prefix scan target — so the hot path must now call ListByIssuer(ctx, id) to
// drive an indexed query. This regression test asserts the hot path invokes
// ListByIssuer exactly once and never falls back to the full-table ListAll scan,
// and also double-checks that cross-issuer revocations are correctly excluded
// from the generated CRL (no in-Go filter left to catch them).
func TestCAOperationsSvc_GenerateDERCRL_UsesListByIssuer_NotListAll(t *testing.T) {
caSvc, revocationRepo, _ := newCAOperationsSvcTest()
// Pre-populate with revocations from TWO issuers. If the hot path regresses
// and calls ListAll instead of ListByIssuer, the generated CRL would either
// include the wrong rows or — with the in-Go filter gone — pull in both
// issuers' revocations. ListByIssuer scopes at the query level so only
// iss-local rows come back.
now := time.Now()
revocationRepo.Revocations = []*domain.CertificateRevocation{
{
SerialNumber: "LOCAL-001",
CertificateID: "cert-local-1",
IssuerID: "iss-local",
Reason: "keyCompromise",
RevokedAt: now.Add(-24 * time.Hour),
RevokedBy: "admin",
},
{
SerialNumber: "LOCAL-002",
CertificateID: "cert-local-2",
IssuerID: "iss-local",
Reason: "superseded",
RevokedAt: now.Add(-12 * time.Hour),
RevokedBy: "admin",
},
{
SerialNumber: "OTHER-001",
CertificateID: "cert-other-1",
IssuerID: "iss-other",
Reason: "keyCompromise",
RevokedAt: now.Add(-6 * time.Hour),
RevokedBy: "admin",
},
}
crl, err := caSvc.GenerateDERCRL(context.Background(), "iss-local")
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if len(crl) == 0 {
t.Fatal("expected non-empty CRL")
}
// The contractual assertion: the CRL hot path MUST use the scoped query.
if got, want := revocationRepo.ListByIssuerCalls, 1; got != want {
t.Errorf("ListByIssuerCalls = %d, want %d — CRL hot path must call the scoped query driven by migration 000012 index", got, want)
}
if got := revocationRepo.ListAllCalls; got != 0 {
t.Errorf("ListAllCalls = %d, want 0 — CRL hot path must NOT fall back to the full-table scan after F-001", got)
}
if got, want := revocationRepo.LastListIssuerID, "iss-local"; got != want {
t.Errorf("LastListIssuerID = %q, want %q — issuer scoping argument lost", got, want)
}
}
func TestCAOperationsSvc_GenerateDERCRL_EmptyCRL(t *testing.T) {
caSvc, revocationRepo, _ := newCAOperationsSvcTest()