CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
583 lines
22 KiB
TypeScript
583 lines
22 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 ModuleDatabase,
|
|
type ModuleRuntimeServices,
|
|
parseEkklesiaUserId,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationStore,
|
|
CORE_MODULES_LIFECYCLE_MANAGE,
|
|
createAuthorizationEvaluator,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleUiPublicationRegistry,
|
|
type DiscoveredModule,
|
|
discoverModules,
|
|
type ModuleLifecycleStore,
|
|
ModuleLifecycleStoreError,
|
|
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 { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
|
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
|
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
|
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
|
import { readyHealth } from "./support/ready-health.js";
|
|
import { createInMemoryModuleLifecycleStore } from "./support/in-memory-module-lifecycle.js";
|
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.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("44444444-4444-4444-8444-444444444444");
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { force: true, recursive: true })),
|
|
);
|
|
});
|
|
|
|
interface ModuleFixture {
|
|
readonly id: string;
|
|
readonly dependencies?: Record<string, unknown>;
|
|
}
|
|
|
|
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 mkdir(path.join(moduleDirectory, "dist/ui"), { 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" },
|
|
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
|
id: fixture.id,
|
|
manifestVersion: 8,
|
|
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" };`,
|
|
),
|
|
writeFile(
|
|
path.join(moduleDirectory, "dist/ui/index.js"),
|
|
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
|
|
),
|
|
]);
|
|
}
|
|
|
|
const defaultFixtures: readonly ModuleFixture[] = [
|
|
{ id: "storage" },
|
|
{ id: "announcements", dependencies: { modules: { required: { storage: "^1.0.0" } } } },
|
|
];
|
|
|
|
interface InstallOptions {
|
|
readonly fixtures?: readonly ModuleFixture[];
|
|
readonly principal?: EkklesiaPrincipal;
|
|
readonly platformOwner?: boolean;
|
|
readonly bootDisabled?: readonly string[];
|
|
readonly store?: ModuleLifecycleStore;
|
|
/** Permissions this principal actually holds through a grant, evaluated by Core's real evaluator
|
|
* — never a bypass, so a delegated holder is exercised the same way a real one would be. */
|
|
readonly grants?: readonly string[];
|
|
}
|
|
|
|
interface Installation {
|
|
readonly app: Hono;
|
|
readonly modules: readonly DiscoveredModule[];
|
|
readonly store: ModuleLifecycleStore;
|
|
}
|
|
|
|
/**
|
|
* Composes the real application over a real discovered inventory, reproducing `startApi`'s own
|
|
* composition: resolve eligibility from the stored configuration, then build the UI publication
|
|
* registry and the boot snapshot from the **eligible** list rather than the discovered one. Doing it
|
|
* the same way here is what makes these assertions evidence about production composition rather than
|
|
* about a bespoke test wiring.
|
|
*/
|
|
async function install(options: InstallOptions = {}): Promise<Installation> {
|
|
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-module-lifecycle-"));
|
|
temporaryDirectories.push(modulesDirectory);
|
|
for (const fixture of options.fixtures ?? defaultFixtures) {
|
|
await createModule(modulesDirectory, fixture);
|
|
}
|
|
const modules = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
const store =
|
|
options.store ??
|
|
createInMemoryModuleLifecycleStore(
|
|
(options.bootDisabled ?? []).map((moduleId) => ({ enabled: false, moduleId })),
|
|
);
|
|
const bootConfiguration = await store.readConfiguration();
|
|
const eligibility = resolveModuleEligibility(modules, bootConfiguration);
|
|
|
|
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,
|
|
// Permission composition still runs over the whole discovered inventory, unchanged: a
|
|
// disabled module's permissions stay registered and grantable, and nothing enforces them
|
|
// because it mounts no routes.
|
|
registry: composePlatformPermissionRegistry(modules),
|
|
store: authorizationStore,
|
|
},
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
health: readyHealth(),
|
|
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
|
moduleStorage: moduleStorageOptionFor(modules),
|
|
moduleLifecycle: {
|
|
snapshot: {
|
|
discoveredModules: modules,
|
|
eligibility: eligibility.entries,
|
|
startedModuleIds: eligibility.eligibleModules.map((module) => module.manifest.id),
|
|
},
|
|
store,
|
|
},
|
|
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 LifecycleEntry {
|
|
readonly id: string;
|
|
readonly version: string;
|
|
readonly configured: { readonly enabled: boolean; readonly explicit: boolean };
|
|
readonly runtime: {
|
|
readonly state: string;
|
|
readonly reason?: string;
|
|
readonly dependency?: { readonly kind: string; readonly moduleId: string };
|
|
};
|
|
readonly restartRequired: boolean;
|
|
}
|
|
|
|
async function readLifecycle(app: Hono): Promise<readonly LifecycleEntry[]> {
|
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as { readonly modules: readonly LifecycleEntry[] };
|
|
return body.modules;
|
|
}
|
|
|
|
function setEnabled(app: Hono, moduleId: string, enabled: boolean, origin = trustedOrigin) {
|
|
return app.request(`${MODULE_LIFECYCLE_PATH}/${moduleId}/enabled`, {
|
|
body: JSON.stringify({ enabled }),
|
|
headers: { "content-type": "application/json", origin },
|
|
method: "PUT",
|
|
});
|
|
}
|
|
|
|
describe("GET /api/v1/module-lifecycle", () => {
|
|
it("reports every discovered module with both axes separated", async () => {
|
|
const { app } = await install(owner());
|
|
|
|
expect(await readLifecycle(app)).toEqual([
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
id: "storage",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "1.0.0",
|
|
},
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
id: "announcements",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "1.0.0",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("explains a disabled module and the dependent it blocks, with a structured reason", async () => {
|
|
const { app } = await install({ ...owner(), bootDisabled: ["storage"] });
|
|
|
|
expect(await readLifecycle(app)).toEqual([
|
|
{
|
|
configured: { enabled: false, explicit: true },
|
|
id: "storage",
|
|
restartRequired: false,
|
|
runtime: { state: "disabled" },
|
|
version: "1.0.0",
|
|
},
|
|
{
|
|
configured: { enabled: true, explicit: false },
|
|
id: "announcements",
|
|
restartRequired: false,
|
|
runtime: {
|
|
dependency: { kind: "module", moduleId: "storage" },
|
|
reason: "required_dependency_disabled",
|
|
state: "blocked",
|
|
},
|
|
version: "1.0.0",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("is not cached and varies on origin and cookie", async () => {
|
|
const { app } = await install(owner());
|
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
|
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
|
expect(response.headers.get("Vary")).toContain("Cookie");
|
|
});
|
|
|
|
it("refuses an anonymous caller", async () => {
|
|
const { app } = await install();
|
|
const response = await app.request(MODULE_LIFECYCLE_PATH);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
});
|
|
|
|
it("refuses an ordinary authenticated user who holds no lifecycle permission", async () => {
|
|
// The same principal that `core.modules.ui.read` (baseline `authenticated`) would admit: this
|
|
// resource is deliberately restricted, because it is the only place a disabled module is
|
|
// visible at all.
|
|
const { app } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
|
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
|
const lifecycle = await app.request(MODULE_LIFECYCLE_PATH);
|
|
|
|
expect(inventory.status).toBe(200);
|
|
expect(lifecycle.status).toBe(403);
|
|
});
|
|
|
|
it("admits a non-owner holding exactly core.modules.lifecycle.manage through a real grant", async () => {
|
|
const { app } = await install({
|
|
grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)],
|
|
principal: createAuthenticatedPrincipal(viewerId),
|
|
});
|
|
|
|
expect((await app.request(MODULE_LIFECYCLE_PATH)).status).toBe(200);
|
|
// Holding it confers no module authority: the Dashboard's own manage permission is untouched by
|
|
// this grant, so a lifecycle administrator cannot reorganize navigation.
|
|
const reorganize = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
|
|
body: JSON.stringify({ label: "Media", order: 10 }),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "PUT",
|
|
});
|
|
expect(reorganize.status).toBe(403);
|
|
});
|
|
|
|
it("reports a sanitized 503 when the configuration cannot be read, never a driver detail", async () => {
|
|
const failing: ModuleLifecycleStore = {
|
|
readConfiguration: async () => {
|
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_READ_FAILED");
|
|
},
|
|
setEnabled: async () => {
|
|
throw new ModuleLifecycleStoreError("MODULE_LIFECYCLE_WRITE_FAILED");
|
|
},
|
|
};
|
|
// Boot with a working store, then swap in the failing one for the request path only.
|
|
const { app } = await install({ ...owner(), store: failingAfterBoot(failing) });
|
|
const response = await app.request(MODULE_LIFECYCLE_PATH, {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "MODULE_LIFECYCLE_READ_FAILED" });
|
|
});
|
|
});
|
|
|
|
/** Answers the one boot-time read with an empty configuration, then fails every later call — so a
|
|
* request-path failure can be exercised without making startup composition itself impossible. */
|
|
function failingAfterBoot(failing: ModuleLifecycleStore): ModuleLifecycleStore {
|
|
let bootRead = false;
|
|
return {
|
|
async readConfiguration() {
|
|
if (!bootRead) {
|
|
bootRead = true;
|
|
return { modules: [] };
|
|
}
|
|
return failing.readConfiguration();
|
|
},
|
|
setEnabled: (input) => failing.setEnabled(input),
|
|
};
|
|
}
|
|
|
|
describe("PUT /api/v1/module-lifecycle/:moduleId/enabled", () => {
|
|
it("persists a decision and reports honestly that the running process has not changed", async () => {
|
|
const { app } = await install(owner());
|
|
|
|
const response = await setEnabled(app, "announcements", false);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({
|
|
configured: { enabled: false, explicit: true },
|
|
id: "announcements",
|
|
// Not `disabled`: this process is still serving that module. Claiming otherwise would be a
|
|
// lie about a module that is still running.
|
|
restartRequired: true,
|
|
runtime: { state: "running" },
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("marks the dependents of a newly disabled module as restart-pending too", async () => {
|
|
const { app } = await install(owner());
|
|
|
|
await setEnabled(app, "storage", false);
|
|
|
|
expect((await readLifecycle(app)).map((entry) => [entry.id, entry.restartRequired])).toEqual([
|
|
["storage", true],
|
|
["announcements", true],
|
|
]);
|
|
});
|
|
|
|
it("is idempotent, and re-enabling clears the pending restart", async () => {
|
|
const { app } = await install(owner());
|
|
|
|
await setEnabled(app, "announcements", false);
|
|
await setEnabled(app, "announcements", false);
|
|
const reEnabled = await setEnabled(app, "announcements", true);
|
|
|
|
expect(await reEnabled.json()).toEqual({
|
|
configured: { enabled: true, explicit: true },
|
|
id: "announcements",
|
|
restartRequired: false,
|
|
runtime: { state: "running" },
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("refuses a module this process cannot see, rather than storing an unverifiable record", async () => {
|
|
const { app, store } = await install(owner());
|
|
|
|
const response = await setEnabled(app, "not-installed", false);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "MODULE_NOT_INSTALLED" });
|
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
|
});
|
|
|
|
it("refuses a malformed module identifier without reaching the store", async () => {
|
|
const { app, store } = await install(owner());
|
|
|
|
const response = await setEnabled(app, "Not%20A%20Module", false);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
|
});
|
|
|
|
it("rejects an untrusted origin before authorization or the store runs", async () => {
|
|
const { app, store } = await install(owner());
|
|
|
|
const response = await setEnabled(app, "announcements", false, untrustedOrigin);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
|
});
|
|
|
|
it("rejects a missing origin", async () => {
|
|
const { app } = await install(owner());
|
|
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
|
body: JSON.stringify({ enabled: false }),
|
|
headers: { "content-type": "application/json" },
|
|
method: "PUT",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
});
|
|
|
|
it("refuses an authenticated caller without the restricted permission", async () => {
|
|
const { app, store } = await install({ principal: createAuthenticatedPrincipal(viewerId) });
|
|
|
|
const response = await setEnabled(app, "announcements", false);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
["a non-boolean value", { enabled: "false" }],
|
|
["a missing field", {}],
|
|
["an unexpected key alongside a valid one", { enabled: false, force: true }],
|
|
["an array", []],
|
|
["a bare string", "disabled"],
|
|
])("rejects %s with a closed, structured validation failure", async (_label, body) => {
|
|
const { app, store } = await install(owner());
|
|
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
|
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" });
|
|
expect((await store.readConfiguration()).modules).toEqual([]);
|
|
});
|
|
|
|
it("enforces the platform body limit with the one sanitized 413 contract", async () => {
|
|
const { app } = await install(owner());
|
|
const oversized = JSON.stringify({ enabled: "x".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1024) });
|
|
const response = await app.request(`${MODULE_LIFECYCLE_PATH}/announcements/enabled`, {
|
|
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" });
|
|
});
|
|
|
|
it("never leaks a filesystem path, package name, or manifest path through either verb", async () => {
|
|
const { app } = await install(owner());
|
|
const listed = await (
|
|
await app.request(MODULE_LIFECYCLE_PATH, { headers: { origin: trustedOrigin } })
|
|
).text();
|
|
const mutated = await (await setEnabled(app, "announcements", false)).text();
|
|
|
|
for (const payload of [listed, mutated]) {
|
|
expect(payload).not.toContain("/tmp");
|
|
expect(payload).not.toContain("@test/module-");
|
|
expect(payload).not.toContain("ekklesia.module.json");
|
|
expect(payload).not.toContain("dist/");
|
|
expect(payload).not.toContain("entrypoint");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("lifecycle state governs module UI exposure", () => {
|
|
it("keeps a disabled module out of the mountable inventory and out of Dashboard navigation", async () => {
|
|
const { app } = await install({
|
|
bootDisabled: ["announcements"],
|
|
platformOwner: true,
|
|
principal: createAuthenticatedPrincipal(viewerId),
|
|
});
|
|
|
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
|
readonly modules: readonly { readonly id: string }[];
|
|
};
|
|
const navigation = (await (await app.request(DASHBOARD_NAVIGATION_PATH)).json()) as {
|
|
readonly groups: readonly { readonly modules: readonly { readonly name: string }[] }[];
|
|
readonly ungrouped: readonly { readonly module: { readonly id: string } }[];
|
|
};
|
|
|
|
// Discovered, validated, and declaring a real compiled UI artifact — and still absent, because
|
|
// publication is built from the eligible set rather than the discovered one.
|
|
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage"]);
|
|
expect(navigation.ungrouped.map((entry) => entry.module.id)).toEqual(["storage"]);
|
|
expect(navigation.groups).toEqual([]);
|
|
});
|
|
|
|
it("keeps a dependency-blocked module out of both, exactly like an explicitly disabled one", async () => {
|
|
const { app } = await install({
|
|
bootDisabled: ["storage"],
|
|
platformOwner: true,
|
|
principal: createAuthenticatedPrincipal(viewerId),
|
|
});
|
|
|
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
|
readonly modules: readonly { readonly id: string }[];
|
|
};
|
|
|
|
expect(inventory.modules).toEqual([]);
|
|
});
|
|
|
|
it("does not remove a module from navigation merely because a restart is pending", async () => {
|
|
const { app } = await install({
|
|
platformOwner: true,
|
|
principal: createAuthenticatedPrincipal(viewerId),
|
|
});
|
|
|
|
await setEnabled(app, "announcements", false);
|
|
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
|
|
readonly modules: readonly { readonly id: string }[];
|
|
};
|
|
|
|
// The module really is still mounted and still serving; hiding its UI now would break a viewer
|
|
// mid-session for a change that has not taken effect. `restartRequired` is where that pending
|
|
// change is reported.
|
|
expect(inventory.modules.map((entry) => entry.id)).toEqual(["storage", "announcements"]);
|
|
});
|
|
});
|
|
|
|
describe("the lifecycle 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 () => {
|
|
// `moduleRoutePathSchema` accepts `enabled` as an ordinary static segment, so a module named
|
|
// `announcements` may legitimately register `PUT /enabled` — mounted at exactly
|
|
// `/api/v1/modules/announcements/enabled`. If the platform published its lifecycle mutation
|
|
// there, registration order would decide which one answers.
|
|
expect(MODULE_LIFECYCLE_PATH.startsWith("/api/v1/modules/")).toBe(false);
|
|
expect(MODULE_LIFECYCLE_PATH).toBe("/api/v1/module-lifecycle");
|
|
|
|
const { app } = await install(owner());
|
|
const collision = await app.request("/api/v1/modules/announcements/enabled", {
|
|
body: JSON.stringify({ enabled: false }),
|
|
headers: { "content-type": "application/json", origin: trustedOrigin },
|
|
method: "PUT",
|
|
});
|
|
|
|
expect(collision.status).toBe(404);
|
|
expect(await collision.json()).toEqual({ code: "NOT_FOUND" });
|
|
});
|
|
});
|