From fc3c7ad1e37d0dcb6be6d2884e50c8e2729b3d3e Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Wed, 29 Apr 2026 03:03:19 +0000 Subject: [PATCH] =?UTF-8?q?crl/ocsp=20e2e:=20wire=20helpers=20to=20integra?= =?UTF-8?q?tion=5Ftest.go=20primitives=20=E2=80=94=20Phase=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase 6 e2e scaffold landed in a4df1f8 with t.Skip stubs for the five harness primitives that the test needed but the integration_test.go suite already provided. This commit replaces the stubs with real implementations so TestCRLOCSPLifecycle + TestCRLOCSPPostEndpoint actually exercise the CRL/OCSP backend end-to-end against a running docker-compose.test.yml stack. Wired helpers: * issueLocalCert(commonName) → POSTs /api/v1/certificates against iss-local with the test stack's seeded owner/team/policy/profile, triggers /renew, waits for jobs via the existing waitForJobsDone helper, GETs /versions, parses pem_chain into leaf + issuer CA. Returns (leaf, pemChain, hexSerial). Records the cert ID in a package-level registry keyed by hex serial. * revokeCertViaAPI(hexSerial, reason) → resolves hex serial to certctl cert ID via the registry (the API keys revocation by cert ID, not X.509 serial) and POSTs /revoke with the RFC 5280 reason code. * fetchCACert(issuerID) → returns the issuing CA from any cert previously issued via issueLocalCert (chain[1], or chain[0] for self-signed test root). Falls back to a just-in-time issuance if the registry is empty so the helper is callable from any phase. * requireServerReady → polls GET /health (the unauthenticated Bearer-free liveness route from router.go) until 200 OK or 30s. * serverBaseURL → returns the harness's serverURL package var (CERTCTL_TEST_SERVER_URL, defaulting to https://localhost:8443). * httpClient → returns newUnauthHTTPClient (TLS-trust-aware, no Bearer) since /.well-known/pki/{crl,ocsp}/ run unauthenticated by design (M-006: relying parties must validate revocation without API keys). New helper: * parsePEMChain — decodes a PEM bundle into [leaf, issuer]. Handles the self-signed-root edge case by returning the leaf twice rather than nil. Used by issueLocalCert to populate the registry. Constants block at top of file pins the test-stack identifiers (iss-local, owner-test-admin, team-test-ops, rp-default, prof-test-tls) — these match deploy/docker-compose.test.yml seed data so the suite stays in sync with what the stack actually serves. Verification (sandbox — Docker not available so the test bodies themselves can't run here, but the static checks pass): - gofmt: clean - go vet -tags integration ./deploy/test/...: clean - go test -tags integration -list '.*' ./deploy/test/...: lists TestCRLOCSPLifecycle + TestCRLOCSPPostEndpoint among the existing suite tests, confirming the file compiles + binds correctly. CI runs the full suite via docker-compose.test.yml in the standard integration-test workflow. Local repro per the file header doc: cd deploy && docker compose -f docker-compose.test.yml up --build -d cd deploy/test && go test -tags integration -v -run TestCRLOCSP \ -timeout 10m ./... --- deploy/test/crl_ocsp_e2e_test.go | 258 +++++++++++++++++++++++++++---- 1 file changed, 226 insertions(+), 32 deletions(-) diff --git a/deploy/test/crl_ocsp_e2e_test.go b/deploy/test/crl_ocsp_e2e_test.go index 64a6bbd..2cee65f 100644 --- a/deploy/test/crl_ocsp_e2e_test.go +++ b/deploy/test/crl_ocsp_e2e_test.go @@ -29,6 +29,7 @@ package integration_test import ( "crypto/x509" "encoding/asn1" + "encoding/json" "encoding/pem" "fmt" "io" @@ -41,6 +42,22 @@ import ( "golang.org/x/crypto/ocsp" ) +// --------------------------------------------------------------------------- +// Test-stack-specific identifiers — match deploy/docker-compose.test.yml's +// seed data + migrations/seed.sql. The CRL/OCSP suite issues its own certs +// (rather than reusing mc-local-test from the main TestIntegrationSuite) +// so the suites can run independently and in parallel. +// --------------------------------------------------------------------------- + +const ( + crlE2EIssuerID = "iss-local" + crlE2EOwnerID = "owner-test-admin" + crlE2ETeamID = "team-test-ops" + crlE2EPolicyID = "rp-default" + crlE2EProfileID = "prof-test-tls" + crlE2EJobsTimeout = 180 * time.Second +) + // TestCRLOCSPLifecycle exercises the CRL/OCSP-Responder backend // end-to-end against the running test stack. Skipped in -short. func TestCRLOCSPLifecycle(t *testing.T) { @@ -162,25 +179,162 @@ func TestCRLOCSPPostEndpoint(t *testing.T) { // 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, "", "" +// crlE2ECert tracks the certctl-side ID + the parsed leaf together. The +// revoke endpoint is keyed by the certctl certificate ID (mc-*), not by +// the X.509 serial — so the test threads both through the helpers. +type crlE2ECert struct { + CertctlID string // e.g. "mc-crl-e2e-" + Leaf *x509.Certificate // parsed leaf + HexSerial string // lowercase hex of Leaf.SerialNumber, no leading zero stripping + PEMChain string // raw pem_chain string from versions endpoint + IssuerCA *x509.Certificate // parsed issuer CA (chain[1] when present, else chain[0]) } -// revokeCertViaAPI calls POST /api/v1/certificates/{id}/revoke (or the -// equivalent path in the existing integration suite). Stub for now. +// crlE2ECerts holds the in-flight cert-ID → cert mapping so revokeCertViaAPI +// can resolve the hex serial back to the certctl cert ID. Populated by +// issueLocalCert. Map access is safe because the e2e test is single-threaded +// (the integration tag suites don't t.Parallel()). +var crlE2ECerts = map[string]*crlE2ECert{} + +// issueLocalCert issues a cert against the test-stack's local issuer and +// returns the parsed leaf + raw PEM chain + hex serial. Wires through the +// existing integration_test.go primitives: +// - newTestClient() for the HTTPS Bearer-authenticated client +// - waitForJobsDone() for the async issuance job +// - parsePEMCert() for the PEM → x509.Certificate parse +// +// The cert ID is derived from a monotonic counter so successive calls in +// the same run get unique IDs (mc-crl-e2e-1, mc-crl-e2e-2, …) — keeps the +// test re-runnable against the same DB without ON CONFLICT noise. +func issueLocalCert(t *testing.T, commonName string) (cert *x509.Certificate, certPEM string, hexSerial string) { + t.Helper() + + c := newTestClient() + + certID := fmt.Sprintf("mc-crl-e2e-%d", len(crlE2ECerts)+1) + body := fmt.Sprintf(`{ + "id": %q, + "name": %q, + "common_name": %q, + "sans": [%q], + "issuer_id": %q, + "owner_id": %q, + "team_id": %q, + "renewal_policy_id": %q, + "certificate_profile_id": %q, + "environment": "test" + }`, certID, certID, commonName, commonName, + crlE2EIssuerID, crlE2EOwnerID, crlE2ETeamID, crlE2EPolicyID, crlE2EProfileID) + + resp, err := c.Post("/api/v1/certificates", body) + if err != nil { + t.Fatalf("issueLocalCert: POST /certificates: %v", err) + } + if resp.StatusCode/100 != 2 { + t.Fatalf("issueLocalCert: POST status %d, body=%s", resp.StatusCode, readBody(resp)) + } + resp.Body.Close() + + // Trigger issuance + wait for the job to finish. + resp, err = c.Post("/api/v1/certificates/"+certID+"/renew", "") + if err != nil { + t.Fatalf("issueLocalCert: POST renew: %v", err) + } + resp.Body.Close() + waitForJobsDone(t, c, certID, crlE2EJobsTimeout) + + // Pull the freshly-issued version. + resp, err = c.Get("/api/v1/certificates/" + certID + "/versions") + if err != nil { + t.Fatalf("issueLocalCert: GET versions: %v", err) + } + rawBody := readBody(resp) + var versions []certVersion + if err := json.Unmarshal([]byte(rawBody), &versions); err != nil { + // Versions endpoint may use the paged envelope. + var pr pagedResponse + if err := json.Unmarshal([]byte(rawBody), &pr); err != nil { + t.Fatalf("issueLocalCert: decode versions: %v (body: %s)", err, rawBody) + } + if err := json.Unmarshal(pr.Data, &versions); err != nil { + t.Fatalf("issueLocalCert: unmarshal paged versions: %v", err) + } + } + if len(versions) == 0 { + t.Fatalf("issueLocalCert: no versions returned for %s", certID) + } + v := versions[0] + if v.PEMChain == "" { + t.Fatalf("issueLocalCert: empty pem_chain on version %s", v.ID) + } + + leaf, issuerCA := parsePEMChain(t, v.PEMChain) + hex := strings.ToLower(leaf.SerialNumber.Text(16)) + + crlE2ECerts[hex] = &crlE2ECert{ + CertctlID: certID, + Leaf: leaf, + HexSerial: hex, + PEMChain: v.PEMChain, + IssuerCA: issuerCA, + } + return leaf, v.PEMChain, hex +} + +// parsePEMChain decodes a leaf || issuer || ... PEM bundle. Returns the leaf +// + the next cert in the chain (the issuing CA, used as the OCSP issuer). +// If the chain has only one cert (self-signed test root), returns it twice. +func parsePEMChain(t *testing.T, chainPEM string) (leaf, issuer *x509.Certificate) { + t.Helper() + rest := []byte(chainPEM) + var certs []*x509.Certificate + for { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + break + } + if block.Type != "CERTIFICATE" { + continue + } + c, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parsePEMChain: %v", err) + } + certs = append(certs, c) + } + if len(certs) == 0 { + t.Fatalf("parsePEMChain: no certificates decoded from chain") + } + leaf = certs[0] + if len(certs) >= 2 { + issuer = certs[1] + } else { + issuer = certs[0] // self-signed test root + } + return leaf, issuer +} + +// revokeCertViaAPI calls POST /api/v1/certificates/{id}/revoke. The certctl +// API keys revocation by certctl cert ID (mc-*), not by X.509 serial — so +// this resolver looks up the cert ID via the hex-serial registry populated +// by issueLocalCert. func revokeCertViaAPI(t *testing.T, hexSerial string, reason string) { t.Helper() - t.Skip("TODO: wire to existing API revoke helper") + entry, ok := crlE2ECerts[strings.ToLower(hexSerial)] + if !ok { + t.Fatalf("revokeCertViaAPI: no certctl ID registered for serial %s — call issueLocalCert first", hexSerial) + } + c := newTestClient() + body := fmt.Sprintf(`{"reason": %q}`, reason) + resp, err := c.Post("/api/v1/certificates/"+entry.CertctlID+"/revoke", body) + if err != nil { + t.Fatalf("revokeCertViaAPI: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + t.Fatalf("revokeCertViaAPI: POST status %d, body=%s", resp.StatusCode, readBody(resp)) + } } // fetchCRL hits GET /.well-known/pki/crl/{issuer_id} and returns the @@ -230,12 +384,37 @@ func fetchOCSP(t *testing.T, issuerID, hexSerial string) (*ocsp.Response, *x509. 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. +// fetchCACert returns the issuing CA certificate for the given issuer. +// +// Strategy: a cert issued via issueLocalCert against this issuer left its +// chain in the crlE2ECerts registry; the second cert in that chain is the +// issuing CA (or the leaf itself for a self-signed test root). This +// avoids a dependency on a /.well-known/pki/cacert/ endpoint that the +// backend doesn't expose today — the bundle is published via the EST +// /.well-known/est/cacerts surface (PKCS#7) but the test-harness route +// here is simpler and deterministic. +// +// If no leaf has been issued yet against this issuer, falls back to a +// just-in-time issuance so the helper is callable from any phase order. func fetchCACert(t *testing.T, issuerID string) *x509.Certificate { t.Helper() - t.Skip("TODO: wire to CA cert fetch endpoint") + for _, entry := range crlE2ECerts { + if entry.IssuerCA != nil && entry.Leaf.Issuer.CommonName != "" { + // All issued e2e certs share the same iss-local CA; the first + // one we find is correct for issuerID == "iss-local". + if issuerID == crlE2EIssuerID || strings.HasPrefix(issuerID, "iss-local") { + return entry.IssuerCA + } + } + } + // Fallback: no cert in registry for this issuer yet — synthesise one. + _, _, _ = issueLocalCert(t, fmt.Sprintf("cacert-bootstrap-%d.example.com", time.Now().UnixNano())) + for _, entry := range crlE2ECerts { + if entry.IssuerCA != nil { + return entry.IssuerCA + } + } + t.Fatalf("fetchCACert: no CA cert resolvable for issuer %s after bootstrap", issuerID) return nil } @@ -268,28 +447,43 @@ func certHasOCSPNoCheck(cert *x509.Certificate) bool { 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. +// requireServerReady polls /health until it returns 200, or t.Fatals after +// 30s. The endpoint is unauthenticated (router.go pins it as a Bearer-free +// liveness route for K8s/Docker probes) so it doubles as a "is the test +// stack up?" probe before the suite makes its first authenticated call. func requireServerReady(t *testing.T) { t.Helper() - if _, err := pem.Decode(nil); err != nil { - // no-op reference to keep imports tidy + client := newUnauthHTTPClient() + deadline := time.Now().Add(30 * time.Second) + url := serverURL + "/health" + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return + } + } + time.Sleep(500 * time.Millisecond) } - t.Skip("TODO: wire to integration_test.go::requireServerReady (or replace with the existing helper)") + t.Fatalf("requireServerReady: %s never returned 200 within 30s — is the test stack up? (run `docker compose -f deploy/docker-compose.test.yml up -d` first)", url) } +// serverBaseURL returns the server URL configured by the integration +// harness (CERTCTL_TEST_SERVER_URL, defaulting to https://localhost:8443 +// per deploy/docker-compose.test.yml). func serverBaseURL(t *testing.T) string { t.Helper() - return "https://localhost:8443" // matches deploy/docker-compose.test.yml + return serverURL } +// httpClient returns the unauthenticated TLS-trust-aware client from the +// integration harness. The /.well-known/pki/{crl,ocsp}/ endpoints are +// reachable without a Bearer token by design (M-006: relying parties +// must validate revocation without API keys), so we deliberately use the +// no-Authorization client here — this matches how a real revocation- +// validating consumer would hit the endpoints in production. 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} + return newUnauthHTTPClient() }