Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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)

View File

@@ -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"
]
}

View File

@@ -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;

View File

@@ -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"
);
});
});

View File

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

View File

@@ -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");
});
});

View File

@@ -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 };
};
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
export { GarageS3Fixture } from "./garage-s3-fixture.js";
export type {
GarageS3Config,
GarageS3FixtureOptions,
} from "./garage-s3-fixture.types.js";

View File

@@ -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";

View File

@@ -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();
}
};
}

View File

@@ -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"
]
}

View File

@@ -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,
}),
],
});

View File

@@ -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,
},
});