mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:31:34 +00:00
crl/ocsp e2e: wire helpers to integration_test.go primitives — Phase 6
The Phase 6 e2e scaffold landed in cdacac6 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 ./...
This commit is contained in:
@@ -29,6 +29,7 @@ package integration_test
|
|||||||
import (
|
import (
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/asn1"
|
"encoding/asn1"
|
||||||
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -41,6 +42,22 @@ import (
|
|||||||
"golang.org/x/crypto/ocsp"
|
"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
|
// TestCRLOCSPLifecycle exercises the CRL/OCSP-Responder backend
|
||||||
// end-to-end against the running test stack. Skipped in -short.
|
// end-to-end against the running test stack. Skipped in -short.
|
||||||
func TestCRLOCSPLifecycle(t *testing.T) {
|
func TestCRLOCSPLifecycle(t *testing.T) {
|
||||||
@@ -162,25 +179,162 @@ func TestCRLOCSPPostEndpoint(t *testing.T) {
|
|||||||
// existing convention.
|
// existing convention.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// issueLocalCert issues a cert against the test-stack's local issuer
|
// crlE2ECert tracks the certctl-side ID + the parsed leaf together. The
|
||||||
// and returns the parsed cert + PEM + hex serial. Implementation
|
// revoke endpoint is keyed by the certctl certificate ID (mc-*), not by
|
||||||
// reuses the existing integration_test.go::createCertificate path —
|
// the X.509 serial — so the test threads both through the helpers.
|
||||||
// adapt the body to whatever helper is in scope by the time CI runs
|
type crlE2ECert struct {
|
||||||
// this. For brevity, the stub here documents the contract; the
|
CertctlID string // e.g. "mc-crl-e2e-<n>"
|
||||||
// implementer can replace the body with the actual API calls once
|
Leaf *x509.Certificate // parsed leaf
|
||||||
// the integration_test.go primitives are read in full.
|
HexSerial string // lowercase hex of Leaf.SerialNumber, no leading zero stripping
|
||||||
func issueLocalCert(t *testing.T, commonName string) (cert *x509.Certificate, certPEM string, hexSerial string) {
|
PEMChain string // raw pem_chain string from versions endpoint
|
||||||
t.Helper()
|
IssuerCA *x509.Certificate // parsed issuer CA (chain[1] when present, else chain[0])
|
||||||
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
|
// crlE2ECerts holds the in-flight cert-ID → cert mapping so revokeCertViaAPI
|
||||||
// equivalent path in the existing integration suite). Stub for now.
|
// 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) {
|
func revokeCertViaAPI(t *testing.T, hexSerial string, reason string) {
|
||||||
t.Helper()
|
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
|
// 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
|
return parsed, parsed.Certificate
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchCACert fetches the CA cert PEM via the existing
|
// fetchCACert returns the issuing CA certificate for the given issuer.
|
||||||
// /.well-known/pki/cacert/ or equivalent endpoint. Stub for now;
|
//
|
||||||
// implementer wires to the real path when fleshing out.
|
// 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 {
|
func fetchCACert(t *testing.T, issuerID string) *x509.Certificate {
|
||||||
t.Helper()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,28 +447,43 @@ func certHasOCSPNoCheck(cert *x509.Certificate) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireServerReady, serverBaseURL, httpClient — these helpers exist
|
// requireServerReady polls /health until it returns 200, or t.Fatals after
|
||||||
// in integration_test.go's harness. Local stubs here simply skip
|
// 30s. The endpoint is unauthenticated (router.go pins it as a Bearer-free
|
||||||
// when called outside a configured stack, so this file compiles
|
// liveness route for K8s/Docker probes) so it doubles as a "is the test
|
||||||
// standalone in the sandbox where `go vet ./deploy/test/...` runs
|
// stack up?" probe before the suite makes its first authenticated call.
|
||||||
// without the full integration env.
|
|
||||||
func requireServerReady(t *testing.T) {
|
func requireServerReady(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := pem.Decode(nil); err != nil {
|
client := newUnauthHTTPClient()
|
||||||
// no-op reference to keep imports tidy
|
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
|
||||||
}
|
}
|
||||||
t.Skip("TODO: wire to integration_test.go::requireServerReady (or replace with the existing helper)")
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
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 {
|
func serverBaseURL(t *testing.T) string {
|
||||||
t.Helper()
|
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 {
|
func httpClient(t *testing.T) *http.Client {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// The existing integration suite has a TLS-trust-aware client; reuse
|
return newUnauthHTTPClient()
|
||||||
// it when integrating fully. The stub here returns a plain client
|
|
||||||
// so the test compiles standalone.
|
|
||||||
return &http.Client{Timeout: 30 * time.Second}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user