Initial Commit
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
## Why
|
||||
|
||||
Several integration scenarios on genesis need real HTTPS, not `rejectUnauthorized: false` shortcuts: WebRTC transports (`drive-webrtc`, `transport-webrtc`) refuse non-secure contexts; HTTPS edges (`server-caddy-imgproxy`, future Caddy/nginx fixtures); SMTPS/IMAPS variants of `testing-integration-mailpit`; the HTTPS path of `testing-integration-garage-s3`. Today every consumer either rolls its own self-signed certs (and a per-suite trust dance) or skips the secure variant entirely.
|
||||
|
||||
We already have a working `step-ca` deployment at `../certs/` (PostLocker CA: `smallstep/step-ca` + Caddy/LE + a Hono API + a download portal). The CA core is reusable for tests; the public-ops layers (Caddy/LE, REST API, SQLite key DB, ACME, domain-policy) are not. This change ports the CA nucleus into the existing `testing-integration-*` plugin family so any fixture can opt into a real PKI chain with one line.
|
||||
|
||||
## What Changes
|
||||
|
||||
### New plugin package — `@suaveplan/testing-integration-step-ca`
|
||||
|
||||
- Lives at `packages/dev/testing-integration-step-ca` (mirrors the `testing-integration-*` plugin convention from `CLAUDE.md`).
|
||||
- Implements `ServiceFixture<StepCaConfig>` from `@suaveplan/testing-integration` with image `smallstep/step-ca` pinned to a digest.
|
||||
- `StepCaConfig` is `{ caUrl: string; rootCertPem: string; rootCertPath: string; fingerprint: string; provisionerName: string; provisionerPassword: string }`.
|
||||
- Env-bridge passthrough mode (5 vars, all required): `STEPCA_URL`, `STEPCA_ROOT_CERT_PEM`, `STEPCA_ROOT_CERT_PATH`, `STEPCA_FINGERPRINT`, `STEPCA_PROVISIONER_PASSWORD`. Partial set (4 of 5) falls through to container mode. Passthrough `stop()` is a no-op.
|
||||
- Container mode: starts step-ca, runs an init+configure sequence equivalent to `../certs/scripts/init-ca.sh` + `configure-ca.sh` but trimmed to the parts a test harness needs:
|
||||
1. `step ca init --deployment-type standalone --name <caName> --dns <dnsNames> --address :8443 --provisioner <provName> --password-file <pwFile>`
|
||||
2. read `/home/step/certs/root_ca.crt` → `rootCertPem`
|
||||
3. `step certificate fingerprint /home/step/certs/root_ca.crt` → `fingerprint`
|
||||
4. `step crypto jwk create` (EC P-256, ES256, sig) for the JWK provisioner, password-encrypted
|
||||
5. `jq` insert the JWK provisioner into `ca.json`
|
||||
6. write `rootCertPem` to a host tmpfile and expose its path as `rootCertPath`
|
||||
- Health: `GET https://localhost:<caPort>/health` with TLS verification disabled (the root isn't trusted yet on the host); injectable `fetchFn`. Network errors / non-`ok` → `false`. Default timeout `STEPCA_HEALTH_TIMEOUT_MS = 60_000`, poll `STEPCA_HEALTH_POLL_INTERVAL_MS = 1000`.
|
||||
- Public helper `issueCert({ commonName, sans?, ttl? }) → { certPem, keyPem, chainPem, serial }` — shells `step ca certificate` against the JWK provisioner inside the container via the injected `execFn` (default `container.exec`). Returns leaf + chain PEMs and the serial.
|
||||
- Errors prefixed `INTEGRATION.STEP_CA.*` via `@suaveplan/error`. Injectable `containerFactory`, `execFn`, and `fetchFn` for unit-testability of the fixture itself (mirrors the garage-s3 contract).
|
||||
- Pre-publish gate: 100% Istanbul coverage per `openspec/AGENTS.md` §7.
|
||||
|
||||
### Out of scope — explicitly NOT ported
|
||||
|
||||
- Caddy / Let's Encrypt public TLS (tests don't need DNS or public certs).
|
||||
- Hono OpenAPI cert-management service (`api/`), the SQLite API-key DB, and bearer-scope auth.
|
||||
- Web download portal (`web/`) and the `step` CLI binary mirror.
|
||||
- ACME provisioner and domain-policy enforcement (`API_ALLOWED_DOMAINS`).
|
||||
|
||||
The fixture issues leaves directly through the JWK provisioner using `step ca certificate` — no REST hop, no auth layer, no policy enforcement (test code knows what names it wants).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `testing-integration-step-ca` — a `ServiceFixture` for `smallstep/step-ca` (image policy, env-bridge, health-check contract, init+configure sequence, `issueCert()` helper, error code prefix).
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected code:** new package at `/mnt/s/web/genesis/packages/dev/testing-integration-step-ca/**`.
|
||||
- **Affected consumers:** opt-in. `drive-webrtc`, `transport-webrtc`, `transport-https`, `server-caddy-imgproxy`, and the secure variants of `mailpit` / `garage-s3` fixtures gain a path to real-chain HTTPS. Zero behaviour change for fixtures that don't depend on it.
|
||||
- **Catalog additions (root `package.json`):** none — `testcontainers` and `@suaveplan/testing-integration` are already pinned.
|
||||
- **CLAUDE.md / MANIFEST.md:** add the new package to the `Packages` section in `CLAUDE.md`, then run `bun scripts/folder-reorg/generate-manifest.ts` to refresh `MANIFEST.md`.
|
||||
- **Cross-cutting:** new capability spec only; no existing capability is modified. On archive the spec folds into `openspec/specs/testing-integration-step-ca/spec.md`.
|
||||
@@ -0,0 +1,241 @@
|
||||
# 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"`
|
||||
@@ -0,0 +1,66 @@
|
||||
# Tasks — testing-integration-step-ca
|
||||
|
||||
## 1. Package scaffolding
|
||||
|
||||
- [x] 1.1 Create `packages/dev/testing-integration-step-ca/` with the standard layout (`package.json`, `tsconfig.json`, `vite.config.ts`, `README.md`, `src/index.ts`, `src/fixture/`).
|
||||
- [x] 1.2 `package.json`: name `@suaveplan/testing-integration-step-ca`, version `0.1.0`, `peerDependencies` for `@suaveplan/testing-integration` and `testcontainers` (NOT `dependencies`), `dependencies` for `@suaveplan/error` and `@suaveplan/types` only.
|
||||
- [x] 1.3 Subpath exports: `.`, `./fixture`, `./constants`.
|
||||
- [x] 1.4 Add the package to `CLAUDE.md` `Packages` section, then run `bun scripts/folder-reorg/generate-manifest.ts`.
|
||||
|
||||
## 2. Image + constants
|
||||
|
||||
- [x] 2.1 Pin `smallstep/step-ca` to a specific tag + digest in `src/constants/index.ts` as `STEPCA_IMAGE`.
|
||||
- [x] 2.2 Define `STEPCA_HEALTH_TIMEOUT_MS = 60_000`, `STEPCA_HEALTH_POLL_INTERVAL_MS = 1000`.
|
||||
- [x] 2.3 Define `STEPCA_ENV_VARS = ['STEPCA_URL','STEPCA_ROOT_CERT_PEM','STEPCA_ROOT_CERT_PATH','STEPCA_FINGERPRINT','STEPCA_PROVISIONER_PASSWORD'] as const`.
|
||||
- [x] 2.4 Define `STEPCA_DEFAULT_PROVISIONER = 'api'`, `STEPCA_DEFAULT_DNS = 'localhost,step-ca'`, `STEPCA_INTERNAL_PORT = 8443`.
|
||||
|
||||
## 3. Error codes
|
||||
|
||||
- [x] 3.1 Register codes via `@suaveplan/error`'s `defineCodes` with prefix `INTEGRATION.STEP_CA.*`: `INIT_FAILED`, `JWK_PROVISIONER_FAILED`, `ROOT_READ_FAILED`, `FINGERPRINT_FAILED`, `ISSUE_CERT_FAILED`, `PARSE_PEM_FAILED`, `HEALTH_TIMEOUT`.
|
||||
|
||||
## 4. Fixture core (`src/fixture/step-ca-fixture.ts`)
|
||||
|
||||
- [x] 4.1 Implement `StepCaFixture implements ServiceFixture<StepCaConfig>` with `name === 'step-ca'`.
|
||||
- [x] 4.2 Constructor options: `{ image?; caName?; dnsNames?; provisionerName?; healthTimeoutMs?; healthPollIntervalMs?; containerFactory?; execFn?; fetchFn? }`.
|
||||
- [x] 4.3 `start()` env-bridge: when ALL FIVE `STEPCA_ENV_VARS` are set+non-empty → return passthrough config; partial (4 of 5) → fall through to container mode.
|
||||
- [x] 4.4 Container mode: start container via factory (default: dynamic `GenericContainer` import), expose internal `8443` → host port, set environment variables required by step-ca.
|
||||
- [x] 4.5 Init sequence (steps 1–6 from proposal §What Changes/Container mode); each step wrapped in a try/catch that throws the matching `INTEGRATION.STEP_CA.*` error with the underlying stderr attached.
|
||||
- [x] 4.6 Materialize `rootCertPem` to a host-side tmpfile (`fs.mkdtempSync` + `tmp/<name>.crt`), set `rootCertPath` accordingly.
|
||||
- [x] 4.7 If `waitForHealth()` rejects, stop the container exactly once and clear the reference before re-throwing.
|
||||
|
||||
## 5. Health probe
|
||||
|
||||
- [x] 5.1 `healthCheck()` returns `false` when no container started.
|
||||
- [x] 5.2 Container mode: `GET https://localhost:<caPort>/health` via injected `fetchFn` (default `globalThis.fetch`) with TLS verification disabled (use a per-call `https.Agent` or `Bun`-equivalent that the injected fetch can apply). Return `res.ok`. Network errors → `false`.
|
||||
- [x] 5.3 `waitForHealth()` delegates to `new HealthPoller({ timeoutMs, intervalMs }).poll(this)` with `STEPCA_HEALTH_TIMEOUT_MS` / `STEPCA_HEALTH_POLL_INTERVAL_MS` defaults.
|
||||
- [x] 5.4 Timeout rejection MUST be a `TestEnvironmentError` whose message contains `Health check timed out for fixture "step-ca"`.
|
||||
|
||||
## 6. `issueCert()` helper
|
||||
|
||||
- [x] 6.1 Signature `issueCert({ commonName, sans?, ttl? }) → Promise<{ certPem; keyPem; chainPem; serial }>`.
|
||||
- [x] 6.2 Shells `step ca certificate <commonName> <crt> <key> --provisioner <provName> --provisioner-password-file <pwFile> --not-after=<ttl> --san <san1> --san <san2> ...` inside the container via injected `execFn`.
|
||||
- [x] 6.3 Reads cert + key from container, splits leaf + chain via PEM block parser (own implementation; no external dep), extracts serial via `step certificate inspect --format json`.
|
||||
- [x] 6.4 Throws `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` on non-zero exit; `INTEGRATION.STEP_CA.PARSE_PEM_FAILED` on malformed PEM.
|
||||
- [x] 6.5 Verifies `commonName` is non-empty before shelling — empty → `INTEGRATION.STEP_CA.ISSUE_CERT_FAILED` (no shell invocation).
|
||||
|
||||
## 7. Tests (100% Istanbul, per AGENTS.md §7)
|
||||
|
||||
- [x] 7.1 `src/fixture/step-ca-fixture.test.ts` — passthrough mode (full + partial), container mode happy path, init sequence command order, image override, `containerFactory` injection, `execFn` injection, `fetchFn` injection, health-check truth table, `waitForHealth` timeout + cleanup, `stop()` idempotency.
|
||||
- [x] 7.2 `src/fixture/step-ca-fixture-issue-cert.test.ts` — issueCert happy path (with mocked `execFn`), SAN passthrough, TTL passthrough, malformed PEM rejection, empty commonName rejection, non-zero exit rejection.
|
||||
- [x] 7.3 `src/fixture/step-ca-fixture-parse.test.ts` — PEM block splitter (leaf + chain), serial extraction, edge cases (empty input, missing END line, multiple chain certs).
|
||||
- [x] 7.4 Integration-level smoke test `src/fixture/step-ca-fixture.integration.test.ts` (gated by `INTEGRATION_DOCKER=1`) that actually boots the container, issues a cert, and verifies the leaf chains to the root using Node's `tls.createSecureContext({ ca: rootCertPem })`.
|
||||
|
||||
## 8. Pre-publish gate
|
||||
|
||||
- [x] 8.1 `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration-step-ca` green.
|
||||
- [x] 8.2 `bunx turbo test:coverage --filter=@suaveplan/testing-integration-step-ca` reports 100% on touched modules.
|
||||
- [x] 8.3 Spec-conformance test file (`src/fixture/step-ca-fixture.spec-conformance.test.ts`) asserts every "Verified by" anchor in `specs/testing-integration-step-ca/spec.md` resolves to a real test.
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
- [x] 9.1 Root `README.md` for the package: purpose, basic example (`new StepCaFixture()` + `issueCert`), advanced example (using the root cert with `NODE_EXTRA_CA_CERTS` and Playwright `--ignore-certificate-errors-spki-list`), API reference, implementation notes, ≥200 words.
|
||||
- [x] 9.2 Co-located `.md` for each implementation file per `CLAUDE.md` Documentation rules.
|
||||
|
||||
## 10. Archive
|
||||
|
||||
- [x] 10.1 On ship: fold the spec delta into `openspec/specs/testing-integration-step-ca/spec.md` and move this folder to `openspec/archive/<yyyy-mm-dd>-testing-integration-step-ca/`.
|
||||
Reference in New Issue
Block a user