Files
skills/docs/testkit/testing/testing-integration-step-ca/src/fixture/step-ca-fixture.md
2026-05-29 08:20:56 +01:00

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 into ca.json, captures the root fingerprint, and execs 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

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 script execs 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.