Initial Commit
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# @suaveplan/testing-integration-garage-s3 - Garage S3 v2.0.0 testcontainers fixture for @suaveplan/testing-integration
|
||||
|
||||
## Summary
|
||||
|
||||
Garage S3 v2.0.0 testcontainers fixture for `@suaveplan/testing-integration`.
|
||||
|
||||
Implements `ServiceFixture<GarageS3Config>` — manages a Garage container lifecycle including the 7-step init sequence (status, layout assign, layout apply, bucket create, key create, bucket allow, key info). Returns a plain S3 config; consumers bring their own `@aws-sdk/client-s3`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [garage-s3-fixture](./src/fixture/garage-s3-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add -D @suaveplan/testing-integration-garage-s3
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
|
||||
|
||||
const fixture = new GarageS3Fixture();
|
||||
const config = await fixture.start();
|
||||
// config.endpoint, config.adminEndpoint, config.region, config.bucket
|
||||
// config.accessKeyId, config.secretAccessKey
|
||||
await fixture.stop();
|
||||
```
|
||||
|
||||
## Env-bridge passthrough
|
||||
|
||||
Set all 4 env vars to skip container startup:
|
||||
|
||||
```text
|
||||
GARAGE_S3_ENDPOINT=http://localhost:3900
|
||||
GARAGE_S3_ADMIN_ENDPOINT=http://localhost:3902
|
||||
GARAGE_S3_ACCESS_KEY_ID=your-key-id
|
||||
GARAGE_S3_SECRET_ACCESS_KEY=your-secret
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- [GarageS3Fixture](src/fixture/garage-s3-fixture.md)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@suaveplan/testing-integration-garage-s3",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Garage S3 v2.0.0 testcontainers fixture for @suaveplan/testing-integration",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./fixture/garage-s3-fixture": {
|
||||
"types": "./dist/fixture/garage-s3-fixture.d.ts",
|
||||
"default": "./dist/fixture/garage-s3-fixture.js"
|
||||
},
|
||||
"./constants": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"testcontainers": ">=11.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"testcontainers": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"testcontainers": "catalog:containers",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Default Docker image for Garage S3. */
|
||||
export const GARAGE_S3_IMAGE = "ghcr.io/deuxfleurs/garage:v2.0.0";
|
||||
|
||||
/** S3 API port exposed by Garage. */
|
||||
export const GARAGE_S3_API_PORT = 3900;
|
||||
|
||||
/** Admin API port exposed by Garage. */
|
||||
export const GARAGE_S3_ADMIN_PORT = 3902;
|
||||
|
||||
/** Default bucket name created during init. */
|
||||
export const GARAGE_S3_DEFAULT_BUCKET = "test-bucket";
|
||||
|
||||
/** Default S3 region. */
|
||||
export const GARAGE_S3_DEFAULT_REGION = "garage";
|
||||
|
||||
/** Access key name created during init. */
|
||||
export const GARAGE_S3_KEY_NAME = "test-key";
|
||||
|
||||
/** Default storage capacity assigned to the node. */
|
||||
export const GARAGE_S3_DEFAULT_CAPACITY = "1G";
|
||||
|
||||
/** Default zone used in layout assignment. */
|
||||
export const GARAGE_S3_DEFAULT_ZONE = "default";
|
||||
|
||||
/** Layout version used when applying the cluster layout. */
|
||||
export const GARAGE_S3_LAYOUT_VERSION = "1";
|
||||
|
||||
/**
|
||||
* Log line testcontainers should wait for before treating the container as
|
||||
* ready. Garage v2 emits this once at end of startup. Used in place of the
|
||||
* default `Wait.forListeningPorts()` strategy (which hangs under Bun per
|
||||
* oven-sh/bun#21342).
|
||||
*/
|
||||
export const GARAGE_S3_READY_LOG = "Garage server is up and running";
|
||||
|
||||
/** Number of `GARAGE_S3_READY_LOG` occurrences to wait for during a single startup. */
|
||||
export const GARAGE_S3_READY_LOG_TIMES = 1;
|
||||
|
||||
/** Maximum time to wait for Garage to become healthy. */
|
||||
export const GARAGE_S3_HEALTH_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Health check poll interval in milliseconds. */
|
||||
export const GARAGE_S3_HEALTH_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
/** Env vars checked for passthrough mode — ALL must be set. */
|
||||
export const GARAGE_S3_ENV_VARS = [
|
||||
"GARAGE_S3_ENDPOINT",
|
||||
"GARAGE_S3_ADMIN_ENDPOINT",
|
||||
"GARAGE_S3_ACCESS_KEY_ID",
|
||||
"GARAGE_S3_SECRET_ACCESS_KEY",
|
||||
] as const;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GarageS3Fixture } from "./garage-s3-fixture.js";
|
||||
|
||||
describe("GarageS3Fixture — parseNodeId", () => {
|
||||
class Exposed extends GarageS3Fixture {
|
||||
parse(output: string) {
|
||||
return this.parseNodeId(output);
|
||||
}
|
||||
}
|
||||
|
||||
it("parses valid status output", { timeout: 5000 }, () => {
|
||||
const fixture = new Exposed();
|
||||
const nodeId = fixture.parse(
|
||||
"ID\tStatus\tAddr\nabc123def456 alive 127.0.0.1:3901\n"
|
||||
);
|
||||
expect(nodeId).toBe("abc123def456");
|
||||
});
|
||||
|
||||
it("throws on empty string", { timeout: 5000 }, () => {
|
||||
const fixture = new Exposed();
|
||||
expect(() => fixture.parse("")).toThrow("no data rows found");
|
||||
});
|
||||
|
||||
it("throws when only header lines present", { timeout: 5000 }, () => {
|
||||
const fixture = new Exposed();
|
||||
expect(() =>
|
||||
fixture.parse("ID\tStatus\tAddr\n=== header ===\n")
|
||||
).toThrow("no data rows found");
|
||||
});
|
||||
|
||||
it("throws when data line starts with spaces (empty first token)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fixture = new Exposed();
|
||||
// A non-blank line starting with spaces — passes filter, but split(/\s+/)[0] is ""
|
||||
expect(() => fixture.parse("ID\tStatus\n abc123 alive\n")).toThrow(
|
||||
"no data rows found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GarageS3Fixture — parseKeyInfo", () => {
|
||||
class Exposed extends GarageS3Fixture {
|
||||
parse(output: string) {
|
||||
return this.parseKeyInfo(output);
|
||||
}
|
||||
}
|
||||
|
||||
it("parses valid key info output", { timeout: 5000 }, () => {
|
||||
const fixture = new Exposed();
|
||||
const result = fixture.parse(
|
||||
"Key ID: GK1234567890\nSecret key: sk_abcdefghij\n"
|
||||
);
|
||||
expect(result.accessKeyId).toBe("GK1234567890");
|
||||
expect(result.secretAccessKey).toBe("sk_abcdefghij");
|
||||
});
|
||||
|
||||
it("throws when Key ID missing", { timeout: 5000 }, () => {
|
||||
const fixture = new Exposed();
|
||||
expect(() => fixture.parse("Secret key: sk_abcdefghij\n")).toThrow(
|
||||
"Key ID"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when Secret key missing", { timeout: 5000 }, () => {
|
||||
const fixture = new Exposed();
|
||||
expect(() => fixture.parse("Key ID: GK1234567890\n")).toThrow(
|
||||
"Secret key"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
# GarageS3Fixture
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`GarageS3Fixture` is an integration test fixture that manages the full lifecycle of a Garage S3 v2.0.0 container using testcontainers. It implements the `ServiceFixture<GarageS3Config>` interface from `@suaveplan/testing-integration`, providing a complete S3-compatible object storage backend for integration tests.
|
||||
|
||||
Garage requires a 7-step initialization sequence after startup: node status, layout assignment, layout application, bucket creation, key creation, bucket permission grant, and key credential retrieval. This fixture automates the entire sequence.
|
||||
|
||||
## Features
|
||||
|
||||
- **Env-bridge passthrough**: when all four vars (`GARAGE_S3_ENDPOINT`, `GARAGE_S3_ADMIN_ENDPOINT`, `GARAGE_S3_ACCESS_KEY_ID`, `GARAGE_S3_SECRET_ACCESS_KEY`) are set and non-empty, returns them directly. Partial sets fall through to container mode.
|
||||
- **7-step init sequence**: fully automated post-startup initialization including layout and bucket provisioning.
|
||||
- **Admin-port health check**: polls `GET /health` on the admin port (3902) not the S3 port (3900), which does not expose a health endpoint.
|
||||
- **Inline health polling**: `waitForHealth()` polls before running the init sequence, using configurable `healthTimeoutMs` and `healthPollIntervalMs` for fast test timeouts.
|
||||
- **Injectable exec function**: the `execFn` option replaces `container.exec()` for unit tests covering all 7 init steps.
|
||||
- **Injectable fetch**: the `fetchFn` option replaces `globalThis.fetch` in health checks.
|
||||
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without Docker.
|
||||
- **Configurable bucket name**: the `bucket` option overrides the default `test-bucket`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const fixture = new GarageS3Fixture({ bucket: "test-bucket" });
|
||||
const config = await fixture.start();
|
||||
|
||||
const s3 = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: "hello.txt",
|
||||
Body: "world",
|
||||
}));
|
||||
|
||||
await fixture.stop();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
|
||||
|
||||
// Unit test using injected execFn and fetchFn
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: fakeContainerFactory,
|
||||
execFn: async (cmd) => {
|
||||
if (cmd[1] === "status") return { stdout: "ID\tabc123\n", exitCode: 0 };
|
||||
if (cmd[1] === "key" && cmd[2] === "info")
|
||||
return { stdout: "Key ID: GK123\nSecret key: sk456\n", exitCode: 0 };
|
||||
return { stdout: "", exitCode: 0 };
|
||||
},
|
||||
fetchFn: async () => ({ ok: true } as Response),
|
||||
healthTimeoutMs: 100,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class GarageS3Fixture`
|
||||
|
||||
Implements `ServiceFixture<GarageS3Config>`.
|
||||
|
||||
#### `constructor(options?: GarageS3FixtureOptions)`
|
||||
|
||||
Creates a new fixture instance. All options are optional.
|
||||
|
||||
#### `name: string`
|
||||
|
||||
Always `"garage-s3"`. Used by `TestEnvironment` as the registry key.
|
||||
|
||||
#### `start(): Promise<GarageS3Config>`
|
||||
|
||||
Starts the service. In passthrough mode returns env var values immediately. Otherwise starts a container, waits for health, runs the 7-step init sequence, and returns the provisioned config.
|
||||
|
||||
#### `stop(): Promise<void>`
|
||||
|
||||
Stops the container. No-op in passthrough mode or when not started.
|
||||
|
||||
#### `healthCheck(): Promise<boolean>`
|
||||
|
||||
Fetches `GET http://localhost:<adminPort>/health` and returns `true` when the response is OK. Returns `false` if no container is active or the request fails.
|
||||
|
||||
### `interface GarageS3Config`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `endpoint` | `string` | S3 API URL: `http://localhost:<port>` |
|
||||
| `adminEndpoint` | `string` | Admin API URL: `http://localhost:<port>` |
|
||||
| `region` | `string` | Always `"garage"` |
|
||||
| `bucket` | `string` | Bucket name created during init |
|
||||
| `accessKeyId` | `string` | S3 access key ID from `garage key info` |
|
||||
| `secretAccessKey` | `string` | S3 secret access key from `garage key info` |
|
||||
|
||||
### `interface GarageS3FixtureOptions`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `image?` | `string` | Docker image override. |
|
||||
| `bucket?` | `string` | Bucket name override. Defaults to `"test-bucket"`. |
|
||||
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
|
||||
| `execFn?` | `(cmd: string[]) => Promise<{ stdout: string; exitCode: number }>` | Injectable executor for init sequence unit testing. |
|
||||
| `fetchFn?` | `typeof fetch` | Injectable fetch for health check unit testing. |
|
||||
| `healthTimeoutMs?` | `number` | Health check timeout. Defaults to 120s. |
|
||||
| `healthPollIntervalMs?` | `number` | Health check poll interval. Defaults to 1s. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **Why admin port for health check**: the Garage S3 API port (3900) serves S3 protocol only and does not expose `/health`. Only the admin API port (3902) does.
|
||||
- **Why inline health poll in `start()`**: the `ServiceFixture` contract requires `start()` to return only after the service is ready. The 7-step init sequence requires a live Garage instance.
|
||||
- **testcontainers exec return shape**: `ExecResult.output` contains combined stdout+stderr in testcontainers v11. The `getExecFn()` wrapper maps `.output` to `.stdout` for consistency with the `execFn` interface.
|
||||
- **Garage node ID format**: the `garage status` output contains a row per node. The node ID is the first whitespace-separated token on the first non-header data line.
|
||||
@@ -0,0 +1,482 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { GarageS3Fixture } from "./garage-s3-fixture.js";
|
||||
|
||||
type GarageCommand = string[];
|
||||
type ExecResponse = { stdout: string; exitCode: number };
|
||||
|
||||
function makeGarageContainerFake(
|
||||
portMap: Record<number, number> = { 3900: 9900, 3902: 9902 },
|
||||
execResponses?: Partial<Record<string, ExecResponse>>
|
||||
) {
|
||||
const callLog: GarageCommand[] = [];
|
||||
const stopped: boolean[] = [];
|
||||
|
||||
function defaultExec(cmd: GarageCommand): ExecResponse {
|
||||
const sub = cmd[1];
|
||||
if (sub === "status") {
|
||||
return {
|
||||
stdout: "ID\tStatus\tAddr\nabc123def456 alive 127.0.0.1:3901\n",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (sub === "key" && cmd[2] === "info") {
|
||||
return {
|
||||
stdout: "Key ID: GK1234567890\nSecret key: sk_abcdefghij\n",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: "", exitCode: 0 };
|
||||
}
|
||||
|
||||
const fakeStarted = {
|
||||
getMappedPort: (port: number) =>
|
||||
portMap[port as keyof typeof portMap] ?? port,
|
||||
stop: async () => void stopped.push(true),
|
||||
exec: async (cmd: GarageCommand) => {
|
||||
callLog.push(cmd);
|
||||
const key = cmd.join(" ");
|
||||
const resp = execResponses?.[key] ?? defaultExec(cmd);
|
||||
return {
|
||||
output: resp.stdout,
|
||||
stdout: resp.stdout,
|
||||
stderr: "",
|
||||
exitCode: resp.exitCode,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const fakeContainer = {
|
||||
withExposedPorts: function () {
|
||||
return this;
|
||||
},
|
||||
withEnvironment: function () {
|
||||
return this;
|
||||
},
|
||||
withWaitStrategy: function () {
|
||||
return this;
|
||||
},
|
||||
withCopyContentToContainer: function () {
|
||||
return this;
|
||||
},
|
||||
start: async () => fakeStarted,
|
||||
};
|
||||
|
||||
return {
|
||||
fakeContainer,
|
||||
fakeStarted,
|
||||
callLog,
|
||||
stopped,
|
||||
factory: (_img: string) => fakeContainer as never,
|
||||
execFn: async (cmd: GarageCommand): Promise<ExecResponse> => {
|
||||
callLog.push(cmd);
|
||||
return execResponses?.[cmd.join(" ")] ?? defaultExec(cmd);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
"GARAGE_S3_ENDPOINT",
|
||||
"GARAGE_S3_ADMIN_ENDPOINT",
|
||||
"GARAGE_S3_ACCESS_KEY_ID",
|
||||
"GARAGE_S3_SECRET_ACCESS_KEY",
|
||||
];
|
||||
|
||||
describe("GarageS3Fixture", () => {
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) delete process.env[key];
|
||||
});
|
||||
|
||||
it("name property returns garage-s3", { timeout: 5000 }, () => {
|
||||
const fixture = new GarageS3Fixture();
|
||||
expect(fixture.name).toBe("garage-s3");
|
||||
});
|
||||
|
||||
it("passthrough mode (all 4 vars set): returns config from env", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900";
|
||||
process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902";
|
||||
process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY";
|
||||
process.env["GARAGE_S3_SECRET_ACCESS_KEY"] = "sk_env_secret";
|
||||
|
||||
const fixture = new GarageS3Fixture();
|
||||
const config = await fixture.start();
|
||||
|
||||
expect(config.endpoint).toBe("http://localhost:3900");
|
||||
expect(config.adminEndpoint).toBe("http://localhost:3902");
|
||||
expect(config.accessKeyId).toBe("GK_ENV_KEY");
|
||||
expect(config.secretAccessKey).toBe("sk_env_secret");
|
||||
expect(config.region).toBe("garage");
|
||||
expect(config.bucket).toBe("test-bucket");
|
||||
});
|
||||
|
||||
it("passthrough mode: stop is a no-op", { timeout: 30000 }, async () => {
|
||||
process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900";
|
||||
process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902";
|
||||
process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY";
|
||||
process.env["GARAGE_S3_SECRET_ACCESS_KEY"] = "sk_env_secret";
|
||||
|
||||
const fixture = new GarageS3Fixture();
|
||||
await fixture.start();
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("partial passthrough (3 of 4 vars): falls through to container mode", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900";
|
||||
process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902";
|
||||
process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY";
|
||||
// SECRET_ACCESS_KEY not set
|
||||
|
||||
const { factory, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(config.accessKeyId).toBe("GK1234567890");
|
||||
});
|
||||
|
||||
it("container mode: full init sequence happy path", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
|
||||
expect(config.accessKeyId).toBe("GK1234567890");
|
||||
expect(config.secretAccessKey).toBe("sk_abcdefghij");
|
||||
expect(config.endpoint).toBe("http://localhost:9900");
|
||||
expect(config.adminEndpoint).toBe("http://localhost:9902");
|
||||
expect(config.bucket).toBe("test-bucket");
|
||||
expect(config.region).toBe("garage");
|
||||
});
|
||||
|
||||
it("container mode: init sequence runs 7 commands in order", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, callLog, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
|
||||
expect(callLog).toHaveLength(7);
|
||||
expect(callLog[0]?.[1]).toBe("status");
|
||||
expect(callLog[1]?.[1]).toBe("layout");
|
||||
expect(callLog[1]?.[2]).toBe("assign");
|
||||
expect(callLog[2]?.[1]).toBe("layout");
|
||||
expect(callLog[2]?.[2]).toBe("apply");
|
||||
expect(callLog[3]?.[1]).toBe("bucket");
|
||||
expect(callLog[3]?.[2]).toBe("create");
|
||||
expect(callLog[4]?.[1]).toBe("key");
|
||||
expect(callLog[4]?.[2]).toBe("create");
|
||||
expect(callLog[5]?.[1]).toBe("bucket");
|
||||
expect(callLog[5]?.[2]).toBe("allow");
|
||||
expect(callLog[6]?.[1]).toBe("key");
|
||||
expect(callLog[6]?.[2]).toBe("info");
|
||||
});
|
||||
|
||||
it("node ID passed to layout assign", { timeout: 30000 }, async () => {
|
||||
const { factory, callLog, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
|
||||
const assignCmd = callLog[1];
|
||||
expect(assignCmd).toContain("abc123def456");
|
||||
});
|
||||
|
||||
it("bucket name used in bucket create and bucket allow", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, callLog, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
bucket: "my-custom-bucket",
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
|
||||
expect(callLog[3]).toContain("my-custom-bucket");
|
||||
expect(callLog[5]).toContain("my-custom-bucket");
|
||||
});
|
||||
|
||||
it("healthCheck returns true via injected fetchFn", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(true);
|
||||
});
|
||||
|
||||
it("healthCheck returns false via injected fetchFn (ok: false)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, execFn } = makeGarageContainerFake();
|
||||
let callCount = 0;
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => {
|
||||
callCount++;
|
||||
return { ok: callCount === 1 } as Response;
|
||||
},
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("healthCheck returns false when fetch throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, execFn } = makeGarageContainerFake();
|
||||
let callCount = 0;
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => {
|
||||
callCount++;
|
||||
if (callCount > 1) throw new Error("ECONNREFUSED");
|
||||
return { ok: true } as Response;
|
||||
},
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("healthCheck returns false when no container", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fixture = new GarageS3Fixture();
|
||||
const healthy = await fixture.healthCheck();
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
|
||||
it("stop calls container.stop()", { timeout: 30000 }, async () => {
|
||||
const { factory, stopped, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
await fixture.stop();
|
||||
expect(stopped).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stop when passthrough mode is a no-op", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
process.env["GARAGE_S3_ENDPOINT"] = "http://localhost:3900";
|
||||
process.env["GARAGE_S3_ADMIN_ENDPOINT"] = "http://localhost:3902";
|
||||
process.env["GARAGE_S3_ACCESS_KEY_ID"] = "GK_ENV_KEY";
|
||||
process.env["GARAGE_S3_SECRET_ACCESS_KEY"] = "sk_env_secret";
|
||||
|
||||
const fixture = new GarageS3Fixture();
|
||||
await fixture.start();
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("stop when no container is a no-op", { timeout: 30000 }, async () => {
|
||||
const fixture = new GarageS3Fixture();
|
||||
await expect(fixture.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("waitForHealth: throws on timeout when health never passes", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: false }) as Response,
|
||||
healthTimeoutMs: 10,
|
||||
healthPollIntervalMs: 5,
|
||||
});
|
||||
await expect(fixture.start()).rejects.toThrow(
|
||||
/Health check timed out for fixture "garage-s3"/
|
||||
);
|
||||
});
|
||||
|
||||
it("image override: factory called with custom image", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const calledWith: string[] = [];
|
||||
const { fakeContainer, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
image: "ghcr.io/deuxfleurs/garage:custom",
|
||||
containerFactory: (img) => {
|
||||
calledWith.push(img);
|
||||
return fakeContainer as never;
|
||||
},
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
expect(calledWith[0]).toBe("ghcr.io/deuxfleurs/garage:custom");
|
||||
});
|
||||
|
||||
it("bucket override: reflected in init commands and returned config", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, callLog, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
bucket: "custom-bucket",
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
const config = await fixture.start();
|
||||
|
||||
expect(config.bucket).toBe("custom-bucket");
|
||||
expect(callLog[3]).toContain("custom-bucket");
|
||||
});
|
||||
|
||||
it("getExecFn: uses injected execFn when provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const called: string[][] = [];
|
||||
const { factory } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn: async (cmd) => {
|
||||
called.push(cmd);
|
||||
if (cmd[1] === "status") {
|
||||
return {
|
||||
stdout: "ID\tStatus\tAddr\nabc123test alive 127.0.0.1\n",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
if (cmd[1] === "key" && cmd[2] === "info") {
|
||||
return {
|
||||
stdout: "Key ID: GK_TEST\nSecret key: sk_TEST\n",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
return { stdout: "", exitCode: 0 };
|
||||
},
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
expect(called.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("getExecFn: falls back to container.exec when no execFn provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, callLog } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
healthTimeoutMs: 1000,
|
||||
healthPollIntervalMs: 10,
|
||||
});
|
||||
await fixture.start();
|
||||
expect(callLog.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolveContainerFactory: falls back to GenericContainer import", {
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
class Exposed extends GarageS3Fixture {
|
||||
resolveFactory() {
|
||||
return this.resolveContainerFactory();
|
||||
}
|
||||
}
|
||||
const exposed = new Exposed();
|
||||
const factory = await exposed.resolveFactory();
|
||||
expect(typeof factory).toBe("function");
|
||||
const container = factory("ghcr.io/deuxfleurs/garage:v2.0.0");
|
||||
expect(container).toBeDefined();
|
||||
});
|
||||
|
||||
it("start(): stops container and clears reference when waitForHealth throws", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { factory, stopped } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
fetchFn: async () => ({ ok: false }) as Response,
|
||||
healthTimeoutMs: 10,
|
||||
healthPollIntervalMs: 5,
|
||||
});
|
||||
|
||||
await expect(fixture.start()).rejects.toThrow(
|
||||
/Health check timed out for fixture "garage-s3"/
|
||||
);
|
||||
// Container must have been stopped and cleared to prevent leaks
|
||||
expect(stopped).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("waitForHealth: uses GARAGE_S3_HEALTH_TIMEOUT_MS and GARAGE_S3_HEALTH_POLL_INTERVAL_MS constants when no options provided", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Create fixture without healthTimeoutMs / healthPollIntervalMs so the
|
||||
// default-constant branches in waitForHealth() are taken.
|
||||
const { factory, execFn } = makeGarageContainerFake();
|
||||
const fixture = new GarageS3Fixture({
|
||||
containerFactory: factory,
|
||||
execFn,
|
||||
// fetchFn returns ok:true on first call so health passes immediately
|
||||
fetchFn: async () => ({ ok: true }) as Response,
|
||||
// Intentionally omit healthTimeoutMs and healthPollIntervalMs
|
||||
});
|
||||
const config = await fixture.start();
|
||||
expect(config.accessKeyId).toBe("GK1234567890");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* GarageS3Fixture — testcontainers-backed ServiceFixture for Garage S3 v2.0.0.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
applyWaitStrategy,
|
||||
HealthPoller,
|
||||
type ServiceFixture,
|
||||
} from "@suaveplan/testing-integration";
|
||||
import type { GenericContainer, StartedTestContainer } from "testcontainers";
|
||||
import {
|
||||
GARAGE_S3_ADMIN_PORT,
|
||||
GARAGE_S3_API_PORT,
|
||||
GARAGE_S3_DEFAULT_BUCKET,
|
||||
GARAGE_S3_DEFAULT_CAPACITY,
|
||||
GARAGE_S3_DEFAULT_REGION,
|
||||
GARAGE_S3_DEFAULT_ZONE,
|
||||
GARAGE_S3_ENV_VARS,
|
||||
GARAGE_S3_HEALTH_POLL_INTERVAL_MS,
|
||||
GARAGE_S3_HEALTH_TIMEOUT_MS,
|
||||
GARAGE_S3_IMAGE,
|
||||
GARAGE_S3_KEY_NAME,
|
||||
GARAGE_S3_LAYOUT_VERSION,
|
||||
GARAGE_S3_READY_LOG,
|
||||
GARAGE_S3_READY_LOG_TIMES,
|
||||
} from "../constants/index.js";
|
||||
import type {
|
||||
GarageS3Config,
|
||||
GarageS3FixtureOptions,
|
||||
} from "./garage-s3-fixture.types.js";
|
||||
|
||||
/**
|
||||
* Manages the full lifecycle of a Garage S3 v2.0.0 container for integration tests.
|
||||
*
|
||||
* Supports env-bridge passthrough mode: when all four env vars are set and non-empty,
|
||||
* the fixture returns them directly without starting a container. Partial sets fall
|
||||
* through to container mode.
|
||||
*
|
||||
* After container startup, performs a 7-step initialization sequence:
|
||||
* garage status -> layout assign -> layout apply -> bucket create ->
|
||||
* key create -> bucket allow -> key info
|
||||
*/
|
||||
export class GarageS3Fixture implements ServiceFixture<GarageS3Config> {
|
||||
readonly name = "garage-s3";
|
||||
|
||||
protected readonly options: GarageS3FixtureOptions;
|
||||
protected container: StartedTestContainer | null = null;
|
||||
protected config: GarageS3Config | null = null;
|
||||
protected passthrough = false;
|
||||
|
||||
constructor(options: GarageS3FixtureOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the container factory. Returns the injected factory when provided,
|
||||
* otherwise dynamically imports testcontainers and wraps GenericContainer.
|
||||
*/
|
||||
protected async resolveContainerFactory(): Promise<
|
||||
(image: string) => GenericContainer
|
||||
> {
|
||||
if (this.options.containerFactory) {
|
||||
return this.options.containerFactory;
|
||||
}
|
||||
const { GenericContainer } = await import("testcontainers");
|
||||
return (img: string) => new GenericContainer(img);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal Garage v2 config TOML mounted at `/etc/garage.toml`.
|
||||
* The `rpc_secret` is freshly generated per fixture instance so each test
|
||||
* run uses an isolated cluster identity. Storage paths point at `/tmp`
|
||||
* inside the container so no host volume is required.
|
||||
*/
|
||||
protected buildGarageConfig(): string {
|
||||
const rpcSecret = randomBytes(32).toString("hex");
|
||||
const adminToken = randomBytes(16).toString("hex");
|
||||
return [
|
||||
'metadata_dir = "/tmp/garage-meta"',
|
||||
'data_dir = "/tmp/garage-data"',
|
||||
'db_engine = "sqlite"',
|
||||
"replication_factor = 1",
|
||||
"",
|
||||
'rpc_bind_addr = "[::]:3901"',
|
||||
'rpc_public_addr = "127.0.0.1:3901"',
|
||||
`rpc_secret = "${rpcSecret}"`,
|
||||
"",
|
||||
"[s3_api]",
|
||||
'api_bind_addr = "[::]:3900"',
|
||||
's3_region = "garage"',
|
||||
'root_domain = ".s3.garage.localhost"',
|
||||
"",
|
||||
"[admin]",
|
||||
'api_bind_addr = "[::]:3902"',
|
||||
`admin_token = "${adminToken}"`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async start(): Promise<GarageS3Config> {
|
||||
// Env-bridge passthrough mode — ALL four vars must be set and non-empty
|
||||
const [endpoint, adminEndpoint, accessKeyId, secretAccessKey] =
|
||||
GARAGE_S3_ENV_VARS.map((v) => process.env[v] ?? "");
|
||||
if (endpoint && adminEndpoint && accessKeyId && secretAccessKey) {
|
||||
this.passthrough = true;
|
||||
this.config = {
|
||||
endpoint,
|
||||
adminEndpoint,
|
||||
region: GARAGE_S3_DEFAULT_REGION,
|
||||
bucket: this.options.bucket ?? GARAGE_S3_DEFAULT_BUCKET,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
};
|
||||
return this.config;
|
||||
}
|
||||
|
||||
// Start container
|
||||
const image = this.options.image ?? GARAGE_S3_IMAGE;
|
||||
const factory = await this.resolveContainerFactory();
|
||||
const configToml = this.buildGarageConfig();
|
||||
let builder = factory(image)
|
||||
.withExposedPorts(GARAGE_S3_API_PORT, GARAGE_S3_ADMIN_PORT)
|
||||
.withEnvironment({ GARAGE_ALLOW_WORLD_READABLE_CONFIG: "true" });
|
||||
// Mount the generated Garage config TOML. Cast covers the
|
||||
// testcontainers method that's not in the type alias used here.
|
||||
const builderWithCopy = builder as unknown as {
|
||||
withCopyContentToContainer: (
|
||||
items: { content: string; target: string; mode?: number }[]
|
||||
) => GenericContainer;
|
||||
};
|
||||
builder = builderWithCopy.withCopyContentToContainer([
|
||||
{
|
||||
content: configToml,
|
||||
target: "/etc/garage.toml",
|
||||
mode: 0o644,
|
||||
},
|
||||
]);
|
||||
// Bun-friendly log-message wait strategy in place of the default
|
||||
// `Wait.forListeningPorts()` (which hangs under Bun per
|
||||
// oven-sh/bun#21342). Applied to the INITIAL container start, before
|
||||
// the 7-step init sequence.
|
||||
const raw = await applyWaitStrategy(builder, {
|
||||
readyLog: GARAGE_S3_READY_LOG,
|
||||
times: GARAGE_S3_READY_LOG_TIMES,
|
||||
});
|
||||
this.container = await raw.start();
|
||||
|
||||
try {
|
||||
// Wait for health before running init
|
||||
await this.waitForHealth();
|
||||
|
||||
// Run 7-step init sequence
|
||||
const bucket = this.options.bucket ?? GARAGE_S3_DEFAULT_BUCKET;
|
||||
const { accessKeyId: keyId, secretAccessKey: keySecret } =
|
||||
await this.runInitSequence(bucket);
|
||||
|
||||
const s3Port = this.container.getMappedPort(GARAGE_S3_API_PORT);
|
||||
const adminPort =
|
||||
this.container.getMappedPort(GARAGE_S3_ADMIN_PORT);
|
||||
|
||||
this.config = {
|
||||
endpoint: `http://localhost:${s3Port}`,
|
||||
adminEndpoint: `http://localhost:${adminPort}`,
|
||||
region: GARAGE_S3_DEFAULT_REGION,
|
||||
bucket,
|
||||
accessKeyId: keyId,
|
||||
secretAccessKey: keySecret,
|
||||
};
|
||||
return this.config;
|
||||
} catch (err) {
|
||||
await this.container.stop();
|
||||
this.container = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.passthrough || !this.container) return;
|
||||
await this.container.stop();
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.container) return false;
|
||||
const adminPort = this.container.getMappedPort(GARAGE_S3_ADMIN_PORT);
|
||||
try {
|
||||
const fetchFn =
|
||||
this.options.fetchFn ??
|
||||
/* istanbul ignore next */ globalThis.fetch;
|
||||
const res = await fetchFn(`http://localhost:${adminPort}/health`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls healthCheck() until it returns true or the timeout elapses.
|
||||
*
|
||||
* Delegates to {@link HealthPoller} from `@suaveplan/testing-integration`
|
||||
* so that the polling loop, timeout semantics, and `TestEnvironmentError`
|
||||
* shape are shared with the rest of the integration-testing surface.
|
||||
* Only safe to call in container mode — `healthCheck()` returns `false`
|
||||
* in passthrough mode (no container to probe).
|
||||
*/
|
||||
protected async waitForHealth(): Promise<void> {
|
||||
const timeoutMs =
|
||||
this.options.healthTimeoutMs ?? GARAGE_S3_HEALTH_TIMEOUT_MS;
|
||||
const intervalMs =
|
||||
this.options.healthPollIntervalMs ??
|
||||
GARAGE_S3_HEALTH_POLL_INTERVAL_MS;
|
||||
const poller = new HealthPoller({ timeoutMs, intervalMs });
|
||||
await poller.poll(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the 7-step Garage init sequence and returns the access credentials.
|
||||
*/
|
||||
protected async runInitSequence(
|
||||
bucket: string
|
||||
): Promise<{ accessKeyId: string; secretAccessKey: string }> {
|
||||
const run = this.getExecFn();
|
||||
|
||||
// Step 1: Get node ID
|
||||
const statusResult = await run(["garage", "status"]);
|
||||
const nodeId = this.parseNodeId(statusResult.stdout);
|
||||
|
||||
// Step 2: Assign layout
|
||||
await run([
|
||||
"garage",
|
||||
"layout",
|
||||
"assign",
|
||||
"-z",
|
||||
GARAGE_S3_DEFAULT_ZONE,
|
||||
"-c",
|
||||
GARAGE_S3_DEFAULT_CAPACITY,
|
||||
nodeId,
|
||||
]);
|
||||
|
||||
// Step 3: Apply layout
|
||||
await run([
|
||||
"garage",
|
||||
"layout",
|
||||
"apply",
|
||||
"--version",
|
||||
GARAGE_S3_LAYOUT_VERSION,
|
||||
]);
|
||||
|
||||
// Step 4: Create bucket
|
||||
await run(["garage", "bucket", "create", bucket]);
|
||||
|
||||
// Step 5: Create access key
|
||||
await run(["garage", "key", "create", GARAGE_S3_KEY_NAME]);
|
||||
|
||||
// Step 6: Grant bucket access
|
||||
await run([
|
||||
"garage",
|
||||
"bucket",
|
||||
"allow",
|
||||
"--read",
|
||||
"--write",
|
||||
bucket,
|
||||
"--key",
|
||||
GARAGE_S3_KEY_NAME,
|
||||
]);
|
||||
|
||||
// Step 7: Read key credentials
|
||||
const keyInfoResult = await run([
|
||||
"garage",
|
||||
"key",
|
||||
"info",
|
||||
GARAGE_S3_KEY_NAME,
|
||||
]);
|
||||
return this.parseKeyInfo(keyInfoResult.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the node ID from the output of `garage status`.
|
||||
*
|
||||
* Expects the first non-header, non-blank data line, with the node ID
|
||||
* as the first whitespace-separated token.
|
||||
*/
|
||||
protected parseNodeId(statusOutput: string): string {
|
||||
const lines = statusOutput.split("\n");
|
||||
const dataLines = lines.filter(
|
||||
(line) =>
|
||||
line.trim() !== "" &&
|
||||
!line.startsWith("=") &&
|
||||
!line.startsWith("ID")
|
||||
);
|
||||
if (dataLines.length === 0 || !dataLines[0]) {
|
||||
throw new Error(
|
||||
"GarageS3Fixture.parseNodeId: no data rows found in garage status output"
|
||||
);
|
||||
}
|
||||
const parts = dataLines[0].split(/\s+/);
|
||||
const nodeId = parts[0];
|
||||
if (!nodeId) {
|
||||
throw new Error(
|
||||
"GarageS3Fixture.parseNodeId: no data rows found in garage status output"
|
||||
);
|
||||
}
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the access key ID and secret from the output of `garage key info`.
|
||||
*/
|
||||
protected parseKeyInfo(keyInfoOutput: string): {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
} {
|
||||
const keyIdMatch = keyInfoOutput.match(/Key ID:\s+(\S+)/);
|
||||
const secretMatch = keyInfoOutput.match(/Secret key:\s+(\S+)/);
|
||||
|
||||
if (!keyIdMatch?.[1]) {
|
||||
throw new Error(
|
||||
"GarageS3Fixture.parseKeyInfo: missing Key ID line in garage key info output"
|
||||
);
|
||||
}
|
||||
if (!secretMatch?.[1]) {
|
||||
throw new Error(
|
||||
"GarageS3Fixture.parseKeyInfo: missing Secret key line in garage key info output"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId: keyIdMatch[1],
|
||||
secretAccessKey: secretMatch[1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the exec function to use for running commands in the container.
|
||||
* Uses the injected execFn when provided; otherwise wraps container.exec().
|
||||
*/
|
||||
protected getExecFn(): (
|
||||
cmd: string[]
|
||||
) => Promise<{ stdout: string; exitCode: number }> {
|
||||
if (this.options.execFn) {
|
||||
return this.options.execFn;
|
||||
}
|
||||
const container = this.container!;
|
||||
return async (cmd: string[]) => {
|
||||
const result = await container.exec(cmd);
|
||||
return { stdout: result.stdout, exitCode: result.exitCode };
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { GenericContainer } from "testcontainers";
|
||||
|
||||
export interface GarageS3Config extends Record<string, unknown> {
|
||||
/** S3 endpoint URL: "http://localhost:<s3Port>" */
|
||||
endpoint: string;
|
||||
/** Admin endpoint URL: "http://localhost:<adminPort>" */
|
||||
adminEndpoint: string;
|
||||
/** Always "garage" */
|
||||
region: string;
|
||||
/** Bucket name created during init */
|
||||
bucket: string;
|
||||
/** Parsed from garage key info output */
|
||||
accessKeyId: string;
|
||||
/** Parsed from garage key info output */
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
export interface GarageS3FixtureOptions {
|
||||
/** Override the Docker image. Defaults to GARAGE_S3_IMAGE constant. */
|
||||
image?: string;
|
||||
/** Bucket name to create. Defaults to "test-bucket". */
|
||||
bucket?: string;
|
||||
/** Injectable container factory for unit testing. */
|
||||
containerFactory?: (image: string) => GenericContainer;
|
||||
/**
|
||||
* Injectable command runner for unit testing the 7-step init sequence.
|
||||
* Returns stdout and exitCode. When provided, replaces container exec.
|
||||
*/
|
||||
execFn?: (cmd: string[]) => Promise<{ stdout: string; exitCode: number }>;
|
||||
/** Injectable fetch for healthCheck unit testing. */
|
||||
fetchFn?: typeof fetch;
|
||||
/**
|
||||
* Override health check timeout (ms). Defaults to GARAGE_S3_HEALTH_TIMEOUT_MS.
|
||||
* Provided to allow short timeouts in unit tests.
|
||||
*/
|
||||
healthTimeoutMs?: number;
|
||||
/**
|
||||
* Override health check poll interval (ms). Defaults to GARAGE_S3_HEALTH_POLL_INTERVAL_MS.
|
||||
*/
|
||||
healthPollIntervalMs?: number;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { GarageS3Fixture } from "./garage-s3-fixture.js";
|
||||
export type {
|
||||
GarageS3Config,
|
||||
GarageS3FixtureOptions,
|
||||
} from "./garage-s3-fixture.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./constants/index.js";
|
||||
export { GarageS3Fixture } from "./fixture/garage-s3-fixture.js";
|
||||
export type {
|
||||
GarageS3Config,
|
||||
GarageS3FixtureOptions,
|
||||
} from "./fixture/garage-s3-fixture.types.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { webcrypto } from "node:crypto";
|
||||
|
||||
type WritableGlobal = { crypto?: Crypto; File?: unknown };
|
||||
const g = globalThis as WritableGlobal;
|
||||
if (g.crypto === undefined) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
// testcontainers/undici requires File global in Node environments
|
||||
if (g.File === undefined) {
|
||||
g.File = class File extends Blob {
|
||||
readonly name: string;
|
||||
readonly lastModified: number;
|
||||
constructor(
|
||||
fileBits: BlobPart[],
|
||||
fileName: string,
|
||||
options?: FilePropertyBag
|
||||
) {
|
||||
super(fileBits, options);
|
||||
this.name = fileName;
|
||||
this.lastModified = options?.lastModified ?? Date.now();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
"testcontainers",
|
||||
"@suaveplan/testing-integration",
|
||||
"node:net",
|
||||
"node:crypto",
|
||||
],
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
rollupTypes: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user