4.9 KiB
StepCaFixture
Purpose
StepCaFixture manages the full lifecycle of a smallstep/step-ca container for integration tests that need a real PKI chain — WebRTC, secure SMTPS/IMAPS, HTTPS edges (Caddy/nginx), and any scenario that refuses non-secure contexts. Implementing the ServiceFixture<StepCaConfig> contract from @suaveplan/testing-integration, it can be composed into a TestEnvironment alongside Mailpit, Garage S3, Postgres, Redis, and other fixtures, with parallel startup and reverse-order teardown.
Features
- 5-var env-bridge passthrough mode (all of
STEPCA_URL,STEPCA_ROOT_CERT_PEM,STEPCA_ROOT_CERT_PATH,STEPCA_FINGERPRINT,STEPCA_PROVISIONER_PASSWORD); a partial set (4 of 5) falls through to container mode. - Bake-init shell entrypoint runs
step ca init, generates an EC P-256 JWK provisioner, merges it intoca.json, captures the root fingerprint, andexecsstep-ca— all in one container start. - Host-side root-cert tmpfile (
rootCertPath) materialised after health passes; suitable forNODE_EXTRA_CA_CERTS, Playwright trust args, or volume-mounting into sibling containers. - HTTPS health probe with TLS verification disabled (root is not yet trusted on the host).
issueCert({ commonName, sans?, ttl? })shellsstep ca certificateagainst the JWK provisioner; returns leaf + chain + key + serial.- Injectable
containerFactory,execFn, andfetchFnso the fixture itself is unit-testable at 100% Istanbul coverage.
Usage: basic example
import { StepCaFixture } from "@suaveplan/testing-integration-step-ca";
const ca = new StepCaFixture();
const cfg = await ca.start();
console.log(cfg.fingerprint); // 64-hex SHA-256 of the root
console.log(cfg.rootCertPath); // host-side tmpfile path
const { certPem } = await ca.issueCert({ commonName: "host.test" });
await ca.stop();
Usage: advanced example
import { TestEnvironment } from "@suaveplan/testing-integration";
import { StepCaFixture } from "@suaveplan/testing-integration-step-ca";
const ca = new StepCaFixture({
caName: "Edge Test CA",
dnsNames: "localhost,step-ca,edge.test",
provisionerName: "api",
healthTimeoutMs: 30_000,
});
const env = new TestEnvironment([ca]);
await env.startAll();
const cfg = await ca.start();
process.env.NODE_EXTRA_CA_CERTS = cfg.rootCertPath;
const leaf = await ca.issueCert({
commonName: "edge.test",
sans: ["edge.test", "edge.local"],
ttl: "24h",
});
await env.stopAll();
API reference
new StepCaFixture(options?: StepCaFixtureOptions)
StepCaFixtureOptions accepts image, caName, dnsNames, provisionerName, healthTimeoutMs, healthPollIntervalMs, plus the injectable test seams containerFactory, execFn, fetchFn.
start(): Promise<StepCaConfig>
In env-bridge mode: returns the env-supplied config without starting a container. In container mode: starts the container with the bake-init entrypoint, polls /health until ok, reads root_ca.crt + secrets/fingerprint via exec, materialises the host-side tmpfile, and returns { caUrl, rootCertPem, rootCertPath, fingerprint, provisionerName, provisionerPassword }. Throws on container failure; if waitForHealth() rejects the container is stopped and the reference cleared before the error propagates.
stop(): Promise<void>
Idempotent. Removes the host-side root-cert tmpfile (best-effort) and stops the container exactly once.
healthCheck(): Promise<boolean>
GET https://localhost:<mappedPort>/health via the injected fetchFn (default uses node:https.request with rejectUnauthorized: false). Returns false when no container is running, when the response is not ok, or on any network error.
issueCert(opts: IssueCertOptions): Promise<IssuedCert>
Empty commonName → throws INTEGRATION.STEP_CA.ISSUE_CERT_FAILED without invoking exec. Non-zero step exit → INTEGRATION.STEP_CA.ISSUE_CERT_FAILED with stderr (or stdout fallback) attached as cause. Malformed PEM or step certificate inspect JSON failure → INTEGRATION.STEP_CA.PARSE_PEM_FAILED.
Implementation notes
- The container runs init+serve in a single entrypoint (
sh -c "<initScript>"); the scriptexecsstep-caso the running PID has the daemon, not the shell. Health passing therefore implies init has fully completed. caName,dnsNames,provisionerNameare validated against^[A-Za-z0-9 _.-]{1,128}$and JSON-escaped viaJSON.stringifybefore interpolation into the entrypoint script. This blocks shell-metacharacter injection at the caller boundary.- The provisioner password is generated with
crypto.randomBytes(24).toString("hex")(192 bits of entropy). parsePemBlocks(sibling helper) is the only PEM-aware code;issueCertdelegates leaf/chain splitting to it.- The
defaultHealthFetchandcontainer.execfallback paths are marked/* istanbul ignore next */— they're only exercisable in the integration smoke test (gated byINTEGRATION_DOCKER=1), not unit tests.