`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]>
358 lines
14 KiB
TypeScript
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] ?? " |