Files
Ekklesia/core/tests/module-storage.integration.ts
T
ekklesiaandClaude Opus 5 b9ca0d560d
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Failing after 2m20s
feat: add host-provided module object storage
`manifest.storage.buckets` has existed since manifest version 1 and meant
nothing — discovery validated it and no code read it. This turns it into a
real capability, which is what stands between Announcements and the module
system: that product currently fetches a hardcoded LAN S3 endpoint, parses
ListObjectsV2 XML by hand, and hands the browser direct object URLs.

- A module declares a logical bucket and receives a scoped facade at
  `context.storage` — list, head, get, put, delete. The contract names no
  provider: no endpoint, region, credential, or physical bucket is reachable
  from it, which is what lets the same module run under a future standalone
  host over its own or a shared S3-compatible service.
- Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the
  deployment target (ADR 0004), never an API name. Probing the pinned image
  found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's
  `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would
  have failed every second boot. A live test pins it.
- Logical → physical mapping is deterministic and collision-safe: an
  installation prefix, the module and bucket ids joined by `--` (which cannot
  occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide),
  and a digest over the full untruncated identity. Two modules declaring
  `assets` always get separate namespaces; neither can see the physical name.
- Declaring a bucket is now a requirement: with no provider configured the
  module is `blocked · storage_unavailable` through ADR 0028's existing
  resolver, propagating to required dependents. An installation with no object
  storage stays a complete deployment.
- Binding availability is not provider health. Eligibility asks whether
  storage is configured, never whether it answered; bucket creation is lazy on
  first use so a storage outage cannot stop the platform booting; a running
  module whose store blinks stays running and retries. Nothing deletes a
  bucket.
- Credentials are deployment-supplied, never persisted, never routed through
  module configuration, and never visible to modules, module UI, diagnostics,
  or API responses. Provider errors translate to a closed code set carrying no
  endpoint, bucket, request id, or XML.
- Retires ADR 0029's transitional `bucket` configuration key. No manifest
  version change. The template declares no bucket, so it still runs without S3.

See ADR 0030.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-23 08:45:53 -05:00

358 lines
14 KiB
TypeScript

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<Uint8Array>;
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("<?xml");
expect(rendered).not.toContain(process.env[accessKeyEnvironment] ?? "");
});
it("keeps two modules declaring the same logical bucket completely isolated", async () => {
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" });
});
});