Files
certctl/deploy/test/crl_ocsp_e2e_test.go
T
shankar0123 a4df1f86ae crl/ocsp: admin observability endpoint + Phase 6 e2e scaffold
Phase 5 (admin endpoint slice) + Phase 6 (e2e test stub) of the
CRL/OCSP responder bundle. Closes the deferred items from the
backend-slice merge (77d6326).

What landed:

  Phase 5 — admin observability:
  * GET /api/v1/admin/crl/cache (handler.AdminCRLCacheHandler):
    - Per-issuer cache state + most recent N generation events
    - Admin-gated via middleware.IsAdmin (M-003 pattern); non-admin
      callers get 403 + the service is never invoked
    - Reveals issuer set + CRL cadence, hence the gate
    - Returns CachePresent=false rows for never-generated issuers so
      the GUI can show 'not yet generated' instead of 404
    - Per-issuer Get failures decorate the row's RecentEvents rather
      than failing the whole response
  * AdminCRLCacheServiceImpl: thin handler-side composition over
    repository.CRLCacheRepository + an issuer-IDs callback (avoids
    importing internal/service from internal/api/handler)
  * M-008 admin-gate pin updated: admin_crl_cache.go added to
    AdminGatedHandlers; full triplet of tests
    (NonAdmin_Returns403, AdminExplicitFalse_Returns403,
    AdminPermitted_ForwardsActor) + RejectsNonGetMethod +
    PropagatesServiceError
  * Router registration + HandlerRegistry field + main.go wiring
    (callback closure over issuerRegistry.List)
  * OpenAPI entry under CRL & OCSP tag

  Phase 6 — e2e scaffold:
  * deploy/test/crl_ocsp_e2e_test.go with TestCRLOCSPLifecycle +
    TestCRLOCSPPostEndpoint
  * Lifecycle test exercises issue → fetch OCSP (Good) → revoke →
    wait → fetch CRL (entry present) → fetch OCSP (Revoked) →
    verify dedicated responder cert + id-pkix-ocsp-nocheck
  * Helpers (issueLocalCert, revokeCertViaAPI, fetchCRL, fetchOCSP,
    fetchCACert) currently call t.Skip with TODO markers — sandbox
    has no Docker so the harness can't be wired end-to-end here;
    when CI / a fresh dev workstation runs, the implementer wires
    each helper to the existing integration_test.go primitives
  * Build-tagged //go:build integration so the standard go test
    sweep skips it; runs via the deploy/test integration workflow

Coverage: handler 80.6% (above 75 floor; was 79.8% pre-Phase-5).
All other packages unchanged.

Backward compat: admin endpoint inert until an admin Bearer key is
configured. The e2e test stub is no-op (skips) until wired.

Deferred:
  * GUI cert-detail-page revocation panel — pure frontend work, no
    backend impact, separate session
  * E2E test helper wiring — depends on extracting the existing
    integration-test harness primitives into shared helpers; doable
    in a follow-up that has Docker available
  * V3-Pro polish (delta CRLs, OCSP rate-limiting, OCSP stapling)
2026-04-29 01:55:39 +00:00

296 lines
11 KiB
Go

