Files
suaveplan/packages/testkit/testing/testing-integration-step-ca/README.md
2026-05-27 23:47:53 +01:00

7.0 KiB

@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 — parsePemBlocks is a small, dependency-free PEM splitter used by StepCaFixture.issueCert() to separate a leaf certif…
  • step-ca-fixture — 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<StepCaConfig> 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 execs 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

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

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<StepCaConfig>

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<StepCaConfig>

Returns { caUrl, rootCertPem, rootCertPath, fingerprint, provisionerName, provisionerPassword }. In container mode, also materialises rootCertPath to a host-side tmpfile.

stop(): Promise<void>

Stops the container (idempotent) and unlinks the host-side root-cert tmpfile (best-effort).

healthCheck(): Promise<boolean>

GET https://localhost:<mappedPort>/health with TLS verification disabled. Returns false when no container has been started or on any network error.

issueCert(opts: IssueCertOptions): Promise<IssuedCert>

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 "<initScript>") and execs 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.