# 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` 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 into `ca.json`, captures the root fingerprint, and `exec`s `step-ca` — all in one container start. - Host-side root-cert tmpfile (`rootCertPath`) materialised after health passes; suitable for `NODE_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? })` shells `step ca certificate` against the JWK provisioner; returns leaf + chain + key + serial. - Injectable `containerFactory`, `execFn`, and `fetchFn` so the fixture itself is unit-testable at 100% Istanbul coverage. ## Usage: basic example ```ts 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 ```ts 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` 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` Idempotent. Removes the host-side root-cert tmpfile (best-effort) and stops the container exactly once. ### `healthCheck(): Promise` `GET https://localhost:/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` 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 ""`); the script `exec`s `step-ca` so the running PID has the daemon, not the shell. Health passing therefore implies init has fully completed. - `caName`, `dnsNames`, `provisionerName` are validated against `^[A-Za-z0-9 _.-]{1,128}$` and JSON-escaped via `JSON.stringify` before 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; `issueCert` delegates leaf/chain splitting to it. - The `defaultHealthFetch` and `container.exec` fallback paths are marked `/* istanbul ignore next */` — they're only exercisable in the integration smoke test (gated by `INTEGRATION_DOCKER=1`), not unit tests.