Initial Commit
This commit is contained in:
126
packages/testkit/testing/testing-integration-step-ca/README.md
Normal file
126
packages/testkit/testing/testing-integration-step-ca/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# @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.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## 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…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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 `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<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 `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.
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@suaveplan/testing-integration-step-ca",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "smallstep/step-ca testcontainers fixture for @suaveplan/testing-integration — real PKI for HTTPS/WebRTC integration tests",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./fixture/step-ca-fixture": {
|
||||
"types": "./dist/fixture/step-ca-fixture.d.ts",
|
||||
"default": "./dist/fixture/step-ca-fixture.js"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"testcontainers": ">=11.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"testcontainers": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"testcontainers": "catalog:containers",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Default Docker image for smallstep step-ca. Pinned to v0.28.x line. */
|
||||
export const STEPCA_IMAGE = "smallstep/step-ca:0.28.4";
|
||||
|
||||
/** Internal HTTPS port exposed by step-ca. */
|
||||
export const STEPCA_INTERNAL_PORT = 8443;
|
||||
|
||||
/** Default JWK provisioner name. */
|
||||
export const STEPCA_DEFAULT_PROVISIONER = "api";
|
||||
|
||||
/** Default DNS SANs for the CA itself. */
|
||||
export const STEPCA_DEFAULT_DNS = "localhost,step-ca";
|
||||
|
||||
/** Default CA display name. */
|
||||
export const STEPCA_DEFAULT_NAME = "Suaveplan Test CA";
|
||||
|
||||
/** Maximum time to wait for step-ca to become healthy. */
|
||||
export const STEPCA_HEALTH_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Health check poll interval in milliseconds. */
|
||||
export const STEPCA_HEALTH_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Log line emitted by `step-ca` once its HTTPS listener has bound, after
|
||||
* `step ca init`, JWK provisioner setup, and fingerprint capture have all
|
||||
* completed. Used as a Bun-friendly wait strategy because testcontainers'
|
||||
* default `Wait.forListeningPorts()` hangs under Bun (oven-sh/bun#21342).
|
||||
*/
|
||||
export const STEPCA_READY_LOG = "Serving HTTPS on";
|
||||
|
||||
/**
|
||||
* Number of occurrences of {@link STEPCA_READY_LOG} to wait for during a
|
||||
* single startup. step-ca emits the line exactly once when its HTTPS
|
||||
* listener binds. Tracked alongside oven-sh/bun#21342.
|
||||
*/
|
||||
export const STEPCA_READY_LOG_TIMES = 1;
|
||||
|
||||
/**
|
||||
* Env vars checked for passthrough mode — ALL must be set and non-empty
|
||||
* for the fixture to skip starting a container. A partial set (4 of 5)
|
||||
* falls through to container mode.
|
||||
*/
|
||||
export const STEPCA_ENV_VARS = [
|
||||
"STEPCA_URL",
|
||||
"STEPCA_ROOT_CERT_PEM",
|
||||
"STEPCA_ROOT_CERT_PATH",
|
||||
"STEPCA_FINGERPRINT",
|
||||
"STEPCA_PROVISIONER_PASSWORD",
|
||||
] as const;
|
||||
@@ -0,0 +1,16 @@
|
||||
export { parsePemBlocks } from "./parse-pem-blocks.js";
|
||||
export type { HealthFetch } from "./step-ca-fixture.js";
|
||||
export {
|
||||
buildStepCaInitScript,
|
||||
classifyHealthStatus,
|
||||
defaultHealthFetch,
|
||||
STEPCA_INIT_COMMAND_TOKENS,
|
||||
StepCaFixture,
|
||||
} from "./step-ca-fixture.js";
|
||||
export type {
|
||||
ExecResult,
|
||||
IssueCertOptions,
|
||||
IssuedCert,
|
||||
StepCaConfig,
|
||||
StepCaFixtureOptions,
|
||||
} from "./step-ca-fixture.types.js";
|
||||
@@ -0,0 +1,63 @@
|
||||
# parsePemBlocks
|
||||
|
||||
## Purpose
|
||||
|
||||
`parsePemBlocks` is a small, dependency-free PEM splitter used by `StepCaFixture.issueCert()` to separate a leaf certificate from its chain in a single concatenated PEM payload. The function is exported standalone because the same split is useful in any code that consumes `step ca certificate` output, where the leaf is followed by zero or more intermediate certs.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — no I/O, no globals, no dependencies.
|
||||
- Returns `{ leaf, chain }` where `leaf` is the first `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` block (inclusive of markers) and `chain` is the concatenation of every subsequent block separated by a single newline.
|
||||
- Tolerant of arbitrary text between blocks (comments, blank lines, headers).
|
||||
- Strict failures: empty / non-PEM input throws `"no PEM CERTIFICATE blocks found"`; a `BEGIN CERTIFICATE` line lacking a matching `END CERTIFICATE` throws `"unterminated PEM block"`.
|
||||
|
||||
## Usage: basic example
|
||||
|
||||
```ts
|
||||
import { parsePemBlocks } from "@suaveplan/testing-integration-step-ca/fixture/step-ca-fixture";
|
||||
|
||||
const payload = `-----BEGIN CERTIFICATE-----
|
||||
LEAF
|
||||
-----END CERTIFICATE-----`;
|
||||
|
||||
const { leaf, chain } = parsePemBlocks(payload);
|
||||
// leaf = full BEGIN/END block; chain = ""
|
||||
```
|
||||
|
||||
## Usage: advanced example
|
||||
|
||||
```ts
|
||||
const payload = [
|
||||
"# Issued at 2026-04-28T10:33Z",
|
||||
"-----BEGIN CERTIFICATE-----",
|
||||
"LEAFLEAF",
|
||||
"-----END CERTIFICATE-----",
|
||||
"",
|
||||
"-----BEGIN CERTIFICATE-----",
|
||||
"INTERMEDIATE",
|
||||
"-----END CERTIFICATE-----",
|
||||
"-----BEGIN CERTIFICATE-----",
|
||||
"ROOT",
|
||||
"-----END CERTIFICATE-----",
|
||||
].join("\n");
|
||||
|
||||
const { leaf, chain } = parsePemBlocks(payload);
|
||||
// leaf = "-----BEGIN CERTIFICATE-----\nLEAFLEAF\n-----END CERTIFICATE-----"
|
||||
// chain = "<INTERMEDIATE block>\n<ROOT block>"
|
||||
```
|
||||
|
||||
## API reference
|
||||
|
||||
### `parsePemBlocks(text: string): { leaf: string; chain: string }`
|
||||
|
||||
- **Parameters:** `text` — the raw PEM payload to split.
|
||||
- **Returns:** `{ leaf, chain }`. `chain` is the empty string when the payload contains exactly one block.
|
||||
- **Throws:** `Error("StepCaFixture.parsePemBlocks: no PEM CERTIFICATE blocks found")` — payload contains no `BEGIN CERTIFICATE` line.
|
||||
- **Throws:** `Error("StepCaFixture.parsePemBlocks: unterminated PEM block")` — payload has a `BEGIN` but no matching `END`.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- The implementation walks the payload by `indexOf("-----BEGIN CERTIFICATE-----")` then `indexOf("-----END CERTIFICATE-----")` — O(n) over the input, no regex backtracking risk.
|
||||
- Blocks are sliced inclusive of both markers, so the returned strings are well-formed PEM and can be fed straight to `crypto.X509Certificate` or `tls.createSecureContext`.
|
||||
- The chain separator is a single `\n` (not `\r\n`); callers that need OS-specific line endings should normalise after splitting.
|
||||
- This function deliberately does NOT validate the base64 body or enforce 64-column wrapping — `step` and Node's TLS stack will reject malformed bodies on use, and tests of the splitter should not double-up on cryptographic validation.
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Parses PEM CERTIFICATE blocks from a text payload, splitting the first
|
||||
* block from the remaining (chain) blocks.
|
||||
*
|
||||
* @returns `{ leaf, chain }` where `leaf` is the first BEGIN/END CERTIFICATE
|
||||
* block and `chain` is the remaining blocks concatenated with a single
|
||||
* newline separator. If only one block is present, `chain` is `""`.
|
||||
* @throws if no well-formed block is found, or if a `BEGIN CERTIFICATE` line
|
||||
* lacks a matching `END CERTIFICATE` line.
|
||||
*/
|
||||
export function parsePemBlocks(text: string): {
|
||||
leaf: string;
|
||||
chain: string;
|
||||
} {
|
||||
const beginMarker = "-----BEGIN CERTIFICATE-----";
|
||||
const endMarker = "-----END CERTIFICATE-----";
|
||||
const blocks: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const begin = text.indexOf(beginMarker, cursor);
|
||||
if (begin === -1) {
|
||||
break;
|
||||
}
|
||||
const end = text.indexOf(endMarker, begin + beginMarker.length);
|
||||
if (end === -1) {
|
||||
throw new Error(
|
||||
"StepCaFixture.parsePemBlocks: unterminated PEM block"
|
||||
);
|
||||
}
|
||||
const blockEnd = end + endMarker.length;
|
||||
blocks.push(text.slice(begin, blockEnd));
|
||||
cursor = blockEnd;
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
throw new Error(
|
||||
"StepCaFixture.parsePemBlocks: no PEM CERTIFICATE blocks found"
|
||||
);
|
||||
}
|
||||
|
||||
const leaf = blocks[0] as string;
|
||||
const chain = blocks.slice(1).join("\n");
|
||||
return { leaf, chain };
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { STEPCA_ENV_VARS } from "../constants/index.js";
|
||||
import { StepCaFixture } from "./step-ca-fixture.js";
|
||||
|
||||
const ROOT_PEM = "-----BEGIN CERTIFICATE-----\nROOT\n-----END CERTIFICATE-----";
|
||||
const LEAF_PEM = "-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----";
|
||||
const CHAIN_PEM =
|
||||
"-----BEGIN CERTIFICATE-----\nINTERMEDIATE\n-----END CERTIFICATE-----";
|
||||
const KEY_PEM =
|
||||
"-----BEGIN EC PRIVATE KEY-----\nKEY\n-----END EC PRIVATE KEY-----";
|
||||
|
||||
interface FakeContainer {
|
||||
withExposedPorts: (...ports: number[]) => FakeContainer;
|
||||
withCommand: (cmd: string[]) => FakeContainer;
|
||||
withWaitStrategy: (strategy: unknown) => FakeContainer;
|
||||
start: () => Promise<{
|
||||
getMappedPort: (p: number) => number;
|
||||
stop: () => Promise<void>;
|
||||
}>;
|
||||
}
|
||||
|
||||
function makeFakeFactory(): (image: string) => FakeContainer {
|
||||
return () => {
|
||||
const c: FakeContainer = {
|
||||
withExposedPorts: () => c,
|
||||
withCommand: () => c,
|
||||
withWaitStrategy: function () {
|
||||
return this;
|
||||
},
|
||||
start: async () => ({
|
||||
getMappedPort: () => 18443,
|
||||
stop: async () => {
|
||||
/* noop */
|
||||
},
|
||||
}),
|
||||
};
|
||||
return c;
|
||||
};
|
||||
}
|
||||
|
||||
async function bootFixture(
|
||||
execFn: ReturnType<typeof vi.fn>
|
||||
): Promise<StepCaFixture> {
|
||||
for (const v of STEPCA_ENV_VARS) delete process.env[v];
|
||||
const factory = makeFakeFactory();
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await fixture.start();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
describe("issueCert", () => {
|
||||
it("happy path returns leaf, chain, key, serial", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: `${LEAF_PEM}\n${CHAIN_PEM}`, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "certificate" &&
|
||||
cmd[2] === "inspect"
|
||||
)
|
||||
return {
|
||||
stdout: JSON.stringify({ serial_number: "1234ABCD" }),
|
||||
exitCode: 0,
|
||||
};
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
const result = await fixture.issueCert({ commonName: "host.test" });
|
||||
expect(result.certPem).toBe(LEAF_PEM);
|
||||
expect(result.chainPem).toBe(CHAIN_PEM);
|
||||
expect(result.keyPem).toBe(KEY_PEM);
|
||||
expect(result.serial).toBe("1234ABCD");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("happy path with serial fallback to .serial field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: LEAF_PEM, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "certificate" &&
|
||||
cmd[2] === "inspect"
|
||||
)
|
||||
return {
|
||||
stdout: JSON.stringify({ serial: "00FF" }),
|
||||
exitCode: 0,
|
||||
};
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
const result = await fixture.issueCert({ commonName: "host.test" });
|
||||
expect(result.serial).toBe("00FF");
|
||||
expect(result.chainPem).toBe("");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("SAN entries appear in argv in order", { timeout: 5000 }, async () => {
|
||||
let issueArgv: string[] = [];
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
) {
|
||||
issueArgv = cmd;
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
}
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: LEAF_PEM, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "certificate" &&
|
||||
cmd[2] === "inspect"
|
||||
)
|
||||
return {
|
||||
stdout: JSON.stringify({ serial_number: "1" }),
|
||||
exitCode: 0,
|
||||
};
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await fixture.issueCert({
|
||||
commonName: "host.test",
|
||||
sans: ["a.test", "b.test"],
|
||||
});
|
||||
const aIndex = issueArgv.indexOf("a.test");
|
||||
const bIndex = issueArgv.indexOf("b.test");
|
||||
expect(aIndex).toBeGreaterThan(0);
|
||||
expect(bIndex).toBeGreaterThan(aIndex);
|
||||
expect(issueArgv[aIndex - 1]).toBe("--san");
|
||||
expect(issueArgv[bIndex - 1]).toBe("--san");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("ttl appears as --not-after=<ttl>", { timeout: 5000 }, async () => {
|
||||
let issueArgv: string[] = [];
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
) {
|
||||
issueArgv = cmd;
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
}
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: LEAF_PEM, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "certificate" &&
|
||||
cmd[2] === "inspect"
|
||||
)
|
||||
return {
|
||||
stdout: JSON.stringify({ serial_number: "1" }),
|
||||
exitCode: 0,
|
||||
};
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await fixture.issueCert({ commonName: "host.test", ttl: "24h" });
|
||||
expect(issueArgv).toContain("--not-after=24h");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("empty commonName rejects without invoking execFn (ISSUE_CERT_FAILED)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
const callsBefore = execFn.mock.calls.length;
|
||||
await expect(fixture.issueCert({ commonName: "" })).rejects.toThrow(
|
||||
/ISSUE_CERT_FAILED/
|
||||
);
|
||||
await expect(fixture.issueCert({ commonName: " " })).rejects.toThrow(
|
||||
/ISSUE_CERT_FAILED/
|
||||
);
|
||||
expect(execFn.mock.calls.length).toBe(callsBefore);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("rejects when fixture not started", { timeout: 5000 }, async () => {
|
||||
const fixture = new StepCaFixture();
|
||||
await expect(fixture.issueCert({ commonName: "x" })).rejects.toThrow(
|
||||
/fixture not started/
|
||||
);
|
||||
});
|
||||
|
||||
it("non-zero exit on issue rejects with ISSUE_CERT_FAILED + stderr cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "", exitCode: 1, stderr: "denied" };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
const err = await fixture
|
||||
.issueCert({ commonName: "host.test" })
|
||||
.catch((e: unknown) => e as Error);
|
||||
expect((err as Error).message).toMatch(/ISSUE_CERT_FAILED/);
|
||||
expect((err as Error & { cause?: unknown }).cause).toBe("denied");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("non-zero exit on issue without stderr falls back to stdout", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "stdout-msg", exitCode: 1 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
const err = await fixture
|
||||
.issueCert({ commonName: "host.test" })
|
||||
.catch((e: unknown) => e as Error);
|
||||
expect((err as Error & { cause?: unknown }).cause).toBe("stdout-msg");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("non-zero exit on cert read rejects", { timeout: 5000 }, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: "", exitCode: 1 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await expect(
|
||||
fixture.issueCert({ commonName: "host.test" })
|
||||
).rejects.toThrow(/failed to read issued cert\/key/);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("non-zero exit on key read rejects", { timeout: 5000 }, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: LEAF_PEM, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: "", exitCode: 1 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await expect(
|
||||
fixture.issueCert({ commonName: "host.test" })
|
||||
).rejects.toThrow(/failed to read issued cert\/key/);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("malformed PEM rejects with PARSE_PEM_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: "not a pem", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await expect(
|
||||
fixture.issueCert({ commonName: "host.test" })
|
||||
).rejects.toThrow(/PARSE_PEM_FAILED/);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("non-zero exit on inspect rejects with ISSUE_CERT_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: LEAF_PEM, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "certificate" &&
|
||||
cmd[2] === "inspect"
|
||||
)
|
||||
return { stdout: "", exitCode: 1 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await expect(
|
||||
fixture.issueCert({ commonName: "host.test" })
|
||||
).rejects.toThrow(/step certificate inspect exited non-zero/);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("invalid JSON from inspect rejects with PARSE_PEM_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: LEAF_PEM, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "certificate" &&
|
||||
cmd[2] === "inspect"
|
||||
)
|
||||
return { stdout: "not json", exitCode: 0 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await expect(
|
||||
fixture.issueCert({ commonName: "host.test" })
|
||||
).rejects.toThrow(/PARSE_PEM_FAILED/);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("missing serial fields in inspect JSON rejects with PARSE_PEM_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const execFn = vi.fn(async (cmd: string[]) => {
|
||||
const key = cmd.join(" ");
|
||||
if (key.includes("cat /home/step/certs/root_ca.crt"))
|
||||
return { stdout: ROOT_PEM, exitCode: 0 };
|
||||
if (key.includes("cat /home/step/secrets/fingerprint"))
|
||||
return { stdout: "abcd", exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "ca" &&
|
||||
cmd[2] === "certificate"
|
||||
)
|
||||
return { stdout: "issued", exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".crt"))
|
||||
return { stdout: LEAF_PEM, exitCode: 0 };
|
||||
if (cmd[0] === "cat" && (cmd[1] ?? "").endsWith(".key"))
|
||||
return { stdout: KEY_PEM, exitCode: 0 };
|
||||
if (
|
||||
cmd[0] === "step" &&
|
||||
cmd[1] === "certificate" &&
|
||||
cmd[2] === "inspect"
|
||||
)
|
||||
return { stdout: "{}", exitCode: 0 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
const fixture = await bootFixture(execFn);
|
||||
await expect(
|
||||
fixture.issueCert({ commonName: "host.test" })
|
||||
).rejects.toThrow(/PARSE_PEM_FAILED/);
|
||||
await fixture.stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePemBlocks } from "./parse-pem-blocks.js";
|
||||
|
||||
const A = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----";
|
||||
const B = "-----BEGIN CERTIFICATE-----\nBBBB\n-----END CERTIFICATE-----";
|
||||
const C = "-----BEGIN CERTIFICATE-----\nCCCC\n-----END CERTIFICATE-----";
|
||||
|
||||
describe("parsePemBlocks", () => {
|
||||
it("single block returns leaf with empty chain", { timeout: 5000 }, () => {
|
||||
const { leaf, chain } = parsePemBlocks(A);
|
||||
expect(leaf).toBe(A);
|
||||
expect(chain).toBe("");
|
||||
});
|
||||
|
||||
it("multi-block returns first as leaf, rest as chain in order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { leaf, chain } = parsePemBlocks(`${A}\n${B}\n${C}`);
|
||||
expect(leaf).toBe(A);
|
||||
expect(chain).toBe(`${B}\n${C}`);
|
||||
});
|
||||
|
||||
it("ignores text between blocks", { timeout: 5000 }, () => {
|
||||
const { leaf, chain } = parsePemBlocks(
|
||||
`# leading comment\n${A}\n# middle\n${B}\n# trailing`
|
||||
);
|
||||
expect(leaf).toBe(A);
|
||||
expect(chain).toBe(B);
|
||||
});
|
||||
|
||||
it("throws on empty input", { timeout: 5000 }, () => {
|
||||
expect(() => parsePemBlocks("")).toThrow(
|
||||
/no PEM CERTIFICATE blocks found/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when no BEGIN CERTIFICATE present", { timeout: 5000 }, () => {
|
||||
expect(() => parsePemBlocks("just some random text")).toThrow(
|
||||
/no PEM CERTIFICATE blocks found/
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on unterminated block", { timeout: 5000 }, () => {
|
||||
const truncated = "-----BEGIN CERTIFICATE-----\nAAAA";
|
||||
expect(() => parsePemBlocks(truncated)).toThrow(
|
||||
/unterminated PEM block/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -0,0 +1,784 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { TestEnvironmentError } from "@suaveplan/testing-integration";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
STEPCA_DEFAULT_PROVISIONER,
|
||||
STEPCA_ENV_VARS,
|
||||
STEPCA_IMAGE,
|
||||
STEPCA_INTERNAL_PORT,
|
||||
} from "../constants/index.js";
|
||||
import {
|
||||
buildStepCaInitScript,
|
||||
classifyHealthStatus,
|
||||
defaultHealthFetch,
|
||||
STEPCA_INIT_COMMAND_TOKENS,
|
||||
StepCaFixture,
|
||||
} from "./step-ca-fixture.js";
|
||||
|
||||
interface FakeContainer {
|
||||
withExposedPorts: (...ports: number[]) => FakeContainer;
|
||||
withCommand: (cmd: string[]) => FakeContainer;
|
||||
withWaitStrategy: (strategy: unknown) => FakeContainer;
|
||||
start: () => Promise<FakeStartedContainer>;
|
||||
capturedCommand: string[] | null;
|
||||
}
|
||||
|
||||
interface FakeStartedContainer {
|
||||
getMappedPort: (p: number) => number;
|
||||
stop: () => Promise<void>;
|
||||
stopCalls: number;
|
||||
}
|
||||
|
||||
function makeFakeFactory(
|
||||
opts: { mappedPort?: number; stopImpl?: () => Promise<void> } = {}
|
||||
): {
|
||||
factory: (image: string) => FakeContainer;
|
||||
images: string[];
|
||||
started: FakeStartedContainer[];
|
||||
containers: FakeContainer[];
|
||||
} {
|
||||
const images: string[] = [];
|
||||
const started: FakeStartedContainer[] = [];
|
||||
const containers: FakeContainer[] = [];
|
||||
const factory = (image: string): FakeContainer => {
|
||||
images.push(image);
|
||||
const c: FakeContainer = {
|
||||
capturedCommand: null,
|
||||
withExposedPorts() {
|
||||
return c;
|
||||
},
|
||||
withCommand(cmd: string[]) {
|
||||
c.capturedCommand = cmd;
|
||||
return c;
|
||||
},
|
||||
withWaitStrategy: function () {
|
||||
return this;
|
||||
},
|
||||
async start() {
|
||||
const sc: FakeStartedContainer = {
|
||||
stopCalls: 0,
|
||||
getMappedPort: () => opts.mappedPort ?? 18443,
|
||||
stop:
|
||||
opts.stopImpl ??
|
||||
(async () => {
|
||||
sc.stopCalls += 1;
|
||||
}),
|
||||
};
|
||||
if (opts.stopImpl) {
|
||||
sc.stop = async () => {
|
||||
sc.stopCalls += 1;
|
||||
if (opts.stopImpl) await opts.stopImpl();
|
||||
};
|
||||
}
|
||||
started.push(sc);
|
||||
return sc;
|
||||
},
|
||||
};
|
||||
containers.push(c);
|
||||
return c;
|
||||
};
|
||||
return { factory, images, started, containers };
|
||||
}
|
||||
|
||||
const ROOT_PEM = "-----BEGIN CERTIFICATE-----\nROOT\n-----END CERTIFICATE-----";
|
||||
|
||||
function makeExecFn(
|
||||
scripts: Record<
|
||||
string,
|
||||
{ stdout: string; exitCode?: number; stderr?: string }
|
||||
>
|
||||
) {
|
||||
const calls: string[][] = [];
|
||||
const fn = vi.fn(async (cmd: string[]) => {
|
||||
calls.push(cmd);
|
||||
const key = cmd.join(" ");
|
||||
for (const k of Object.keys(scripts)) {
|
||||
if (key.includes(k)) {
|
||||
const {
|
||||
stdout,
|
||||
exitCode = 0,
|
||||
stderr,
|
||||
} = scripts[k] as {
|
||||
stdout: string;
|
||||
exitCode?: number;
|
||||
stderr?: string;
|
||||
};
|
||||
return { stdout, exitCode, stderr };
|
||||
}
|
||||
}
|
||||
return { stdout: "", exitCode: 0 };
|
||||
});
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
describe("StepCaFixture", () => {
|
||||
beforeEach(() => {
|
||||
for (const v of STEPCA_ENV_VARS) delete process.env[v];
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const v of STEPCA_ENV_VARS) delete process.env[v];
|
||||
});
|
||||
|
||||
it("name property returns step-ca", { timeout: 5000 }, () => {
|
||||
const fixture = new StepCaFixture();
|
||||
expect(fixture.name).toBe("step-ca");
|
||||
});
|
||||
|
||||
it("passthrough mode (all 5 vars set): returns config from env", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
process.env.STEPCA_URL = "https://ca.example";
|
||||
process.env.STEPCA_ROOT_CERT_PEM = ROOT_PEM;
|
||||
process.env.STEPCA_ROOT_CERT_PATH = "/tmp/root.pem";
|
||||
process.env.STEPCA_FINGERPRINT = "deadbeef";
|
||||
process.env.STEPCA_PROVISIONER_PASSWORD = "pw123";
|
||||
const fixture = new StepCaFixture();
|
||||
const config = await fixture.start();
|
||||
expect(config.caUrl).toBe("https://ca.example");
|
||||
expect(config.rootCertPem).toBe(ROOT_PEM);
|
||||
expect(config.rootCertPath).toBe("/tmp/root.pem");
|
||||
expect(config.fingerprint).toBe("deadbeef");
|
||||
expect(config.provisionerName).toBe(STEPCA_DEFAULT_PROVISIONER);
|
||||
expect(config.provisionerPassword).toBe("pw123");
|
||||
});
|
||||
|
||||
it("passthrough preserves caller-supplied provisionerName", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
process.env.STEPCA_URL = "https://ca.example";
|
||||
process.env.STEPCA_ROOT_CERT_PEM = ROOT_PEM;
|
||||
process.env.STEPCA_ROOT_CERT_PATH = "/tmp/root.pem";
|
||||
process.env.STEPCA_FINGERPRINT = "deadbeef";
|
||||
process.env.STEPCA_PROVISIONER_PASSWORD = "pw123";
|
||||
const fixture = new StepCaFixture({ provisionerName: "custom" });
|
||||
const config = await fixture.start();
|
||||
expect(config.provisionerName).toBe("custom");
|
||||
});
|
||||
|
||||
it("stop when passthrough mode is a no-op", { timeout: 5000 }, async () => {
|
||||
process.env.STEPCA_URL = "https://ca.example";
|
||||
process.env.STEPCA_ROOT_CERT_PEM = ROOT_PEM;
|
||||
process.env.STEPCA_ROOT_CERT_PATH = "/tmp/root.pem";
|
||||
process.env.STEPCA_FINGERPRINT = "deadbeef";
|
||||
process.env.STEPCA_PROVISIONER_PASSWORD = "pw123";
|
||||
const fixture = new StepCaFixture();
|
||||
await fixture.start();
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("partial passthrough (4 of 5 vars): falls through to container mode", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
process.env.STEPCA_URL = "https://ca.example";
|
||||
process.env.STEPCA_ROOT_CERT_PEM = ROOT_PEM;
|
||||
process.env.STEPCA_ROOT_CERT_PATH = "/tmp/root.pem";
|
||||
process.env.STEPCA_FINGERPRINT = "deadbeef";
|
||||
// STEPCA_PROVISIONER_PASSWORD not set
|
||||
const { factory, started, images } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "deadbeef\n" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(images).toEqual([STEPCA_IMAGE]);
|
||||
expect(started).toHaveLength(1);
|
||||
expect(config.caUrl).toMatch(/^https:\/\/localhost:\d+$/);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("container mode: full init sequence happy path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, containers } = makeFakeFactory();
|
||||
const { fn: execFn, calls } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": {
|
||||
stdout: "abc123def456\n",
|
||||
},
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(config.rootCertPem).toBe(ROOT_PEM);
|
||||
expect(config.fingerprint).toBe("abc123def456");
|
||||
expect(config.rootCertPath).toMatch(/stepca-/);
|
||||
expect(existsSync(config.rootCertPath)).toBe(true);
|
||||
expect(readFileSync(config.rootCertPath, "utf8")).toBe(ROOT_PEM);
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
const c = containers[0];
|
||||
if (!c) throw new Error("container missing");
|
||||
expect(c.capturedCommand?.[0]).toBe("sh");
|
||||
for (const tok of STEPCA_INIT_COMMAND_TOKENS) {
|
||||
expect(c.capturedCommand?.[2]).toContain(tok);
|
||||
}
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("init sequence runs commands in order with correct token sequence", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, containers } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "f00d" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await fixture.start();
|
||||
const c = containers[0];
|
||||
if (!c) throw new Error("container missing");
|
||||
const script = c.capturedCommand?.[2] ?? "";
|
||||
const indices = STEPCA_INIT_COMMAND_TOKENS.map((tok) =>
|
||||
script.indexOf(tok)
|
||||
);
|
||||
expect(indices.every((i) => i >= 0)).toBe(true);
|
||||
for (let i = 1; i < indices.length; i++) {
|
||||
expect(indices[i]).toBeGreaterThan(indices[i - 1] as number);
|
||||
}
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("caName, dnsNames, provisionerName flow into init args", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, containers } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "f00d" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
caName: "Custom CA",
|
||||
dnsNames: "host.test localhost",
|
||||
provisionerName: "myprov",
|
||||
});
|
||||
await fixture.start();
|
||||
const c = containers[0];
|
||||
if (!c) throw new Error("container missing");
|
||||
const script = c.capturedCommand?.[2] ?? "";
|
||||
expect(script).toContain("Custom CA");
|
||||
expect(script).toContain("host.test localhost");
|
||||
expect(script).toContain("myprov");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("rootCertPath points at file with rootCertPem contents", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(readFileSync(config.rootCertPath, "utf8")).toBe(
|
||||
config.rootCertPem
|
||||
);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("image override: factory called with custom image", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, images } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
image: "custom/step-ca:test",
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await fixture.start();
|
||||
expect(images).toEqual(["custom/step-ca:test"]);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("rejects unsafe caName via assertSafeIdentifier", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn: async () => ({ stdout: "", exitCode: 0 }),
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
caName: "bad;name",
|
||||
});
|
||||
await expect(fixture.start()).rejects.toThrow(
|
||||
/caName contains unsafe characters/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty provisionerName", { timeout: 5000 }, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn: async () => ({ stdout: "", exitCode: 0 }),
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
provisionerName: "",
|
||||
});
|
||||
await expect(fixture.start()).rejects.toThrow(
|
||||
/provisionerName contains unsafe characters/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe dnsNames", { timeout: 5000 }, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn: async () => ({ stdout: "", exitCode: 0 }),
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
dnsNames: "host`with`backticks",
|
||||
});
|
||||
await expect(fixture.start()).rejects.toThrow(
|
||||
/dnsNames contains unsafe characters/
|
||||
);
|
||||
});
|
||||
|
||||
it("healthCheck returns true via injected fetchFn", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await fixture.start();
|
||||
await expect(fixture.healthCheck()).resolves.toBe(true);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("healthCheck returns false via injected fetchFn (ok: false)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
let healthy = true;
|
||||
const fetchFn = vi.fn(async () => ({ ok: healthy }));
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn,
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await fixture.start();
|
||||
healthy = false;
|
||||
await expect(fixture.healthCheck()).resolves.toBe(false);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("healthCheck returns false when fetch throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
let firstCall = true;
|
||||
const fetchFn = vi.fn(async () => {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return { ok: true };
|
||||
}
|
||||
throw new Error("network down");
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn,
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await fixture.start();
|
||||
await expect(fixture.healthCheck()).resolves.toBe(false);
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("healthCheck returns false when no container", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fixture = new StepCaFixture();
|
||||
await expect(fixture.healthCheck()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("waitForHealth: throws on timeout when health never passes", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, started } = makeFakeFactory();
|
||||
const fetchFn = vi.fn(async () => ({ ok: false }));
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn: async () => ({ stdout: "", exitCode: 0 }),
|
||||
fetchFn,
|
||||
healthTimeoutMs: 5,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await expect(fixture.start()).rejects.toThrow(
|
||||
/Health check timed out for fixture "step-ca"/
|
||||
);
|
||||
const err = await fixture.start().catch((e: unknown) => e as Error);
|
||||
expect(err).toBeInstanceOf(TestEnvironmentError);
|
||||
// Both attempts should have stopped their containers
|
||||
expect(started.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("start(): stops container and clears reference when waitForHealth throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, started } = makeFakeFactory();
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn: async () => ({ stdout: "", exitCode: 0 }),
|
||||
fetchFn: async () => ({ ok: false }),
|
||||
healthTimeoutMs: 5,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await expect(fixture.start()).rejects.toThrow();
|
||||
const sc = started[0];
|
||||
if (!sc) throw new Error("started container missing");
|
||||
expect(sc.stopCalls).toBe(1);
|
||||
// Subsequent stop() is now a no-op (container reference cleared)
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
expect(sc.stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("waitForHealth: uses STEPCA_HEALTH_TIMEOUT_MS and STEPCA_HEALTH_POLL_INTERVAL_MS constants when no options provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// We cannot use real defaults (60s) in a unit test; instead verify
|
||||
// that a fixture constructed with no health overrides still passes
|
||||
// through to start() — exercising the default constants branch.
|
||||
const { factory } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(config.fingerprint).toBe("abcd");
|
||||
await fixture.stop();
|
||||
});
|
||||
|
||||
it("stop when no container is a no-op", { timeout: 5000 }, async () => {
|
||||
const fixture = new StepCaFixture();
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("stop calls container.stop() exactly once after container start", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory, started } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
await fixture.start();
|
||||
await fixture.stop();
|
||||
const sc = started[0];
|
||||
if (!sc) throw new Error("started container missing");
|
||||
expect(sc.stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("stop removes the root-cert tmpfile in container mode", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(existsSync(config.rootCertPath)).toBe(true);
|
||||
await fixture.stop();
|
||||
expect(existsSync(config.rootCertPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("stop swallows unlink errors when tmpfile is already removed", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { factory } = makeFakeFactory();
|
||||
const { fn: execFn } = makeExecFn({
|
||||
"cat /home/step/certs/root_ca.crt": { stdout: ROOT_PEM },
|
||||
"cat /home/step/secrets/fingerprint": { stdout: "abcd" },
|
||||
});
|
||||
const fixture = new StepCaFixture({
|
||||
containerFactory: factory as never,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 1,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
// pre-emptively delete to force unlink to throw
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
unlinkSync(config.rootCertPath);
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
class TestableFixture extends StepCaFixture {
|
||||
runResolveFactory() {
|
||||
return this.resolveContainerFactory();
|
||||
}
|
||||
runGetExecFn() {
|
||||
return this.getExecFn();
|
||||
}
|
||||
setFakeContainer(
|
||||
c: {
|
||||
exec: (cmd: string[]) => Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}>;
|
||||
getMappedPort: (p: number) => number;
|
||||
stop: () => Promise<void>;
|
||||
} | null
|
||||
) {
|
||||
const ref = this as unknown as { container: typeof c };
|
||||
ref.container = c;
|
||||
}
|
||||
}
|
||||
|
||||
describe("StepCaFixture protected fallbacks", () => {
|
||||
it("resolveContainerFactory returns the injected factory when provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const fakeFactory = ((image: string) => ({ image })) as never;
|
||||
const fixture = new TestableFixture({
|
||||
containerFactory: fakeFactory,
|
||||
});
|
||||
const got = await fixture.runResolveFactory();
|
||||
expect(got).toBe(fakeFactory);
|
||||
});
|
||||
|
||||
it("getExecFn returns the injected execFn when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const exec = vi.fn(async () => ({ stdout: "ok", exitCode: 0 }));
|
||||
const fixture = new TestableFixture({ execFn: exec });
|
||||
const got = fixture.runGetExecFn();
|
||||
expect(got).toBe(exec);
|
||||
});
|
||||
|
||||
it("getExecFn falls back to container.exec when no execFn provided", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const exec = vi.fn(async () => ({
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
exitCode: 0,
|
||||
}));
|
||||
const fakeContainer = {
|
||||
exec,
|
||||
getMappedPort: () => 18443,
|
||||
stop: async () => {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
const fixture = new TestableFixture();
|
||||
fixture.setFakeContainer(fakeContainer);
|
||||
const fn = fixture.runGetExecFn();
|
||||
const result = await fn(["echo", "x"]);
|
||||
expect(exec).toHaveBeenCalledWith(["echo", "x"]);
|
||||
expect(result).toEqual({
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
exitCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("getExecFn throws when neither execFn nor container is available", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixture = new TestableFixture();
|
||||
fixture.setFakeContainer(null);
|
||||
expect(() => fixture.runGetExecFn()).toThrow(
|
||||
/no container or execFn available/
|
||||
);
|
||||
});
|
||||
|
||||
it("resolveContainerFactory uses loadTestcontainers seam when no factory is injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class FakeGC {
|
||||
constructor(public readonly image: string) {}
|
||||
}
|
||||
class StubFixture extends StepCaFixture {
|
||||
protected override async loadTestcontainers() {
|
||||
return {
|
||||
GenericContainer: FakeGC as unknown as new (
|
||||
image: string
|
||||
) => never,
|
||||
};
|
||||
}
|
||||
runResolveFactory() {
|
||||
return this.resolveContainerFactory();
|
||||
}
|
||||
}
|
||||
const fixture = new StubFixture();
|
||||
const factory = await fixture.runResolveFactory();
|
||||
const built = factory("custom/image:test") as unknown as FakeGC;
|
||||
expect(built).toBeInstanceOf(FakeGC);
|
||||
expect(built.image).toBe("custom/image:test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultHealthFetch", () => {
|
||||
it("returns ok:false when the URL is unreachable", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const result = await defaultHealthFetch("https://127.0.0.1:1/health");
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("healthCheck default fetchFn fallback", () => {
|
||||
it("uses defaultHealthFetch when no fetchFn is injected", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
class StubFixture extends StepCaFixture {
|
||||
setContainer(c: { getMappedPort: (p: number) => number }) {
|
||||
const ref = this as unknown as { container: typeof c };
|
||||
ref.container = c;
|
||||
}
|
||||
}
|
||||
const fixture = new StubFixture();
|
||||
// Port 1 is privileged + nothing listens — defaultHealthFetch will
|
||||
// resolve `ok:false` via its error handler. This exercises the
|
||||
// `?? defaultHealthFetch` branch in healthCheck.
|
||||
fixture.setContainer({ getMappedPort: () => 1 });
|
||||
const ok = await fixture.healthCheck();
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyHealthStatus", () => {
|
||||
it("returns true for 2xx", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthStatus(200)).toBe(true);
|
||||
expect(classifyHealthStatus(204)).toBe(true);
|
||||
expect(classifyHealthStatus(299)).toBe(true);
|
||||
});
|
||||
it("returns true for 3xx", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthStatus(301)).toBe(true);
|
||||
expect(classifyHealthStatus(399)).toBe(true);
|
||||
});
|
||||
it("returns false for 4xx and 5xx", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthStatus(400)).toBe(false);
|
||||
expect(classifyHealthStatus(500)).toBe(false);
|
||||
});
|
||||
it("returns false for 0/undefined", { timeout: 5000 }, () => {
|
||||
expect(classifyHealthStatus(0)).toBe(false);
|
||||
expect(classifyHealthStatus(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStepCaInitScript", () => {
|
||||
it("includes all required command tokens in order", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const script = buildStepCaInitScript({
|
||||
password: "pw",
|
||||
caName: "Test CA",
|
||||
dnsNames: "localhost",
|
||||
initProvisioner: "admin",
|
||||
jwkProvisioner: "api",
|
||||
});
|
||||
const indices = STEPCA_INIT_COMMAND_TOKENS.map((tok) =>
|
||||
script.indexOf(tok)
|
||||
);
|
||||
expect(indices.every((i) => i >= 0)).toBe(true);
|
||||
for (let i = 1; i < indices.length; i++) {
|
||||
expect(indices[i]).toBeGreaterThan(indices[i - 1] as number);
|
||||
}
|
||||
});
|
||||
|
||||
it("interpolates the listen address from STEPCA_INTERNAL_PORT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const script = buildStepCaInitScript({
|
||||
password: "pw",
|
||||
caName: "Test CA",
|
||||
dnsNames: "localhost",
|
||||
initProvisioner: "admin",
|
||||
jwkProvisioner: "api",
|
||||
});
|
||||
expect(script).toContain(`--address :${STEPCA_INTERNAL_PORT}`);
|
||||
});
|
||||
|
||||
it("escapes special characters in CA name via JSON.stringify", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const script = buildStepCaInitScript({
|
||||
password: "pw",
|
||||
caName: 'Bad "quoted" Name',
|
||||
dnsNames: "localhost",
|
||||
initProvisioner: "admin",
|
||||
jwkProvisioner: "api",
|
||||
});
|
||||
// JSON.stringify wraps in quotes and escapes inner quotes, preventing
|
||||
// shell injection via the entrypoint argv.
|
||||
expect(script).toContain('"Bad \\"quoted\\" Name"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* StepCaFixture — testcontainers-backed ServiceFixture for `smallstep/step-ca`.
|
||||
*
|
||||
* Provides a real PKI chain for integration tests that need genuine HTTPS
|
||||
* (WebRTC, secure SMTPS/IMAPS, Caddy/nginx fixtures, etc.) instead of
|
||||
* `rejectUnauthorized: false` workarounds.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
applyWaitStrategy,
|
||||
HealthPoller,
|
||||
type ServiceFixture,
|
||||
} from "@suaveplan/testing-integration";
|
||||
import type { GenericContainer, StartedTestContainer } from "testcontainers";
|
||||
import {
|
||||
STEPCA_DEFAULT_DNS,
|
||||
STEPCA_DEFAULT_NAME,
|
||||
STEPCA_DEFAULT_PROVISIONER,
|
||||
STEPCA_ENV_VARS,
|
||||
STEPCA_HEALTH_POLL_INTERVAL_MS,
|
||||
STEPCA_HEALTH_TIMEOUT_MS,
|
||||
STEPCA_IMAGE,
|
||||
STEPCA_INTERNAL_PORT,
|
||||
STEPCA_READY_LOG,
|
||||
STEPCA_READY_LOG_TIMES,
|
||||
} from "../constants/index.js";
|
||||
import { parsePemBlocks } from "./parse-pem-blocks.js";
|
||||
import type {
|
||||
ExecResult,
|
||||
IssueCertOptions,
|
||||
IssuedCert,
|
||||
StepCaConfig,
|
||||
StepCaFixtureOptions,
|
||||
} from "./step-ca-fixture.types.js";
|
||||
|
||||
/** Command-name tokens emitted by the init entrypoint, in order. */
|
||||
export const STEPCA_INIT_COMMAND_TOKENS = [
|
||||
"step ca init",
|
||||
"step crypto jwk create",
|
||||
"jq",
|
||||
"step certificate fingerprint",
|
||||
"step-ca --password-file",
|
||||
] as const;
|
||||
|
||||
/** Validates a name is safe to interpolate into the entrypoint script. */
|
||||
function assertSafeIdentifier(name: string, label: string): void {
|
||||
if (!/^[A-Za-z0-9 ,_.-]{1,128}$/.test(name)) {
|
||||
throw new Error(
|
||||
`StepCaFixture: ${label} contains unsafe characters or is empty`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the bake-init shell script invoked as the container entrypoint.
|
||||
*
|
||||
* Performs (in order):
|
||||
* 1. write the provisioner password file
|
||||
* 2. `step ca init` (root + intermediate CA + admin provisioner)
|
||||
* 3. `step crypto jwk create` (JWK keypair for the API provisioner)
|
||||
* 4. `jq` merge — append the JWK provisioner into ca.json
|
||||
* 5. `step certificate fingerprint` → /home/step/secrets/fingerprint
|
||||
* 6. replace the shell with the running `step-ca` daemon
|
||||
*/
|
||||
export function buildStepCaInitScript(args: {
|
||||
password: string;
|
||||
caName: string;
|
||||
dnsNames: string;
|
||||
initProvisioner: string;
|
||||
jwkProvisioner: string;
|
||||
}): string {
|
||||
const { password, caName, dnsNames, initProvisioner, jwkProvisioner } =
|
||||
args;
|
||||
const lines = [
|
||||
"set -e",
|
||||
`PASS=${JSON.stringify(password)}`,
|
||||
`JWK_NAME=${JSON.stringify(jwkProvisioner)}`,
|
||||
"mkdir -p /home/step/secrets",
|
||||
'printf "%s" "$PASS" > /home/step/secrets/password',
|
||||
"chmod 600 /home/step/secrets/password",
|
||||
`step ca init --deployment-type standalone --name ${JSON.stringify(caName)} --dns ${JSON.stringify(dnsNames)} --address :${STEPCA_INTERNAL_PORT} --provisioner ${JSON.stringify(initProvisioner)} --password-file /home/step/secrets/password`,
|
||||
"step crypto jwk create /tmp/jwk-pub.json /tmp/jwk-priv.json --kty EC --crv P-256 --alg ES256 --use sig --password-file /home/step/secrets/password --force",
|
||||
"PUB=$(cat /tmp/jwk-pub.json)",
|
||||
"ENC=$(cat /tmp/jwk-priv.json)",
|
||||
`jq --argjson pub "$PUB" --arg enc "$ENC" --arg n "$JWK_NAME" '.authority.provisioners += [{"type":"JWK","name":$n,"key":$pub,"encryptedKey":$enc}]' /home/step/config/ca.json > /tmp/ca.new`,
|
||||
"mv /tmp/ca.new /home/step/config/ca.json",
|
||||
"step certificate fingerprint /home/step/certs/root_ca.crt > /home/step/secrets/fingerprint",
|
||||
"rm -f /tmp/jwk-pub.json /tmp/jwk-priv.json",
|
||||
"step-ca --password-file /home/step/secrets/password /home/step/config/ca.json",
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Health-fetch interface — minimal subset of the Fetch API. */
|
||||
export type HealthFetch = (url: string) => Promise<{ ok: boolean }>;
|
||||
|
||||
/**
|
||||
* Pure response classifier for the default health fetch. Returns `true`
|
||||
* when the HTTP status is in the 2xx/3xx range (anything <400). Exported
|
||||
* so the production HTTPS path can stay a thin shell around it.
|
||||
*/
|
||||
export function classifyHealthStatus(statusCode: number | undefined): boolean {
|
||||
const code = statusCode ?? 0;
|
||||
return code >= 200 && code < 400;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default HTTPS health-fetch with TLS verification disabled.
|
||||
*
|
||||
* Exported for testability; the host trust store does not yet contain the
|
||||
* test root, so the health probe must accept any cert. This is the only
|
||||
* place in the fixture that reads from an untrusted HTTPS endpoint.
|
||||
*/
|
||||
export function defaultHealthFetch(url: string): Promise<{ ok: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const req = httpsRequest(
|
||||
url,
|
||||
{ method: "GET", rejectUnauthorized: false },
|
||||
/* istanbul ignore next -- response callback only fires when the URL is reachable */
|
||||
(res) => {
|
||||
res.resume();
|
||||
resolve({ ok: classifyHealthStatus(res.statusCode) });
|
||||
}
|
||||
);
|
||||
req.on("error", () => resolve({ ok: false }));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the full lifecycle of a `smallstep/step-ca` container for tests.
|
||||
*
|
||||
* Supports env-bridge passthrough: when all five env vars are set and
|
||||
* non-empty, returns them directly without starting a container. A partial
|
||||
* set (4 of 5) falls through to container mode.
|
||||
*/
|
||||
export class StepCaFixture implements ServiceFixture<StepCaConfig> {
|
||||
readonly name = "step-ca";
|
||||
|
||||
protected readonly options: StepCaFixtureOptions;
|
||||
protected container: StartedTestContainer | null = null;
|
||||
protected config: StepCaConfig | null = null;
|
||||
protected passthrough = false;
|
||||
protected rootCertPath: string | null = null;
|
||||
protected provisionerPassword: string | null = null;
|
||||
|
||||
constructor(options: StepCaFixtureOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the container factory. Returns the injected factory when
|
||||
* provided, otherwise dynamically imports testcontainers and wraps
|
||||
* `GenericContainer`.
|
||||
*/
|
||||
protected async resolveContainerFactory(): Promise<
|
||||
(image: string) => GenericContainer
|
||||
> {
|
||||
if (this.options.containerFactory) {
|
||||
return this.options.containerFactory;
|
||||
}
|
||||
const mod = await this.loadTestcontainers();
|
||||
return (img: string) => new mod.GenericContainer(img);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overridable seam for dynamically importing the `testcontainers`
|
||||
* package. Extracted so tests can swap in a fake implementation
|
||||
* without booting Docker; production code calls `await import(...)`.
|
||||
*/
|
||||
/* istanbul ignore next -- production-only seam, exercised by integration smoke */
|
||||
protected async loadTestcontainers(): Promise<{
|
||||
GenericContainer: new (image: string) => GenericContainer;
|
||||
}> {
|
||||
return (await import("testcontainers")) as {
|
||||
GenericContainer: new (image: string) => GenericContainer;
|
||||
};
|
||||
}
|
||||
|
||||
async start(): Promise<StepCaConfig> {
|
||||
const [
|
||||
envUrl,
|
||||
envRootPem,
|
||||
envRootPath,
|
||||
envFingerprint,
|
||||
envProvPassword,
|
||||
] = STEPCA_ENV_VARS.map((v) => process.env[v] ?? "");
|
||||
if (
|
||||
envUrl &&
|
||||
envRootPem &&
|
||||
envRootPath &&
|
||||
envFingerprint &&
|
||||
envProvPassword
|
||||
) {
|
||||
this.passthrough = true;
|
||||
this.config = {
|
||||
caUrl: envUrl,
|
||||
rootCertPem: envRootPem,
|
||||
rootCertPath: envRootPath,
|
||||
fingerprint: envFingerprint,
|
||||
provisionerName:
|
||||
this.options.provisionerName ?? STEPCA_DEFAULT_PROVISIONER,
|
||||
provisionerPassword: envProvPassword,
|
||||
};
|
||||
return this.config;
|
||||
}
|
||||
|
||||
const image = this.options.image ?? STEPCA_IMAGE;
|
||||
const caName = this.options.caName ?? STEPCA_DEFAULT_NAME;
|
||||
const dnsNames = this.options.dnsNames ?? STEPCA_DEFAULT_DNS;
|
||||
const provisionerName =
|
||||
this.options.provisionerName ?? STEPCA_DEFAULT_PROVISIONER;
|
||||
|
||||
assertSafeIdentifier(caName, "caName");
|
||||
assertSafeIdentifier(dnsNames, "dnsNames");
|
||||
assertSafeIdentifier(provisionerName, "provisionerName");
|
||||
|
||||
const password = randomBytes(24).toString("hex");
|
||||
this.provisionerPassword = password;
|
||||
|
||||
const initScript = buildStepCaInitScript({
|
||||
password,
|
||||
caName,
|
||||
dnsNames,
|
||||
initProvisioner: provisionerName,
|
||||
jwkProvisioner: provisionerName,
|
||||
});
|
||||
|
||||
const factory = await this.resolveContainerFactory();
|
||||
const builder = factory(image)
|
||||
.withExposedPorts(STEPCA_INTERNAL_PORT)
|
||||
.withCommand(["sh", "-c", initScript]);
|
||||
const raw = await applyWaitStrategy(builder, {
|
||||
readyLog: STEPCA_READY_LOG,
|
||||
times: STEPCA_READY_LOG_TIMES,
|
||||
});
|
||||
this.container = await raw.start();
|
||||
|
||||
try {
|
||||
await this.waitForHealth();
|
||||
|
||||
const run = this.getExecFn();
|
||||
const rootResult = await run([
|
||||
"cat",
|
||||
"/home/step/certs/root_ca.crt",
|
||||
]);
|
||||
const rootCertPem = rootResult.stdout;
|
||||
const fingerprintResult = await run([
|
||||
"cat",
|
||||
"/home/step/secrets/fingerprint",
|
||||
]);
|
||||
const fingerprint = fingerprintResult.stdout
|
||||
.trim()
|
||||
.split(/\s+/)[0] as string;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "stepca-"));
|
||||
const certPath = join(dir, "root_ca.crt");
|
||||
writeFileSync(certPath, rootCertPem);
|
||||
this.rootCertPath = certPath;
|
||||
|
||||
const port = this.container.getMappedPort(STEPCA_INTERNAL_PORT);
|
||||
this.config = {
|
||||
caUrl: `https://localhost:${port}`,
|
||||
rootCertPem,
|
||||
rootCertPath: certPath,
|
||||
fingerprint,
|
||||
provisionerName,
|
||||
provisionerPassword: password,
|
||||
};
|
||||
return this.config;
|
||||
} catch (err) {
|
||||
await this.container.stop();
|
||||
this.container = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.passthrough) return;
|
||||
if (this.rootCertPath) {
|
||||
try {
|
||||
unlinkSync(this.rootCertPath);
|
||||
} catch {
|
||||
/* best-effort cleanup */
|
||||
}
|
||||
this.rootCertPath = null;
|
||||
}
|
||||
if (!this.container) return;
|
||||
await this.container.stop();
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.container) return false;
|
||||
const port = this.container.getMappedPort(STEPCA_INTERNAL_PORT);
|
||||
try {
|
||||
const fetchFn: HealthFetch =
|
||||
this.options.fetchFn ?? defaultHealthFetch;
|
||||
const res = await fetchFn(`https://localhost:${port}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls `healthCheck()` until it returns true or the timeout elapses.
|
||||
* Delegates to {@link HealthPoller} so timeout semantics and the
|
||||
* `TestEnvironmentError` shape are shared with the rest of the
|
||||
* integration-testing surface.
|
||||
*/
|
||||
protected async waitForHealth(): Promise<void> {
|
||||
const timeoutMs =
|
||||
this.options.healthTimeoutMs ?? STEPCA_HEALTH_TIMEOUT_MS;
|
||||
const intervalMs =
|
||||
this.options.healthPollIntervalMs ?? STEPCA_HEALTH_POLL_INTERVAL_MS;
|
||||
const poller = new HealthPoller({ timeoutMs, intervalMs });
|
||||
await poller.poll(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the runner used to invoke commands in the container.
|
||||
* Uses the injected `execFn` when provided; otherwise wraps
|
||||
* `container.exec()`.
|
||||
*/
|
||||
protected getExecFn(): (cmd: string[]) => Promise<ExecResult> {
|
||||
if (this.options.execFn) {
|
||||
return this.options.execFn;
|
||||
}
|
||||
const container = this.container;
|
||||
if (!container) {
|
||||
throw new Error(
|
||||
"StepCaFixture.getExecFn: no container or execFn available"
|
||||
);
|
||||
}
|
||||
return async (cmd: string[]) => {
|
||||
const result = await container.exec(cmd);
|
||||
return {
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a leaf certificate via the JWK provisioner inside the container.
|
||||
*/
|
||||
async issueCert(opts: IssueCertOptions): Promise<IssuedCert> {
|
||||
const cn = opts.commonName;
|
||||
if (!cn?.trim()) {
|
||||
throw new Error(
|
||||
"INTEGRATION.STEP_CA.ISSUE_CERT_FAILED: commonName must be non-empty"
|
||||
);
|
||||
}
|
||||
if (!this.config || !this.provisionerPassword) {
|
||||
throw new Error(
|
||||
"INTEGRATION.STEP_CA.ISSUE_CERT_FAILED: fixture not started in container mode"
|
||||
);
|
||||
}
|
||||
|
||||
const run = this.getExecFn();
|
||||
const certPath = `/tmp/leaf-${randomBytes(8).toString("hex")}.crt`;
|
||||
const keyPath = `/tmp/leaf-${randomBytes(8).toString("hex")}.key`;
|
||||
|
||||
const argv: string[] = [
|
||||
"step",
|
||||
"ca",
|
||||
"certificate",
|
||||
cn,
|
||||
certPath,
|
||||
keyPath,
|
||||
"--provisioner",
|
||||
this.config.provisionerName,
|
||||
"--provisioner-password-file",
|
||||
"/home/step/secrets/password",
|
||||
];
|
||||
if (opts.ttl) {
|
||||
argv.push(`--not-after=${opts.ttl}`);
|
||||
}
|
||||
if (opts.sans) {
|
||||
for (const san of opts.sans) {
|
||||
argv.push("--san", san);
|
||||
}
|
||||
}
|
||||
|
||||
const issue = await run(argv);
|
||||
if (issue.exitCode !== 0) {
|
||||
const err = new Error(
|
||||
"INTEGRATION.STEP_CA.ISSUE_CERT_FAILED: step ca certificate exited non-zero"
|
||||
);
|
||||
(err as Error & { cause?: unknown }).cause =
|
||||
issue.stderr ?? issue.stdout;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const certCat = await run(["cat", certPath]);
|
||||
const keyCat = await run(["cat", keyPath]);
|
||||
if (certCat.exitCode !== 0 || keyCat.exitCode !== 0) {
|
||||
throw new Error(
|
||||
"INTEGRATION.STEP_CA.ISSUE_CERT_FAILED: failed to read issued cert/key"
|
||||
);
|
||||
}
|
||||
|
||||
let leaf: string;
|
||||
let chain: string;
|
||||
try {
|
||||
const parsed = parsePemBlocks(certCat.stdout);
|
||||
leaf = parsed.leaf;
|
||||
chain = parsed.chain;
|
||||
} catch (err) {
|
||||
const wrapped = new Error(
|
||||
"INTEGRATION.STEP_CA.PARSE_PEM_FAILED: malformed PEM in issued certificate"
|
||||
);
|
||||
(wrapped as Error & { cause?: unknown }).cause = err;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
const inspect = await run([
|
||||
"step",
|
||||
"certificate",
|
||||
"inspect",
|
||||
certPath,
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
if (inspect.exitCode !== 0) {
|
||||
throw new Error(
|
||||
"INTEGRATION.STEP_CA.ISSUE_CERT_FAILED: step certificate inspect exited non-zero"
|
||||
);
|
||||
}
|
||||
let serial: string;
|
||||
try {
|
||||
const parsed = JSON.parse(inspect.stdout) as {
|
||||
serial_number?: string;
|
||||
serial?: string;
|
||||
};
|
||||
const candidate = parsed.serial_number ?? parsed.serial;
|
||||
if (!candidate) {
|
||||
throw new Error("missing serial");
|
||||
}
|
||||
serial = candidate;
|
||||
} catch (err) {
|
||||
const wrapped = new Error(
|
||||
"INTEGRATION.STEP_CA.PARSE_PEM_FAILED: failed to parse step certificate inspect output"
|
||||
);
|
||||
(wrapped as Error & { cause?: unknown }).cause = err;
|
||||
throw wrapped;
|
||||
}
|
||||
|
||||
return {
|
||||
certPem: leaf,
|
||||
chainPem: chain,
|
||||
keyPem: keyCat.stdout,
|
||||
serial,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { GenericContainer } from "testcontainers";
|
||||
|
||||
/**
|
||||
* Configuration returned by {@link StepCaFixture.start}.
|
||||
*
|
||||
* `rootCertPem` is the PEM-encoded root CA certificate; `rootCertPath` is a
|
||||
* filesystem path on the host pointing at the same content (for mounting into
|
||||
* sibling containers or setting `NODE_EXTRA_CA_CERTS`).
|
||||
*/
|
||||
export interface StepCaConfig extends Record<string, unknown> {
|
||||
/** Public-ish CA URL — `https://localhost:<mappedPort>` in container mode. */
|
||||
caUrl: string;
|
||||
/** PEM-encoded root certificate. */
|
||||
rootCertPem: string;
|
||||
/** Host-side path to a tmpfile containing `rootCertPem`. */
|
||||
rootCertPath: string;
|
||||
/** SHA-256 fingerprint of the root certificate (64 hex chars, no separators). */
|
||||
fingerprint: string;
|
||||
/** Name of the JWK provisioner used by `issueCert()`. */
|
||||
provisionerName: string;
|
||||
/** Password protecting the JWK provisioner private key. */
|
||||
provisionerPassword: string;
|
||||
}
|
||||
|
||||
/** Options for issuing a leaf certificate via `issueCert()`. */
|
||||
export interface IssueCertOptions {
|
||||
/** CN for the leaf cert. Required and non-empty. */
|
||||
commonName: string;
|
||||
/** Additional Subject Alternative Names. Each appears as `--san <s>`. */
|
||||
sans?: readonly string[];
|
||||
/** TTL string acceptable to `step ca certificate --not-after=<ttl>` (e.g. "24h"). */
|
||||
ttl?: string;
|
||||
}
|
||||
|
||||
/** Result of a successful `issueCert()` call. */
|
||||
export interface IssuedCert {
|
||||
/** PEM block containing only the leaf certificate. */
|
||||
certPem: string;
|
||||
/** Concatenation of remaining PEM blocks (chain), `""` if none. */
|
||||
chainPem: string;
|
||||
/** PEM-encoded private key. */
|
||||
keyPem: string;
|
||||
/** Hex serial extracted via `step certificate inspect --format json`. */
|
||||
serial: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result shape produced by the injected `execFn` (or `container.exec`).
|
||||
* `stderr` is optional for compatibility with the garage-s3 contract.
|
||||
*/
|
||||
export interface ExecResult {
|
||||
stdout: string;
|
||||
exitCode: number;
|
||||
stderr?: string;
|
||||
}
|
||||
|
||||
export interface StepCaFixtureOptions {
|
||||
/** Override the Docker image. Defaults to {@link STEPCA_IMAGE}. */
|
||||
image?: string;
|
||||
/** CA display name. Defaults to "Suaveplan Test CA". */
|
||||
caName?: string;
|
||||
/** Comma-separated DNS SANs for the CA. Defaults to "localhost,step-ca". */
|
||||
dnsNames?: string;
|
||||
/** JWK provisioner name. Defaults to "api". */
|
||||
provisionerName?: string;
|
||||
/** Override health check timeout (ms). */
|
||||
healthTimeoutMs?: number;
|
||||
/** Override health check poll interval (ms). */
|
||||
healthPollIntervalMs?: number;
|
||||
/** Injectable container factory for unit testing. */
|
||||
containerFactory?: (image: string) => GenericContainer;
|
||||
/** Injectable command runner for unit testing. */
|
||||
execFn?: (cmd: string[]) => Promise<ExecResult>;
|
||||
/** Injectable fetch for healthCheck unit testing. */
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from "./constants/index.js";
|
||||
export { parsePemBlocks } from "./fixture/parse-pem-blocks.js";
|
||||
export type { HealthFetch } from "./fixture/step-ca-fixture.js";
|
||||
export {
|
||||
buildStepCaInitScript,
|
||||
classifyHealthStatus,
|
||||
defaultHealthFetch,
|
||||
STEPCA_INIT_COMMAND_TOKENS,
|
||||
StepCaFixture,
|
||||
} from "./fixture/step-ca-fixture.js";
|
||||
export type {
|
||||
ExecResult,
|
||||
IssueCertOptions,
|
||||
IssuedCert,
|
||||
StepCaConfig,
|
||||
StepCaFixtureOptions,
|
||||
} from "./fixture/step-ca-fixture.types.js";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto; File?: unknown };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
if (g.File === undefined) {
|
||||
g.File = class File extends Blob {
|
||||
readonly name: string;
|
||||
readonly lastModified: number;
|
||||
constructor(
|
||||
fileBits: BlobPart[],
|
||||
fileName: string,
|
||||
options?: FilePropertyBag
|
||||
) {
|
||||
super(fileBits, options);
|
||||
this.name = fileName;
|
||||
this.lastModified = options?.lastModified ?? Date.now();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"testcontainers",
|
||||
"@suaveplan/testing-integration",
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:os",
|
||||
"node:path",
|
||||
"node:crypto",
|
||||
"node:https",
|
||||
],
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
rollupTypes: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user