Files
2026-05-27 23:29:53 +01:00

242 lines
14 KiB
Markdown

# 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<StepCaConfig>
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 <caName> --dns <dnsNames> --address :8443 --provisioner <provisionerName> --password-file <pwFile>`, (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:<caPort>/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 <commonName> <crt> <key> --provisioner <provisionerName> --provisioner-password-file <pwFile>` inside the container via the injected `execFn`, append `--san <s>` for each entry in `sans`, and append `--not-after=<ttl>` 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=<ttl>"` 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"`