14 KiB
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
.nameis 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), androotCertPathpointing 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, andprovisionerName - THEN those values appear in the
step ca initargv andprovisionerNameappears as thenameof the JWK provisioner inserted intoca.json
Scenario: rootCertPath points at a real file containing rootCertPem
- WHEN
start()resolves - THEN the file at
rootCertPathexists and its contents byte-equalrootCertPem
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
containerFactoryis injected - THEN the fixture dynamically imports testcontainers and uses
GenericContainer
Scenario: getExecFn uses injected execFn when provided
- WHEN
options.execFnis provided - THEN the init sequence runs through that function
Scenario: getExecFn falls back to container.exec
- WHEN no
execFnis 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 totrue
Scenario: returns false on ok=false
- WHEN the injected fetch returns
{ ok: false } - THEN
healthCheck()resolves tofalse
Scenario: returns false when fetch throws
- WHEN the injected fetch throws
- THEN
healthCheck()resolves tofalse
Scenario: returns false when no container
- WHEN
healthCheck()is called beforestart() - 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()returnsfalsepast the timeout - THEN
start()rejects with aTestEnvironmentErrorcontainingHealth 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_MSandSTEPCA_HEALTH_POLL_INTERVAL_MSare 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 intochainPem, returns the issuedkeyPem, and the serial fromstep 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.testin 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_FAILEDand the injectedexecFnis never invoked
Scenario: non-zero exit rejects with ISSUE_CERT_FAILED
- WHEN the injected
execFnreturns a non-zero exit code - THEN the call rejects with
INTEGRATION.STEP_CA.ISSUE_CERT_FAILEDand the rejection'scauseexposes the captured stderr
Scenario: malformed PEM rejects with PARSE_PEM_FAILED
- WHEN the cert payload is missing an
END CERTIFICATEline - 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
leafequals the block andchainis""
Scenario: parses multi-block input
- WHEN the input contains a leaf followed by two chain certs
- THEN
leafis the first block andchainis the second + third blocks concatenated in order
Scenario: throws on empty / non-PEM input
- WHEN the input is empty or contains no
BEGIN CERTIFICATEline - THEN
parsePemBlocksthrows
Scenario: throws on unterminated block
- WHEN the input contains
BEGIN CERTIFICATEbut no matchingEND CERTIFICATE - THEN
parsePemBlocksthrows
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 containerstart() - THEN the container's
stop()is invoked exactly once
Scenario: stop removes the root-cert tmpfile in container mode
- WHEN
stop()follows a container-modestart() - THEN the file at
rootCertPathno 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.jsonis inspected - THEN
dependenciesdoes not contain either symbol
Verified by: src/fixture/step-ca-fixture.test.ts::"name property returns step-ca"