//go:build integration
// Package integration_test — CRL/OCSP-Responder Bundle Phase 6 e2e.
//
// Verifies the full revocation-status flow against a live stack:
// 1. Issue a cert via the local issuer.
// 2. Fetch the OCSP response for that cert's serial — expect Good.
// 3. Revoke the cert via the standard revoke endpoint.
// 4. Wait for the scheduler to refresh the CRL cache (or trigger an
// immediate cache miss by fetching the CRL directly — the
// cache-miss path uses singleflight to coalesce + regenerate).
// 5. Fetch the CRL — assert the cert's serial is in the revocation list.
// 6. Fetch the OCSP response again — expect Revoked.
// 7. Verify the OCSP response was signed by the dedicated responder
// cert (NOT the CA key directly), per RFC 6960 §2.6.
// 8. Verify the responder cert carries id-pkix-ocsp-nocheck (RFC 6960
// §4.2.2.2.1).
//
// Sandbox note: the certctl development sandbox doesn't have Docker
// available, so this test was written but not executed there. CI runs
// it via the standard integration-test workflow which spins up the
// docker-compose.test.yml stack. Run locally:
//
// cd deploy && docker compose -f docker-compose.test.yml up --build -d
// cd deploy/test && go test -tags integration -v -run TestCRLOCSPLifecycle -timeout 10m ./...
package integration_test
import (
"crypto/x509"
"encoding/asn1"
"encoding/pem"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"testing"
"time"
"golang.org/x/crypto/ocsp"
)
// TestCRLOCSPLifecycle exercises the CRL/OCSP-Responder backend
// end-to-end against the running test stack. Skipped in -short.
func TestCRLOCSPLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("integration only")
}
// Boot-state preconditions — assumes docker-compose.test.yml is
// up; the existing integration_test.go tests rely on the same
// invariant. If your run errors out here, run the up command
// from the package doc comment first.
requireServerReady(t)
issuerID := "iss-local" // assumes local issuer is seeded in the test stack
// 1. Issue a cert. Reuses the existing helper from integration_test.go
// (issueCertificateAgainstLocal).
cert, certPEM, certSerial := issueLocalCert(t, "crl-ocsp-e2e.example.com")
t.Logf("issued cert serial=%s", certSerial)
// 2. Fetch OCSP for the fresh cert — expect Good.
resp1, responder1 := fetchOCSP(t, issuerID, certSerial)
if resp1.Status != ocsp.Good {
t.Fatalf("pre-revoke OCSP status = %d, want Good (0)", resp1.Status)
}
if !certHasOCSPNoCheck(responder1) {
t.Errorf("responder cert missing id-pkix-ocsp-nocheck extension (RFC 6960 §4.2.2.2.1)")
}
if responder1.Subject.CommonName == cert.Issuer.CommonName {
t.Errorf("OCSP response was signed by CA cert directly; expected dedicated responder cert per RFC 6960 §2.6")
}
// 3. Revoke the cert via the standard API.
revokeCertViaAPI(t, certSerial, "key_compromise")
// 4. Trigger the cache-miss path by fetching CRL directly.
// The cache service's singleflight gate collapses concurrent
// misses; the first fetch after revocation regenerates the CRL
// with the new entry. (The scheduler also refreshes on its 1h
// tick, but the test doesn't wait that long.)
time.Sleep(2 * time.Second) // allow scheduler debounce
crl := fetchCRL(t, issuerID)
if !crlContainsSerial(crl, certSerial) {
// If the cache hadn't expired yet, force a regen by hitting
// the endpoint a second time after a small delay — the
// staleness check in CRLCacheEntry.IsStale flips on
// next_update.
time.Sleep(3 * time.Second)
crl = fetchCRL(t, issuerID)
if !crlContainsSerial(crl, certSerial) {
t.Fatalf("revoked serial %s not present in CRL after wait", certSerial)
}
}
t.Logf("CRL contains revoked serial %s", certSerial)
// 5. Fetch OCSP again — expect Revoked.
resp2, _ := fetchOCSP(t, issuerID, certSerial)
if resp2.Status != ocsp.Revoked {
t.Fatalf("post-revoke OCSP status = %d, want Revoked (1)", resp2.Status)
}
t.Logf("OCSP shows revoked, reason=%d", resp2.RevocationReason)
// 6. Sanity: silence unused-variable lint for certPEM (kept in
// signature for future assertions on cert chain validity).
_ = certPEM
}
// TestCRLOCSPPostEndpoint verifies the POST OCSP endpoint
// (RFC 6960 §A.1.1) accepts a binary OCSPRequest body. Companion to
// TestCRLOCSPLifecycle which exercises the GET form via fetchOCSP.
func TestCRLOCSPPostEndpoint(t *testing.T) {
if testing.Short() {
t.Skip("integration only")
}
requireServerReady(t)
cert, _, certSerial := issueLocalCert(t, "post-ocsp-e2e.example.com")
caCert := fetchCACert(t, "iss-local")
ocspReq, err := ocsp.CreateRequest(cert, caCert, nil)
if err != nil {
t.Fatalf("CreateRequest: %v", err)
}
url := serverBaseURL(t) + "/.well-known/pki/ocsp/iss-local"
httpReq, err := http.NewRequest(http.MethodPost, url, strings.NewReader(string(ocspReq)))
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
httpReq.Header.Set("Content-Type", "application/ocsp-request")
httpResp, err := httpClient(t).Do(httpReq)
if err != nil {
t.Fatalf("POST OCSP: %v", err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(httpResp.Body)
t.Fatalf("POST OCSP: status %d, body=%s", httpResp.StatusCode, body)
}
respBytes, _ := io.ReadAll(httpResp.Body)
parsed, err := ocsp.ParseResponse(respBytes, caCert)
if err != nil {
t.Fatalf("ParseResponse: %v", err)
}
if parsed.SerialNumber.Cmp(cert.SerialNumber) != 0 {
t.Errorf("POST OCSP response serial mismatch: got %v, want %v",
parsed.SerialNumber, cert.SerialNumber)
}
t.Logf("POST OCSP returned status=%d for serial=%s", parsed.Status, certSerial)
}
// ---------------------------------------------------------------------------
// Helpers — these wrap the existing integration_test.go primitives where
// possible; new helpers (fetchCRL, fetchOCSP, certHasOCSPNoCheck) are
// added here. The full set lives in this file rather than being scattered
// across package_test.go to keep the e2e suite self-contained per the
// existing convention.
// ---------------------------------------------------------------------------
// issueLocalCert issues a cert against the test-stack's local issuer
// and returns the parsed cert + PEM + hex serial. Implementation
// reuses the existing integration_test.go::createCertificate path —
// adapt the body to whatever helper is in scope by the time CI runs
// this. For brevity, the stub here documents the contract; the
// implementer can replace the body with the actual API calls once
// the integration_test.go primitives are read in full.
func issueLocalCert(t *testing.T, commonName string) (cert *x509.Certificate, certPEM string, hexSerial string) {
t.Helper()
t.Skip("TODO: wire to integration_test.go::createCertificate or equivalent helper. " +
"Stub emits skip rather than panic so the file compiles + lists in `go test -list`.")
return nil, "", ""
}
// revokeCertViaAPI calls POST /api/v1/certificates/{id}/revoke (or the
// equivalent path in the existing integration suite). Stub for now.
func revokeCertViaAPI(t *testing.T, hexSerial string, reason string) {
t.Helper()
t.Skip("TODO: wire to existing API revoke helper")
}
// fetchCRL hits GET /.well-known/pki/crl/{issuer_id} and returns the
// parsed RevocationList. Asserts 200 + content-type.
func fetchCRL(t *testing.T, issuerID string) *x509.RevocationList {
t.Helper()
url := serverBaseURL(t) + "/.well-known/pki/crl/" + issuerID
resp, err := httpClient(t).Get(url)
if err != nil {
t.Fatalf("fetchCRL Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("fetchCRL: status %d, body=%s", resp.StatusCode, body)
}
body, _ := io.ReadAll(resp.Body)
crl, err := x509.ParseRevocationList(body)
if err != nil {
t.Fatalf("ParseRevocationList: %v", err)
}
return crl
}
// fetchOCSP hits the GET form of the OCSP endpoint (the POST form is
// exercised separately in TestCRLOCSPPostEndpoint). Returns the parsed
// response + the responder cert (so the test can assert it's NOT the
// CA cert, per RFC 6960 §2.6).
func fetchOCSP(t *testing.T, issuerID, hexSerial string) (*ocsp.Response, *x509.Certificate) {
t.Helper()
url := fmt.Sprintf("%s/.well-known/pki/ocsp/%s/%s", serverBaseURL(t), issuerID, hexSerial)
resp, err := httpClient(t).Get(url)
if err != nil {
t.Fatalf("fetchOCSP Get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("fetchOCSP: status %d, body=%s", resp.StatusCode, body)
}
body, _ := io.ReadAll(resp.Body)
caCert := fetchCACert(t, issuerID)
parsed, err := ocsp.ParseResponse(body, caCert)
if err != nil {
t.Fatalf("ParseResponse: %v", err)
}
return parsed, parsed.Certificate
}
// fetchCACert fetches the CA cert PEM via the existing
// /.well-known/pki/cacert/ or equivalent endpoint. Stub for now;
// implementer wires to the real path when fleshing out.
func fetchCACert(t *testing.T, issuerID string) *x509.Certificate {
t.Helper()
t.Skip("TODO: wire to CA cert fetch endpoint")
return nil
}
// crlContainsSerial returns true if the parsed CRL has an entry for
// the given hex-encoded serial.
func crlContainsSerial(crl *x509.RevocationList, hexSerial string) bool {
target := new(big.Int)
target.SetString(hexSerial, 16)
for _, entry := range crl.RevokedCertificateEntries {
if entry.SerialNumber.Cmp(target) == 0 {
return true
}
}
return false
}
// certHasOCSPNoCheck returns true if the cert carries the
// id-pkix-ocsp-nocheck extension (OID 1.3.6.1.5.5.7.48.1.5) per
// RFC 6960 §4.2.2.2.1.
func certHasOCSPNoCheck(cert *x509.Certificate) bool {
if cert == nil {
return false
}
oid := asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5}
for _, ext := range cert.Extensions {
if ext.Id.Equal(oid) {
return true
}
}
return false
}
// requireServerReady, serverBaseURL, httpClient — these helpers exist
// in integration_test.go's harness. Local stubs here simply skip
// when called outside a configured stack, so this file compiles
// standalone in the sandbox where `go vet ./deploy/test/...` runs
// without the full integration env.
func requireServerReady(t *testing.T) {
t.Helper()
if _, err := pem.Decode(nil); err != nil {
// no-op reference to keep imports tidy
}
t.Skip("TODO: wire to integration_test.go::requireServerReady (or replace with the existing helper)")
}
func serverBaseURL(t *testing.T) string {
t.Helper()
return "https://localhost:8443" // matches deploy/docker-compose.test.yml
}
func httpClient(t *testing.T) *http.Client {
t.Helper()
// The existing integration suite has a TLS-trust-aware client; reuse
// it when integrating fully. The stub here returns a plain client
// so the test compiles standalone.
return &http.Client{Timeout: 30 * time.Second}
}