Files
Ekklesia/apps/api/tests/module-configuration-routes.test.ts
T
ekklesia fc6c8045c4
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s
feat: add native module realtime transport
2026-08-23 18:47:18 -05:00

801 lines
29 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
type ModuleConfigurationDeclarationInput,
type ModuleDatabase,
type ModuleRuntimeServices,
parseEkklesiaUserId,
} from "@ekklesia/contracts";
import {
type AuthorizationStore,
CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
type ModuleConfigurationRecord,
type ModuleConfigurationStore,
ModuleConfigurationStoreError,
EMPTY_MODULE_REALTIME_REGISTRY,
resolveModuleEligibility,
} from "@ekklesia/core/modules";
import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import {
createFakeModuleSecretSource,
createInMemoryModuleConfigurationStore,
} from "./support/in-memory-module-configuration.js";
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("55555555-5555-4555-8555-555555555555");
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
/**
* The configuration shape the real Announcements product would need as an Ekklesia module: a slide
* duration and poll interval with defaults, a bucket name it must be told, and a deployment-supplied
* storage credential.
*
* Modelled from the reference repository, never imported from it. Nothing Announcements-specific
* exists in Core or in `apps/api`; this fixture exists to prove a real product's settings survive
* the whole path — manifest, validation, storage, API, snapshot — without the module reaching any
* platform infrastructure.
*/
const announcementsConfiguration: readonly ModuleConfigurationDeclarationInput[] = [
{
key: "slideSeconds",
type: "integer",
default: 7,
minimum: 1,
maximum: 600,
description: "How long each slide is shown.",
},
{ key: "pollIntervalSeconds", type: "integer", default: 30, minimum: 5, maximum: 3600 },
{ key: "bucket", type: "string", required: true, minLength: 1, maxLength: 63 },
{ key: "fit", type: "enum", values: ["contain", "cover"], default: "contain" },
{ key: "storageCredential", type: "secret", required: true },
];
interface ModuleFixture {
readonly id: string;
readonly configuration?: readonly ModuleConfigurationDeclarationInput[];
}
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
configuration: fixture.configuration ?? [],
entrypoints: { server: "./dist/server/index.js" },
id: fixture.id,
manifestVersion: 5,
name: `${fixture.id} module`,
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
),
]);
}
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly grants?: readonly string[];
readonly records?: readonly ModuleConfigurationRecord[];
readonly secrets?: Readonly<Record<string, Readonly<Record<string, string>>>>;
readonly store?: ModuleConfigurationStore;
}
interface Installation {
readonly app: Hono;
readonly modules: readonly DiscoveredModule[];
readonly store: ModuleConfigurationStore;
}
/**
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
* composition: read the stored settings, resolve eligibility and configuration in one pass, then
* build the UI registry and every option from that same resolution. Doing it the same way here is
* what makes these assertions evidence about production composition rather than a bespoke wiring.
*/
async function install(options: InstallOptions = {}): Promise<Installation> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-config-"));
temporaryDirectories.push(modulesDirectory);
for (const fixture of options.fixtures ?? [
{ configuration: announcementsConfiguration, id: "announcements" },
]) {
await createModule(modulesDirectory, fixture);
}
const modules = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
const store = options.store ?? createInMemoryModuleConfigurationStore(options.records ?? []);
const secrets = createFakeModuleSecretSource(options.secrets ?? {});
const lifecycleStore = createInMemoryModuleLifecycleStore();
const bootSettings = await store.readSettings();
const eligibility = resolveModuleEligibility(modules, await lifecycleStore.readConfiguration(), {
secrets,
settings: bootSettings,
});
const held = new Set(options.grants ?? []);
const evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async (query) => held.has(String(query.permission)) },
platformOwners: { isPlatformOwner: async () => options.platformOwner === true },
});
const runtime: EkklesiaIdentityRuntime = {
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
};
const authorizationStore = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: {
evaluator,
registry: composePlatformPermissionRegistry(modules),
store: authorizationStore,
},
dashboard: createInMemoryDashboardStore(),
moduleConfiguration: {
bootSettings,
discoveredModules: modules,
secrets,
snapshots: eligibility.configurations,
store,
},
moduleStorage: moduleStorageOptionFor(modules),
moduleLifecycle: {
snapshot: {
discoveredModules: modules,
eligibility: eligibility.entries,
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
},
store: lifecycleStore,
},
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app, modules, store };
}
function owner(): InstallOptions {
return { platformOwner: true, principal: createAuthenticatedPrincipal(viewerId) };
}
interface ConfigurationBody {
readonly moduleId: string;
readonly version: string;
readonly keys: readonly {
readonly key: string;
readonly type: string;
readonly required: boolean;
readonly description?: string;
readonly default?: string | number | boolean;
readonly values?: readonly string[];
readonly value?: string;
readonly configured: boolean;
}[];
readonly unknownKeys: readonly string[];
readonly restartRequired: boolean;
}
async function readConfiguration(
app: Hono,
moduleId = "announcements",
): Promise<ConfigurationBody> {
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(200);
return (await response.json()) as ConfigurationBody;
}
function putConfiguration(
app: Hono,
values: unknown,
moduleId = "announcements",
origin = trustedOrigin,
) {
return app.request(`${MODULE_CONFIGURATION_PATH}/${moduleId}`, {
body: JSON.stringify({ values }),
headers: { "content-type": "application/json", origin },
method: "PUT",
});
}
describe("GET /api/v1/module-configuration/:moduleId", () => {
it("returns the module's declared schema with defaults and stored values", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
secrets: { announcements: { storageCredential: "s3cret" } },
});
const body = await readConfiguration(app);
expect(body.moduleId).toBe("announcements");
expect(body.keys.map((entry) => entry.key)).toEqual([
"slideSeconds",
"pollIntervalSeconds",
"bucket",
"fit",
"storageCredential",
]);
expect(body.keys[0]).toEqual({
configured: false,
default: 7,
description: "How long each slide is shown.",
key: "slideSeconds",
required: false,
type: "integer",
});
expect(body.keys.find((entry) => entry.key === "bucket")).toMatchObject({
configured: true,
required: true,
value: "slides",
});
expect(body.keys.find((entry) => entry.key === "fit")?.values).toEqual(["contain", "cover"]);
});
it("never returns a secret value, only whether the deployment supplies one", async () => {
const configured = await install({
...owner(),
secrets: { announcements: { storageCredential: "s3cret" } },
});
const body = await readConfiguration(configured.app);
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
expect(secretEntry).toEqual({
configured: true,
key: "storageCredential",
required: true,
type: "secret",
});
// Not redacted, not null, not a placeholder — the field is absent entirely, and the secret
// string appears nowhere in the payload.
expect(secretEntry).not.toHaveProperty("value");
expect(secretEntry).not.toHaveProperty("default");
expect(JSON.stringify(body)).not.toContain("s3cret");
});
it("reports an unsupplied secret as not configured, still without a value field", async () => {
const { app } = await install(owner());
const body = await readConfiguration(app);
expect(body.keys.find((entry) => entry.key === "storageCredential")).toEqual({
configured: false,
key: "storageCredential",
required: true,
type: "secret",
});
});
it("lists stored keys the current manifest no longer declares, without failing", async () => {
const { app } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "removedInThisVersion", moduleId: "announcements", value: "kept" },
],
});
const body = await readConfiguration(app);
expect(body.unknownKeys).toEqual(["removedInThisVersion"]);
expect(body.keys.map((entry) => entry.key)).not.toContain("removedInThisVersion");
});
it("returns a module declaring no configuration as an empty, valid document", async () => {
const { app } = await install({ ...owner(), fixtures: [{ id: "calendar" }] });
const body = await readConfiguration(app, "calendar");
expect(body.keys).toEqual([]);
expect(body.unknownKeys).toEqual([]);
expect(body.restartRequired).toBe(false);
});
it("refuses a module this process did not discover", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/not-installed`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
});
it("is not cached and varies on origin and cookie", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(response.headers.get("Vary")).toContain("Cookie");
});
it("reports a sanitized 503 when the store cannot be read, with no value or driver detail", async () => {
const failing: ModuleConfigurationStore = {
readSettings: async () => {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_READ_FAILED");
},
replace: async () => {
throw new ModuleConfigurationStoreError("MODULE_CONFIGURATION_WRITE_FAILED");
},
};
let bootRead = false;
const { app } = await install({
...owner(),
store: {
readSettings: async () => {
if (!bootRead) {
bootRead = true;
return { records: [] };
}
return failing.readSettings();
},
replace: (input) => failing.replace(input),
},
});
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_CONFIGURATION_READ_FAILED" });
});
});
describe("PUT /api/v1/module-configuration/:moduleId", () => {
it("stores values and reports that a restart is required", async () => {
const { app, store } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await putConfiguration(app, { bucket: "slides", slideSeconds: 12 });
expect(response.status).toBe(200);
const body = (await response.json()) as ConfigurationBody;
expect(body.keys.find((entry) => entry.key === "slideSeconds")).toMatchObject({
configured: true,
value: "12",
});
// The running process still holds the boot snapshot; claiming otherwise would be a lie.
expect(body.restartRequired).toBe(true);
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
]);
});
it("reports no pending restart when the submitted document matches what booted", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await putConfiguration(app, { bucket: "slides" });
expect(((await response.json()) as ConfigurationBody).restartRequired).toBe(false);
});
it("replaces the whole document, so an omitted key is cleared", async () => {
const { app, store } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
],
});
await putConfiguration(app, { bucket: "slides" });
expect((await store.readSettings()).records.map((record) => record.key)).toEqual(["bucket"]);
});
it("never touches another module's stored values", async () => {
const { app, store } = await install({
...owner(),
fixtures: [
{ configuration: announcementsConfiguration, id: "announcements" },
{ configuration: [{ key: "bucket", type: "string" }], id: "calendar" },
],
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "bucket", moduleId: "calendar", value: "events" },
],
});
await putConfiguration(app, { bucket: "renamed" });
expect((await store.readSettings()).records).toEqual([
{ key: "bucket", moduleId: "announcements", value: "renamed" },
{ key: "bucket", moduleId: "calendar", value: "events" },
]);
});
it("refuses to accept secret material over HTTP", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, {
bucket: "slides",
storageCredential: "s3cret",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "CONFIGURATION_SECRET_NOT_WRITABLE" });
// Nothing was written at all — not the secret, and not the valid key beside it.
expect((await store.readSettings()).records).toEqual([]);
});
it("rejects a key the module does not declare", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, { bucket: "slides", mqttEndpoint: "mqtt://x" });
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "CONFIGURATION_KEY_UNDECLARED" });
expect((await store.readSettings()).records).toEqual([]);
});
it.each([
["a string for an integer", { bucket: "slides", slideSeconds: "12" }],
["a float for an integer", { bucket: "slides", slideSeconds: 12.5 }],
["an integer below its declared minimum", { bucket: "slides", slideSeconds: 0 }],
["an integer above its declared maximum", { bucket: "slides", slideSeconds: 100000 }],
["a value outside a declared enum", { bucket: "slides", fit: "stretch" }],
["a string shorter than its declared minLength", { bucket: "" }],
["a nested object", { bucket: { name: "slides" } }],
["an array", { bucket: ["slides"] }],
["null", { bucket: null }],
])("rejects %s without storing anything", async (_label, values) => {
const { app, store } = await install(owner());
const response = await putConfiguration(app, values);
expect(response.status).toBe(400);
expect((await store.readSettings()).records).toEqual([]);
});
it.each([
["a body with an unexpected top-level key", { values: {}, force: true }],
["a body with no values object", {}],
["a bare array body", []],
["a bare string body", "bucket=slides"],
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
});
it.each([
"__proto__",
"constructor",
"prototype",
])("rejects the reserved property name %s as a submitted key", async (key) => {
const { app, store } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: `{"values":{${JSON.stringify(key)}:"polluted"}}`,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(400);
expect((await store.readSettings()).records).toEqual([]);
// Nothing about the request reached `Object.prototype`.
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it("accepts an incomplete document, leaving the module blocked rather than refusing the save", async () => {
const { app, store } = await install(owner());
// `bucket` is required and absent: an operator may save partial work.
const response = await putConfiguration(app, { slideSeconds: 12 });
expect(response.status).toBe(200);
expect((await store.readSettings()).records).toEqual([
{ key: "slideSeconds", moduleId: "announcements", value: "12" },
]);
});
it("rejects an untrusted origin before authorization or the store runs", async () => {
const { app, store } = await install(owner());
const response = await putConfiguration(
app,
{ bucket: "slides" },
"announcements",
untrustedOrigin,
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect((await store.readSettings()).records).toEqual([]);
});
it("rejects a missing origin", async () => {
const { app } = await install(owner());
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: JSON.stringify({ values: { bucket: "slides" } }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
const { app } = await install(owner());
const oversized = JSON.stringify({
values: { bucket: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) },
});
const response = await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
body: oversized,
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
});
});
describe("module configuration authority", () => {
it("refuses an anonymous caller on both verbs", async () => {
const { app } = await install();
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
expect((await putConfiguration(app, { bucket: "slides" })).status).toBe(403);
});
it("refuses an ordinary authenticated user who holds no configuration permission", async () => {
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(403);
});
it("admits a non-owner holding exactly core.modules.configuration.manage", async () => {
const { app } = await install({
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status).toBe(200);
});
it("does not let the lifecycle permission configure a module, or the reverse", async () => {
// The two authorities are deliberately separate: tuning how a module behaves and deciding
// whether it runs at all are different decisions (ADR 0029).
const lifecycleOnly = await install({
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect(
(await lifecycleOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
).toBe(403);
expect((await lifecycleOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
const configurationOnly = await install({
grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)],
principal: createAuthenticatedPrincipal(viewerId),
});
expect((await configurationOnly.app.request(MODULE_LIFECYCLE_PATH)).status).toBe(403);
expect(
(await configurationOnly.app.request(`${MODULE_CONFIGURATION_PATH}/announcements`)).status,
).toBe(200);
});
});
describe("configuration state is visible through the lifecycle resource", () => {
it("reports a module blocked by missing required configuration, naming the key not the value", async () => {
const { app } = await install({
...owner(),
secrets: { announcements: { storageCredential: "s3cret" } },
});
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
const body = (await response.json()) as {
readonly modules: readonly {
readonly id: string;
readonly runtime: {
readonly state: string;
readonly reason?: string;
readonly configurationKey?: string;
};
}[];
};
expect(body.modules[0]?.runtime).toEqual({
configurationKey: "bucket",
reason: "configuration_missing",
state: "blocked",
});
});
it("reports a module blocked by a missing secret, without naming the secret's value", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const response = await app.request(MODULE_LIFECYCLE_PATH, {
headers: { origin: trustedOrigin },
});
const raw = await response.text();
expect(raw).toContain("secret_missing");
expect(raw).toContain("storageCredential");
});
it("keeps a configuration-blocked module out of the mountable UI inventory", async () => {
const { app } = await install({
...owner(),
fixtures: [
{ configuration: [{ key: "bucket", type: "string", required: true }], id: "announcements" },
],
});
const inventory = (await (await app.request("/api/v1/module-ui")).json()) as {
readonly modules: readonly { readonly id: string }[];
};
// Blocked by its own configuration behaves exactly like blocked by a disabled dependency: it is
// not eligible, so it is not published.
expect(inventory.modules).toEqual([]);
});
});
describe("no secret or configuration value leaks through any surface", () => {
it("keeps secret material out of every response body this resource produces", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
secrets: { announcements: { storageCredential: "super-secret-value" } },
});
const listed = await (
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
})
).text();
const mutated = await (await putConfiguration(app, { bucket: "slides" })).text();
const lifecycle = await (
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
).text();
for (const payload of [listed, mutated, lifecycle]) {
expect(payload).not.toContain("super-secret-value");
}
});
it("does not serve a secret value even if a row for that key somehow exists in storage", async () => {
// Unreachable through the API, which refuses to write a secret key, and unreachable through the
// resolver, which never reads one. This plants the row directly to prove the *serialization*
// boundary holds independently: a secret's `value` is omitted by construction, not filtered by a
// branch someone could remove.
const { app } = await install({
...owner(),
records: [
{ key: "bucket", moduleId: "announcements", value: "slides" },
{ key: "storageCredential", moduleId: "announcements", value: "planted-in-the-database" },
],
});
const body = await readConfiguration(app);
const secretEntry = body.keys.find((entry) => entry.key === "storageCredential");
expect(secretEntry).not.toHaveProperty("value");
expect(secretEntry?.configured).toBe(false);
expect(JSON.stringify(body)).not.toContain("planted-in-the-database");
});
it("never leaks a filesystem path, package name, or manifest path", async () => {
const { app } = await install({
...owner(),
records: [{ key: "bucket", moduleId: "announcements", value: "slides" }],
});
const listed = await (
await app.request(`${MODULE_CONFIGURATION_PATH}/announcements`, {
headers: { origin: trustedOrigin },
})
).text();
expect(listed).not.toContain("/tmp");
expect(listed).not.toContain("@test/module-");
expect(listed).not.toContain("ekklesia.module.json");
expect(listed).not.toContain("dist/");
});
});
describe("the configuration prefix is a sibling of the module route namespace", () => {
it("does not sit under /api/v1/modules/, where an installed module could collide with it", async () => {
// `config` and `configuration` are ordinary lowercase static segments under
// `moduleRoutePathSchema`, so a module could legitimately register `PUT /config` and land on
// `/api/v1/modules/announcements/config`. A sibling prefix cannot collide.
expect(MODULE_CONFIGURATION_PATH.startsWith("/api/v1/modules/")).toBe(false);
expect(MODULE_CONFIGURATION_PATH).toBe("/api/v1/module-configuration");
const { app } = await install(owner());
const collision = await app.request("/api/v1/modules/announcements/config", {
body: JSON.stringify({ values: {} }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "PUT",
});
expect(collision.status).toBe(404);
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
});
});