213 lines
11 KiB
Markdown
213 lines
11 KiB
Markdown
# testing-integration-garage-s3
|
|
|
|
## Purpose
|
|
|
|
Garage S3 v2.0.0 `ServiceFixture` for integration tests. Provides env-bridge passthrough mode (4 vars), dual-port container orchestration (3900/3902), and a 7-step initialisation sequence (status, layout assign, layout apply, bucket create, key create, bucket allow, key info). Includes admin-port health checking with injectable `execFn` and `fetchFn` plus configurable health timeouts. Depends on `@suaveplan/testing-integration` and `testcontainers`.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: GarageS3Fixture implements ServiceFixture<GarageS3Config>
|
|
|
|
The package SHALL export `GarageS3Fixture` with `name === "garage-s3"`. `GarageS3Config` SHALL be `{ endpoint: string; adminEndpoint: string; region: string; bucket: string; accessKeyId: string; secretAccessKey: string }`.
|
|
|
|
#### Scenario: name property is "garage-s3"
|
|
|
|
- **WHEN** an instance's `.name` is read
|
|
- **THEN** the value is the literal `"garage-s3"`
|
|
|
|
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"name property returns garage-s3"`
|
|
|
|
---
|
|
|
|
### Requirement: Env-bridge passthrough mode (4 vars, all required)
|
|
|
|
When ALL FOUR vars in `GARAGE_S3_ENV_VARS` are set and non-empty, `start()` MUST return them directly without starting a container. A partial set (3 of 4) MUST fall through to container mode. In passthrough mode `stop()` is a no-op.
|
|
|
|
#### Scenario: returns config from env vars
|
|
|
|
- **WHEN** all four 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 3 of 4 env vars are set
|
|
- **THEN** `start()` boots a container
|
|
|
|
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"passthrough mode (all 4 vars set): returns config from env"` AND `src/fixture/garage-s3-fixture.test.ts::"stop when passthrough mode is a no-op"` AND `src/fixture/garage-s3-fixture.test.ts::"partial passthrough (3 of 4 vars): falls through to container mode"`
|
|
|
|
---
|
|
|
|
### Requirement: Container mode + 7-step init sequence
|
|
|
|
When env vars are partial or unset, `start()` MUST start a Garage S3 v2.0.0 container, expose `GARAGE_S3_API_PORT` and `GARAGE_S3_ADMIN_PORT`, set `GARAGE_ALLOW_WORLD_READABLE_CONFIG=true`, wait for health, then run the 7-step init sequence in order: (1) `garage status`, (2) `garage layout assign -z <zone> -c <capacity> <nodeId>`, (3) `garage layout apply --version <v>`, (4) `garage bucket create <bucket>`, (5) `garage key create <name>`, (6) `garage bucket allow --read --write <bucket> --key <name>`, (7) `garage key info <name>`. The returned config MUST contain endpoint URLs, region, bucket, and parsed access credentials.
|
|
|
|
#### Scenario: full init sequence happy path
|
|
|
|
- **WHEN** `start()` runs end-to-end with the injected execFn
|
|
- **THEN** the returned config contains a populated `accessKeyId` and `secretAccessKey`
|
|
|
|
#### Scenario: init sequence executes 7 commands in correct order
|
|
|
|
- **WHEN** the init sequence completes
|
|
- **THEN** the 7 garage commands appear in the documented order
|
|
|
|
#### Scenario: node ID is passed to layout assign
|
|
|
|
- **WHEN** `garage status` returns a node ID
|
|
- **THEN** that ID appears as the trailing positional arg of `garage layout assign`
|
|
|
|
#### Scenario: bucket name is used in bucket create + bucket allow
|
|
|
|
- **WHEN** the fixture is constructed with a custom bucket
|
|
- **THEN** that bucket name appears in both `bucket create` and `bucket allow` commands and in the returned config
|
|
|
|
#### 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/garage-s3-fixture.test.ts::"container mode: full init sequence happy path"` AND `src/fixture/garage-s3-fixture.test.ts::"container mode: init sequence runs 7 commands in order"` AND `src/fixture/garage-s3-fixture.test.ts::"node ID passed to layout assign"` AND `src/fixture/garage-s3-fixture.test.ts::"bucket name used in bucket create and bucket allow"` AND `src/fixture/garage-s3-fixture.test.ts::"bucket override: reflected in init commands and returned config"` AND `src/fixture/garage-s3-fixture.test.ts::"image override: factory called with custom image"` AND `src/fixture/garage-s3-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"` AND `src/fixture/garage-s3-fixture.test.ts::"getExecFn: uses injected execFn when provided"` AND `src/fixture/garage-s3-fixture.test.ts::"getExecFn: falls back to container.exec when no execFn provided"`
|
|
|
|
---
|
|
|
|
### Requirement: Admin-port HTTP /health probe
|
|
|
|
In container mode, `healthCheck()` MUST `GET http://localhost:<adminPort>/health` via the injected `fetchFn` (default `globalThis.fetch`) 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/garage-s3-fixture.test.ts::"healthCheck returns true via injected fetchFn"` AND `src/fixture/garage-s3-fixture.test.ts::"healthCheck returns false via injected fetchFn (ok: false)"` AND `src/fixture/garage-s3-fixture.test.ts::"healthCheck returns false when fetch throws"` AND `src/fixture/garage-s3-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 `GARAGE_S3_HEALTH_TIMEOUT_MS` and `GARAGE_S3_HEALTH_POLL_INTERVAL_MS`). On timeout, the rejection MUST be a `TestEnvironmentError` whose message includes `"Health check timed out for fixture \"garage-s3\""`. 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 "garage-s3"`
|
|
|
|
#### 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 `GARAGE_S3_HEALTH_TIMEOUT_MS` and `GARAGE_S3_HEALTH_POLL_INTERVAL_MS` are used
|
|
|
|
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"waitForHealth: throws on timeout when health never passes"` AND `src/fixture/garage-s3-fixture.test.ts::"start(): stops container and clears reference when waitForHealth throws"` AND `src/fixture/garage-s3-fixture.test.ts::"waitForHealth: uses GARAGE_S3_HEALTH_TIMEOUT_MS and GARAGE_S3_HEALTH_POLL_INTERVAL_MS constants when no options provided"`
|
|
|
|
---
|
|
|
|
### Requirement: parseNodeId parses garage status output
|
|
|
|
`parseNodeId(output)` MUST extract the first whitespace-delimited token from the first non-header data line. Empty / header-only / blank-leading inputs MUST throw with `"GarageS3Fixture.parseNodeId: no data rows found in garage status output"`.
|
|
|
|
#### Scenario: parses valid output
|
|
|
|
- **WHEN** the output contains a valid data row
|
|
- **THEN** the first token is returned
|
|
|
|
#### Scenario: throws on empty / header-only / blank-leading inputs
|
|
|
|
- **WHEN** the output is empty, header-only, or starts with whitespace
|
|
- **THEN** `parseNodeId` throws
|
|
|
|
**Verified by:** `src/fixture/garage-s3-fixture-parse.test.ts::"parses valid status output"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws on empty string"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when only header lines present"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when data line starts with spaces (empty first token)"`
|
|
|
|
---
|
|
|
|
### Requirement: parseKeyInfo extracts access credentials
|
|
|
|
`parseKeyInfo(output)` MUST match `Key ID:\s+(\S+)` and `Secret key:\s+(\S+)`. Either match missing MUST throw a descriptive error.
|
|
|
|
#### Scenario: parses valid key info
|
|
|
|
- **WHEN** both regexes match
|
|
- **THEN** `{ accessKeyId, secretAccessKey }` is returned
|
|
|
|
#### Scenario: throws when Key ID missing
|
|
|
|
- **WHEN** the output omits the Key ID line
|
|
- **THEN** `parseKeyInfo` throws
|
|
|
|
#### Scenario: throws when Secret key missing
|
|
|
|
- **WHEN** the output omits the Secret key line
|
|
- **THEN** `parseKeyInfo` throws
|
|
|
|
**Verified by:** `src/fixture/garage-s3-fixture-parse.test.ts::"parses valid key info output"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when Key ID missing"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when Secret key missing"`
|
|
|
|
---
|
|
|
|
### Requirement: stop() is idempotent
|
|
|
|
`stop()` MUST be safe to call when no container has been started.
|
|
|
|
#### 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
|
|
|
|
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"stop when no container is a no-op"` AND `src/fixture/garage-s3-fixture.test.ts::"stop calls container.stop()"`
|
|
|
|
---
|
|
|
|
### 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/garage-s3-fixture.test.ts::"name property returns garage-s3"`
|