# @suaveplan/testing-integration-step-ca - smallstep/step-ca testcontainers fixture for @suaveplan/testing-integration — real PKI for HTTPS/WebRTC integration tests ## Summary A `ServiceFixture` for `smallstep/step-ca` that gives any integration test suite a real, ephemeral certificate authority — root cert, JWK provisioner, and an `issueCert()` helper that mints leaf certificates on demand. ## Documentation tree - [parse-pem-blocks](./src/fixture/parse-pem-blocks.md) — `parsePemBlocks` is a small, dependency-free PEM splitter used by `StepCaFixture.issueCert()` to separate a leaf certif… - [step-ca-fixture](./src/fixture/step-ca-fixture.md) — `StepCaFixture` manages the full lifecycle of a `smallstep/step-ca` container for integration tests that need a real PK… ## Purpose WebRTC, secure SMTPS/IMAPS, HTTPS edges (Caddy/nginx), and other integration scenarios refuse non-secure contexts. Without a real PKI chain you end up either rolling per-suite self-signed certs or sprinkling `rejectUnauthorized: false` everywhere. This fixture replaces both with a single `await fixture.start()` that returns a config with a trusted root cert, a fingerprint, and an `issueCert()` method backed by a JWK provisioner inside the container. ## Features - `ServiceFixture` implementation matching the `@suaveplan/testing-integration` contract — drops into `TestEnvironment` alongside `mailpit`, `garage-s3`, `postgres`, `redis`. - 5-var env-bridge passthrough mode (`STEPCA_URL`, `STEPCA_ROOT_CERT_PEM`, `STEPCA_ROOT_CERT_PATH`, `STEPCA_FINGERPRINT`, `STEPCA_PROVISIONER_PASSWORD`); partial sets (4 of 5) fall through to container mode. - Bake-init shell entrypoint runs `step ca init`, creates 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`) automatically materialised, available for `NODE_EXTRA_CA_CERTS`, Playwright `--ignore-certificate-errors-spki-list`, or volume-mounting into sibling containers. - `issueCert({ commonName, sans?, ttl? })` shells `step ca certificate` against the JWK provisioner; returns `{ certPem, chainPem, keyPem, serial }`. - Injectable `containerFactory`, `execFn`, and `fetchFn` for unit-testability of the fixture itself (100% Istanbul coverage on touched modules). - HTTPS health probe with TLS verification disabled (root not yet trusted on the host). ## Basic example ```ts import { StepCaFixture } from "@suaveplan/testing-integration-step-ca"; const ca = new StepCaFixture(); const config = await ca.start(); // Issue a leaf cert for our test server const leaf = await ca.issueCert({ commonName: "host.test", sans: ["host.test", "localhost"], ttl: "24h", }); // Use the root in a Node TLS context import { createSecureContext } from "node:tls"; const trust = createSecureContext({ ca: config.rootCertPem }); await ca.stop(); ``` ## Advanced example — Playwright + secure context ```ts import { TestEnvironment } from "@suaveplan/testing-integration"; import { StepCaFixture } from "@suaveplan/testing-integration-step-ca"; import { chromium } from "playwright"; const ca = new StepCaFixture(); const env = new TestEnvironment([ca]); await env.startAll(); const cfg = ca.start ? await ca.start() : null; // Mount the root into a sibling container or load it into the test process: process.env.NODE_EXTRA_CA_CERTS = cfg!.rootCertPath; // Issue an HTTPS cert for our app server const { certPem, keyPem } = await ca.issueCert({ commonName: "app.test", sans: ["app.test"], ttl: "1h", }); // Launch Playwright with the root trusted (Chromium will follow the system // store; for tests, --ignore-certificate-errors-spki-list works too) const browser = await chromium.launch({ args: [`--ignore-certificate-errors-spki-list=${cfg!.fingerprint}`], }); await env.stopAll(); ``` ## API reference ### `class StepCaFixture implements ServiceFixture` #### `new StepCaFixture(options?: StepCaFixtureOptions)` Options: - `image?: string` — override the Docker image. Defaults to `STEPCA_IMAGE` (a pinned `smallstep/step-ca` tag). - `caName?: string` — CA display name. Defaults to `"Suaveplan Test CA"`. - `dnsNames?: string` — comma-separated DNS SANs for the CA. Defaults to `"localhost,step-ca"`. - `provisionerName?: string` — JWK provisioner name. Defaults to `"api"`. - `healthTimeoutMs?: number` / `healthPollIntervalMs?: number` — override health-poll timing. - `containerFactory?`, `execFn?`, `fetchFn?` — injection points for unit-testing the fixture itself. #### `start(): Promise` Returns `{ caUrl, rootCertPem, rootCertPath, fingerprint, provisionerName, provisionerPassword }`. In container mode, also materialises `rootCertPath` to a host-side tmpfile. #### `stop(): Promise` Stops the container (idempotent) and unlinks the host-side root-cert tmpfile (best-effort). #### `healthCheck(): Promise` `GET https://localhost:/health` with TLS verification disabled. Returns `false` when no container has been started or on any network error. #### `issueCert(opts: IssueCertOptions): Promise` Issues a leaf via the JWK provisioner. Throws `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` on empty `commonName`, non-zero `step` exit, or read failures; throws `INTEGRATION.STEP_CA.PARSE_PEM_FAILED` on malformed PEM or invalid `step certificate inspect` JSON. ### `parsePemBlocks(text: string): { leaf: string; chain: string }` Splits a PEM payload into the first `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` block (`leaf`) and the concatenation of remaining blocks (`chain`). Throws on empty input, non-PEM input, or unterminated blocks. ### `buildStepCaInitScript(args)` Builds the bake-init shell script used as the container entrypoint. Exported for inspection in tests; not normally consumed directly. ## Implementation notes - The container runs all init steps as part of its entrypoint (`sh -c ""`) and `exec`s `step-ca` so health passes only after init has completed. The fixture then reads `root_ca.crt` and `secrets/fingerprint` via `container.exec`. - `caName`, `dnsNames`, and `provisionerName` are validated against `^[A-Za-z0-9 _.-]{1,128}$` before being interpolated into the entrypoint script. Special characters are JSON-escaped via `JSON.stringify` so a malformed override cannot inject shell metacharacters. - The default health-fetch uses `node:https.request` with `rejectUnauthorized: false`. The host trust store does not yet contain the test root, so TLS verification on the health probe must be off; this is the fixture's only HTTPS read of an untrusted endpoint. - `parsePemBlocks` is a pure helper with no dependency on `step-ca` and is reused by `issueCert()` for splitting leaf-from-chain. - Coverage gate: 100% Istanbul on every non-`*.types.ts`, non-barrel module. Container/`fetch` defaults are excluded via `/* istanbul ignore next */` because they're only exercisable in the integration smoke test.