import { randomBytes } from "node:crypto"; import { ModuleStorageError } from "@ekklesia/contracts"; import { afterAll, describe, expect, it } from "vitest"; import { physicalBucketName } from "../src/storage/binding.js"; import { createStorageProviderConfig } from "../src/storage/config.js"; import { createModuleStorage } from "../src/storage/module-storage.js"; import type { StorageProvider } from "../src/storage/provider.js"; import { createS3StorageProvider } from "../src/storage/s3-provider.js"; /** * The storage contract against a **real S3-compatible server**. * * The unit suite proves scoping, binding, and validation with an in-memory provider; this proves the * things only a real server can — and the reason it must exist is concrete. Probing the pinned * SeaweedFS 4.28 image directly revealed that `CreateBucket` on an existing bucket reports * `BucketAlreadyExists`, **not** AWS's `BucketAlreadyOwnedByYou`. A provider that only treated the * AWS spelling as success would have failed on every second boot against this repository's own * development storage service, and no amount of mocking would have caught it. * * SeaweedFS is used rather than a generic mock because it is the intended deployment target * (ADR 0004) and because the repository's Compose file already pins and provisions it, so this adds * no new CI infrastructure. Nothing here is SeaweedFS-specific in the *contract* — the same suite * would pass against MinIO or AWS S3, which is the portability claim being tested. */ const endpointEnvironment = "EKKLESIA_TEST_STORAGE_ENDPOINT"; const accessKeyEnvironment = "EKKLESIA_TEST_STORAGE_ACCESS_KEY"; const secretKeyEnvironment = "EKKLESIA_TEST_STORAGE_SECRET_KEY"; class IntegrationHarnessError extends Error { constructor(message: string) { super(message); this.name = "IntegrationHarnessError"; this.stack = `${this.name}: ${this.message}`; } } function providerConfig() { const endpoint = process.env[endpointEnvironment]; if (typeof endpoint !== "string" || endpoint.trim() === "") { throw new IntegrationHarnessError("The storage integration endpoint is not configured."); } const config = createStorageProviderConfig( { accessKey: process.env[accessKeyEnvironment], endpoint, secretKey: process.env[secretKeyEnvironment], }, // The disposable service speaks plaintext on loopback, exactly like the disposable PostgreSQL // this suite already uses; production configuration would refuse it. { environment: "development" }, ); if (!config) { throw new IntegrationHarnessError("The storage integration configuration is invalid."); } return config; } /** A unique prefix per run, so repeated runs and parallel jobs never collide and no test depends on * another's leftovers. */ function disposablePrefix(): string { return `ekktest${randomBytes(3).toString("hex")}`; } const openProviders: StorageProvider[] = []; function provider(): StorageProvider { const created = createS3StorageProvider(providerConfig()); openProviders.push(created); return created; } afterAll(async () => { for (const open of openProviders.splice(0)) { await open.close().catch(() => undefined); } }); describe.sequential("live S3-compatible module storage", () => { it("creates a module's bucket idempotently, across both provider dialects", async () => { const shared = provider(); const prefix = disposablePrefix(); const bucket = physicalBucketName(prefix, "announcements", "announcement-assets"); await shared.ensureBucket(bucket); // The assertion this whole suite exists for: the pinned SeaweedFS image answers a repeat // `CreateBucket` with `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`, and the // provider must treat both as success. Every restart depends on this. await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined(); await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined(); }); it("round-trips an object through the scoped facade with its content type preserved", async () => { const prefix = disposablePrefix(); const storage = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["announcement-assets"], moduleId: "announcements", provider: provider(), }); const bucket = storage.bucket("announcement-assets"); const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const written = await bucket.put("slides/welcome.png", png, { contentType: "image/png" }); expect(written.key).toBe("slides/welcome.png"); const object = await bucket.get("slides/welcome.png"); expect(object.contentType).toBe("image/png"); expect(object.contentLength).toBe(png.byteLength); // Unquoted for the caller — providers return a quoted ETag, and every module learning to strip // quotes would be a contract failure rather than a module failure. expect(object.etag).toMatch(/^[0-9a-f]{32}$/u); expect(new Uint8Array(await new Response(object.body).arrayBuffer())).toEqual(png); }); it("streams a body without buffering, when the length is supplied", async () => { const prefix = disposablePrefix(); const bucket = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "media", provider: provider(), }).bucket("assets"); const payload = "x".repeat(64 * 1024); const stream = new Blob([payload]).stream() as ReadableStream; await bucket.put("large.bin", stream, { contentLength: payload.length, contentType: "application/octet-stream", }); const object = await bucket.get("large.bin"); expect(object.contentLength).toBe(payload.length); expect(await new Response(object.body).text()).toBe(payload); }); it("returns a Web stream a module can hand straight to a Response", async () => { const prefix = disposablePrefix(); const bucket = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "announcements", provider: provider(), }).bucket("assets"); await bucket.put("welcome.png", new Uint8Array([1, 2, 3]), { contentType: "image/png" }); const object = await bucket.get("welcome.png"); expect(object.body).toBeInstanceOf(ReadableStream); // The browser-delivery shape this pass settles on: browser → module route → scoped storage, // with no S3 knowledge or credential anywhere near the browser. const response = new Response(object.body, { headers: { "content-type": object.contentType ?? "application/octet-stream" }, }); expect(response.headers.get("content-type")).toBe("image/png"); expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); }); it("heads, lists with a prefix, and deletes", async () => { const prefix = disposablePrefix(); const bucket = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "announcements", provider: provider(), }).bucket("assets"); await bucket.put("slides/a.png", "a", { contentType: "image/png" }); await bucket.put("slides/b.png", "b", { contentType: "image/png" }); await bucket.put("other/c.txt", "c"); const metadata = await bucket.head("slides/a.png"); expect(metadata.contentType).toBe("image/png"); expect(metadata.contentLength).toBe(1); expect(metadata.lastModified).toBeInstanceOf(Date); const listing = await bucket.list({ prefix: "slides/" }); expect(listing.objects.map((object) => object.key)).toEqual(["slides/a.png", "slides/b.png"]); expect(listing.objects[0]?.contentLength).toBe(1); // A complete listing carries no cursor, so an absent cursor always means "done". expect(listing.cursor).toBeUndefined(); await bucket.delete("slides/a.png"); expect((await bucket.list({ prefix: "slides/" })).objects.map((o) => o.key)).toEqual([ "slides/b.png", ]); }); it("paginates a listing rather than silently truncating it", async () => { const prefix = disposablePrefix(); const bucket = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "paged", provider: provider(), }).bucket("assets"); for (const index of [0, 1, 2, 3, 4]) { await bucket.put(`object-${index}.txt`, String(index)); } const first = await bucket.list({ limit: 2 }); expect(first.objects).toHaveLength(2); expect(first.cursor).toBeDefined(); const keys = [...first.objects.map((object) => object.key)]; let cursor = first.cursor; while (cursor !== undefined) { const page = await bucket.list({ cursor, limit: 2 }); keys.push(...page.objects.map((object) => object.key)); cursor = page.cursor; } expect(keys.sort()).toEqual([ "object-0.txt", "object-1.txt", "object-2.txt", "object-3.txt", "object-4.txt", ]); }); it("maps a missing object to a structured not-found error, never a provider exception", async () => { const prefix = disposablePrefix(); const bucket = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "announcements", provider: provider(), }).bucket("assets"); await bucket.put("present.txt", "x"); let caught: unknown; try { await bucket.get("absent.txt"); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(ModuleStorageError); expect((caught as ModuleStorageError).code).toBe("STORAGE_OBJECT_NOT_FOUND"); expect((caught as ModuleStorageError).key).toBe("absent.txt"); // Nothing from the provider survives translation: no endpoint, no bucket, no request id, no XML. const rendered = `${String(caught)} ${JSON.stringify(caught)}`; expect(rendered).not.toContain("http"); expect(rendered).not.toContain(prefix); expect(rendered).not.toContain(" { const shared = provider(); const prefix = disposablePrefix(); const announcements = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "announcements", provider: shared, }); const video = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "video", provider: shared, }); await announcements.bucket("assets").put("same-key.txt", "from-announcements"); await video.bucket("assets").put("same-key.txt", "from-video"); // Same logical name, same key, one real storage service — and two different objects. expect( await new Response((await announcements.bucket("assets").get("same-key.txt")).body).text(), ).toBe("from-announcements"); expect(await new Response((await video.bucket("assets").get("same-key.txt")).body).text()).toBe( "from-video", ); // Neither module can see the other's object, even by listing. expect((await announcements.bucket("assets").list()).objects.map((o) => o.key)).toEqual([ "same-key.txt", ]); // And there is no way to ask for the other module's bucket at all. expect(() => announcements.bucket("video-assets")).toThrow(ModuleStorageError); }); it("accepts the longest bucket name the mapping can produce", async () => { const shared = provider(); const prefix = disposablePrefix(); const moduleId = `m${"o".repeat(62)}`; const bucket = physicalBucketName(prefix, moduleId, `b${"u".repeat(46)}`); expect(bucket.length).toBeLessThanOrEqual(63); // Proven against the real server rather than against the documented rule. await expect(shared.ensureBucket(bucket)).resolves.toBeUndefined(); }); it("supports nested object keys, which modules genuinely use", async () => { const prefix = disposablePrefix(); const bucket = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "announcements", provider: provider(), }).bucket("assets"); await bucket.put("2026/easter/slides/welcome.png", "x", { contentType: "image/png" }); expect((await bucket.list({ prefix: "2026/easter/" })).objects.map((o) => o.key)).toEqual([ "2026/easter/slides/welcome.png", ]); }); it("probes a reachable provider, and reports an unreachable one as provider-unavailable", async () => { await expect(provider().probe()).resolves.toBeUndefined(); const unreachable = createS3StorageProvider( createStorageProviderConfig( { accessKey: "unused", // A port nothing listens on: a real connection failure rather than a simulated one. endpoint: "http://127.0.0.1:1", secretKey: "unused", }, { environment: "development" }, )!, ); try { let caught: unknown; try { await unreachable.probe(); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(ModuleStorageError); // Transient by nature: a module retries through this, and no lifecycle state changes // (ADR 0030). expect((caught as ModuleStorageError).code).toBe("STORAGE_PROVIDER_UNAVAILABLE"); } finally { await unreachable.close().catch(() => undefined); } }); it("never deletes a bucket, even when every object in it is removed", async () => { const shared = provider(); const prefix = disposablePrefix(); const bucket = createModuleStorage({ bucketPrefix: prefix, logicalBucketIds: ["assets"], moduleId: "announcements", provider: shared, }).bucket("assets"); await bucket.put("only.txt", "x"); await bucket.delete("only.txt"); // The bucket still exists and still accepts writes: emptying is not removal, and nothing in this // platform destroys a bucket (ADR 0030). expect((await bucket.list()).objects).toEqual([]); await expect(bucket.put("again.txt", "y")).resolves.toMatchObject({ key: "again.txt" }); }); });