first commit
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# 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 `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<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 `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.
|
||||
Reference in New Issue
Block a user