# Capability Spec Delta: testing-integration-step-ca (testing-integration-step-ca) **Package**: `@suaveplan/testing-integration-step-ca` **Status**: PROPOSED ## ADDED Requirements ### Requirement: StepCaFixture implements ServiceFixture The package SHALL export `StepCaFixture` with `name === "step-ca"`. `StepCaConfig` SHALL be `{ caUrl: string; rootCertPem: string; rootCertPath: string; fingerprint: string; provisionerName: string; provisionerPassword: string }`. #### Scenario: name property is "step-ca" - **WHEN** an instance's `.name` is read - **THEN** the value is the literal `"step-ca"` **Verified by:** `src/fixture/step-ca-fixture.test.ts::"name property returns step-ca"` --- ### Requirement: Env-bridge passthrough mode (5 vars, all required) When ALL FIVE vars in `STEPCA_ENV_VARS` (`STEPCA_URL`, `STEPCA_ROOT_CERT_PEM`, `STEPCA_ROOT_CERT_PATH`, `STEPCA_FINGERPRINT`, `STEPCA_PROVISIONER_PASSWORD`) are set and non-empty, `start()` MUST return them directly without starting a container. A partial set (4 of 5) MUST fall through to container mode. In passthrough mode `stop()` is a no-op. #### Scenario: returns config from env vars - **WHEN** all five env vars are set - **THEN** `start()` returns the config without invoking testcontainers #### Scenario: stop is a no-op in passthrough mode - **WHEN** `stop()` follows passthrough start - **THEN** no container is touched #### Scenario: partial passthrough falls through to container mode - **WHEN** only 4 of 5 env vars are set - **THEN** `start()` boots a container **Verified by:** `src/fixture/step-ca-fixture.test.ts::"passthrough mode (all 5 vars set): returns config from env"` AND `src/fixture/step-ca-fixture.test.ts::"stop when passthrough mode is a no-op"` AND `src/fixture/step-ca-fixture.test.ts::"partial passthrough (4 of 5 vars): falls through to container mode"` --- ### Requirement: Container mode + 6-step init sequence When env vars are partial or unset, `start()` MUST start a `smallstep/step-ca` container, expose internal port `8443` to a host port, wait for health, then run the 6-step init sequence in order: (1) `step ca init --deployment-type standalone --name --dns --address :8443 --provisioner --password-file `, (2) read `/home/step/certs/root_ca.crt` from the container into `rootCertPem`, (3) `step certificate fingerprint /home/step/certs/root_ca.crt` → `fingerprint`, (4) `step crypto jwk create` (EC P-256, ES256, sig) password-encrypted with the JWK provisioner password, (5) `jq` insert the JWK provisioner into `/home/step/config/ca.json`, (6) materialise `rootCertPem` to a host-side tmpfile and set `rootCertPath`. The returned config MUST contain a populated `caUrl`, `rootCertPem`, `rootCertPath`, `fingerprint`, `provisionerName`, and `provisionerPassword`. #### Scenario: full init sequence happy path - **WHEN** `start()` runs end-to-end with the injected execFn - **THEN** the returned config contains a populated `rootCertPem` (`-----BEGIN CERTIFICATE-----` prefix), `fingerprint` (64 hex chars), and `rootCertPath` pointing at a real file #### Scenario: init sequence executes 6 commands in correct order - **WHEN** the init sequence completes - **THEN** the 6 commands appear in the documented order #### Scenario: caName, dnsNames and provisionerName flow into init args - **WHEN** the fixture is constructed with custom `caName`, `dnsNames`, and `provisionerName` - **THEN** those values appear in the `step ca init` argv and `provisionerName` appears as the `name` of the JWK provisioner inserted into `ca.json` #### Scenario: rootCertPath points at a real file containing rootCertPem - **WHEN** `start()` resolves - **THEN** the file at `rootCertPath` exists and its contents byte-equal `rootCertPem` #### Scenario: image override flows to factory - **WHEN** the fixture is constructed with `image: "x"` - **THEN** the container factory is invoked with `"x"` #### Scenario: factory falls back to dynamic GenericContainer import - **WHEN** no `containerFactory` is injected - **THEN** the fixture dynamically imports testcontainers and uses `GenericContainer` #### Scenario: getExecFn uses injected execFn when provided - **WHEN** `options.execFn` is provided - **THEN** the init sequence runs through that function #### Scenario: getExecFn falls back to container.exec - **WHEN** no `execFn` is provided - **THEN** the init sequence runs through `container.exec` **Verified by:** `src/fixture/step-ca-fixture.test.ts::"container mode: full init sequence happy path"` AND `src/fixture/step-ca-fixture.test.ts::"container mode: init sequence runs 6 commands in order"` AND `src/fixture/step-ca-fixture.test.ts::"caName, dnsNames, provisionerName flow into init args"` AND `src/fixture/step-ca-fixture.test.ts::"rootCertPath points at file with rootCertPem contents"` AND `src/fixture/step-ca-fixture.test.ts::"image override: factory called with custom image"` AND `src/fixture/step-ca-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"` AND `src/fixture/step-ca-fixture.test.ts::"getExecFn: uses injected execFn when provided"` AND `src/fixture/step-ca-fixture.test.ts::"getExecFn: falls back to container.exec when no execFn provided"` --- ### Requirement: HTTPS /health probe with TLS verification disabled In container mode, `healthCheck()` MUST `GET https://localhost:/health` via the injected `fetchFn` (default `globalThis.fetch`) with TLS verification disabled (the host has not yet trusted the root) and return `res.ok`. Network errors MUST return `false`. Without a started container, `healthCheck()` MUST return `false`. #### Scenario: returns true via injected fetchFn - **WHEN** the injected fetch returns `{ ok: true }` - **THEN** `healthCheck()` resolves to `true` #### Scenario: returns false on ok=false - **WHEN** the injected fetch returns `{ ok: false }` - **THEN** `healthCheck()` resolves to `false` #### Scenario: returns false when fetch throws - **WHEN** the injected fetch throws - **THEN** `healthCheck()` resolves to `false` #### Scenario: returns false when no container - **WHEN** `healthCheck()` is called before `start()` - **THEN** the result is `false` **Verified by:** `src/fixture/step-ca-fixture.test.ts::"healthCheck returns true via injected fetchFn"` AND `src/fixture/step-ca-fixture.test.ts::"healthCheck returns false via injected fetchFn (ok: false)"` AND `src/fixture/step-ca-fixture.test.ts::"healthCheck returns false when fetch throws"` AND `src/fixture/step-ca-fixture.test.ts::"healthCheck returns false when no container"` --- ### Requirement: waitForHealth delegates to HealthPoller The fixture's `waitForHealth()` MUST delegate to `new HealthPoller({ timeoutMs, intervalMs }).poll(this)` (where defaults are `STEPCA_HEALTH_TIMEOUT_MS` and `STEPCA_HEALTH_POLL_INTERVAL_MS`). On timeout, the rejection MUST be a `TestEnvironmentError` whose message includes `"Health check timed out for fixture \"step-ca\""`. On health-check failure during `start()`, the container MUST be stopped and the reference cleared before the error propagates. #### Scenario: throws on timeout when health never passes - **WHEN** `healthCheck()` returns `false` past the timeout - **THEN** `start()` rejects with a `TestEnvironmentError` containing `Health check timed out for fixture "step-ca"` #### Scenario: container is stopped when waitForHealth throws inside start() - **WHEN** `waitForHealth()` throws - **THEN** `container.stop()` is invoked exactly once and the reference is cleared #### Scenario: defaults flow through when no options provided - **WHEN** the fixture is constructed without `healthTimeoutMs` / `healthPollIntervalMs` - **THEN** the constants `STEPCA_HEALTH_TIMEOUT_MS` and `STEPCA_HEALTH_POLL_INTERVAL_MS` are used **Verified by:** `src/fixture/step-ca-fixture.test.ts::"waitForHealth: throws on timeout when health never passes"` AND `src/fixture/step-ca-fixture.test.ts::"start(): stops container and clears reference when waitForHealth throws"` AND `src/fixture/step-ca-fixture.test.ts::"waitForHealth: uses STEPCA_HEALTH_TIMEOUT_MS and STEPCA_HEALTH_POLL_INTERVAL_MS constants when no options provided"` --- ### Requirement: issueCert shells `step ca certificate` against the JWK provisioner `issueCert({ commonName, sans?, ttl? })` MUST shell `step ca certificate --provisioner --provisioner-password-file ` inside the container via the injected `execFn`, append `--san ` for each entry in `sans`, and append `--not-after=` when `ttl` is provided. The function MUST return `{ certPem, keyPem, chainPem, serial }` where `certPem` is the leaf, `chainPem` is the remaining PEM blocks concatenated, `keyPem` is the issued private key, and `serial` is extracted via `step certificate inspect --format json`. An empty `commonName` MUST throw `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` without invoking the shell. Non-zero `step` exit MUST throw `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` with stderr attached. Malformed PEM MUST throw `INTEGRATION.STEP_CA.PARSE_PEM_FAILED`. #### Scenario: happy path returns leaf, chain, key, serial - **WHEN** `issueCert({ commonName: "host.test" })` runs with a mocked execFn that returns a valid leaf+chain - **THEN** the result splits the leaf into `certPem`, the remainder into `chainPem`, returns the issued `keyPem`, and the serial from `step certificate inspect` #### Scenario: SAN entries are passed through - **WHEN** `issueCert({ commonName: "host.test", sans: ["a.test","b.test"] })` runs - **THEN** the argv contains `--san a.test --san b.test` in order #### Scenario: TTL is passed through - **WHEN** `issueCert({ commonName: "host.test", ttl: "24h" })` runs - **THEN** the argv contains `--not-after=24h` #### Scenario: empty commonName rejects without shelling - **WHEN** `issueCert({ commonName: "" })` is called - **THEN** the call rejects with `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` and the injected `execFn` is never invoked #### Scenario: non-zero exit rejects with ISSUE_CERT_FAILED - **WHEN** the injected `execFn` returns a non-zero exit code - **THEN** the call rejects with `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` and the rejection's `cause` exposes the captured stderr #### Scenario: malformed PEM rejects with PARSE_PEM_FAILED - **WHEN** the cert payload is missing an `END CERTIFICATE` line - **THEN** the call rejects with `INTEGRATION.STEP_CA.PARSE_PEM_FAILED` **Verified by:** `src/fixture/step-ca-fixture-issue-cert.test.ts::"issueCert: happy path returns leaf, chain, key, serial"` AND `src/fixture/step-ca-fixture-issue-cert.test.ts::"issueCert: SAN entries appear in argv in order"` AND `src/fixture/step-ca-fixture-issue-cert.test.ts::"issueCert: ttl appears as --not-after="` AND `src/fixture/step-ca-fixture-issue-cert.test.ts::"issueCert: empty commonName rejects without invoking execFn"` AND `src/fixture/step-ca-fixture-issue-cert.test.ts::"issueCert: non-zero exit rejects with ISSUE_CERT_FAILED"` AND `src/fixture/step-ca-fixture-issue-cert.test.ts::"issueCert: malformed PEM rejects with PARSE_PEM_FAILED"` --- ### Requirement: parsePemBlocks splits leaf from chain `parsePemBlocks(text)` MUST return `{ leaf: string; chain: string }` where `leaf` is the first `-----BEGIN CERTIFICATE-----`…`-----END CERTIFICATE-----` block (inclusive of the BEGIN/END markers and the trailing newline) and `chain` is the concatenation of every subsequent block (in order, separated by a single newline; empty string when only one block exists). Inputs without at least one well-formed block MUST throw with `"StepCaFixture.parsePemBlocks: no PEM CERTIFICATE blocks found"`. Inputs with a `BEGIN` line but no matching `END` line MUST throw with `"StepCaFixture.parsePemBlocks: unterminated PEM block"`. #### Scenario: parses single-block input - **WHEN** the input contains exactly one CERTIFICATE block - **THEN** `leaf` equals the block and `chain` is `""` #### Scenario: parses multi-block input - **WHEN** the input contains a leaf followed by two chain certs - **THEN** `leaf` is the first block and `chain` is the second + third blocks concatenated in order #### Scenario: throws on empty / non-PEM input - **WHEN** the input is empty or contains no `BEGIN CERTIFICATE` line - **THEN** `parsePemBlocks` throws #### Scenario: throws on unterminated block - **WHEN** the input contains `BEGIN CERTIFICATE` but no matching `END CERTIFICATE` - **THEN** `parsePemBlocks` throws **Verified by:** `src/fixture/step-ca-fixture-parse.test.ts::"parsePemBlocks: single block returns leaf with empty chain"` AND `src/fixture/step-ca-fixture-parse.test.ts::"parsePemBlocks: multi-block returns first as leaf, rest as chain in order"` AND `src/fixture/step-ca-fixture-parse.test.ts::"parsePemBlocks: throws on empty input"` AND `src/fixture/step-ca-fixture-parse.test.ts::"parsePemBlocks: throws when no BEGIN CERTIFICATE present"` AND `src/fixture/step-ca-fixture-parse.test.ts::"parsePemBlocks: throws on unterminated block"` --- ### Requirement: stop() is idempotent `stop()` MUST be safe to call when no container has been started. After container start, `stop()` MUST invoke the container's `stop()` exactly once and clear the reference. The host-side root-cert tmpfile created during `start()` MUST be removed during `stop()` in container mode (best-effort; `unlink` failure MUST NOT throw). #### Scenario: stop without start is a no-op - **WHEN** `stop()` is called on a freshly-constructed fixture - **THEN** no error is thrown #### Scenario: stop calls container.stop() once after container start - **WHEN** `stop()` follows container `start()` - **THEN** the container's `stop()` is invoked exactly once #### Scenario: stop removes the root-cert tmpfile in container mode - **WHEN** `stop()` follows a container-mode `start()` - **THEN** the file at `rootCertPath` no longer exists **Verified by:** `src/fixture/step-ca-fixture.test.ts::"stop when no container is a no-op"` AND `src/fixture/step-ca-fixture.test.ts::"stop calls container.stop()"` AND `src/fixture/step-ca-fixture.test.ts::"stop removes the root-cert tmpfile in container mode"` --- ### Requirement: testing-integration is a peer dependency The package's `package.json` MUST list `@suaveplan/testing-integration` and `testcontainers` in `peerDependencies`, NOT in `dependencies`. #### Scenario: variant does not bundle the core or testcontainers - **WHEN** the published `package.json` is inspected - **THEN** `dependencies` does not contain either symbol **Verified by:** `src/fixture/step-ca-fixture.test.ts::"name property returns step-ca"`