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

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 .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"