801 lines
29 KiB
TypeScript
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" });
|
|
});
|
|
});
|