Files
Ekklesia/apps/api/tests/dashboard-routes.test.ts
T
ekklesiaandClaude Opus 5 bd34c9c714
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 44s
feat: add Dashboard navigation as presentation configuration
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which
is not a usable experience — and the obvious fix carries an obvious trap.
Letting a module declare its folder, or modelling "Media" as something
Livestream and Video belong to, turns a rendering preference into
architecture: a folder becomes an owner, membership starts to look like a
dependency, and the flat module graph acquires a hierarchy nobody decided on.

So the rule comes first: a Dashboard group is a label on a rendering bucket.
No manifest, no version, no artifact, no API, no lifecycle; nothing depends on
it. Livestream depends on Video because it calls Video's functionality, and
that orders startup. Livestream is shown under Media, and that orders nothing.
No parent module, child module, owning module, or group-derived dependency
appears anywhere in the contracts.

No manifest change and no version 5. `manifest.name` has been required since
version 1 and already is the module's product identity, so the shell renders
"Livestream" instead of `livestream` with nothing added. `suggestedGroup` is
deliberately deferred rather than shipped: it would force a hard
manifest-version cutover for a hint, and its only correct semantics needs
initialization logic that risks a suggestion overwriting an administrator's
decision. Defaulting and ownership are different things.

Two Core tables (migration 0003) hold groups and placements — server-side,
because a church configures its Dashboard rather than one browser;
platform-global, because no organization model exists to scope them to.
`group_id` is `on delete set null`, so deleting a group ungroups its modules
and a dangling reference is impossible. `module_id` carries no foreign key,
because modules are packages rather than rows, so a placement for an
uninstalled module is retained and restores its position on reinstall.

`resolveDashboardNavigation` is a pure function of configuration plus the
mountable set: an unplaced module is ungrouped and visible with no
initialization write, an absent module renders nothing, empty groups are kept,
and ordering is total with the module id as final tie-break.

`GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of
the host-neutral module UI inventory rather than merged into it — each generic
descriptor is embedded untouched, so a standalone host never inherits Dashboard
semantics. Reading needs the permission the inventory already needs; organizing
needs the new platform permission `core.dashboard.navigation.manage` through a
narrow, idempotent, closed-validation mutation contract. No editor UI.

The flatness rule is executable: a test discovers real fixtures with a genuine
dependency, arranges them four ways including both in one folder and the
dependency inverted in presentation, and asserts discovery order, dependency
declarations, and the module set stay byte-identical. The end-to-end test
organizes a real installation through the real API, mounts a module from inside
a group, moves it, and asserts the navigation entry is byte-identical across the
move and still mounts the same artifact.

The shell's client for the generic inventory became dead code and was removed
rather than kept speculatively; the endpoint itself is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 12:44:40 -05:00

622 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,
createAuthorizationEvaluator,
} from "@ekklesia/core/authorization";
import type { DashboardConfiguration } from "@ekklesia/core/dashboard";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
discoverModules,
} 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 { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333");
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 name: string;
readonly withUi?: boolean;
}
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 writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${fixture.id}`,
type: "module",
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
},
id: fixture.id,
manifestVersion: 4,
name: fixture.name,
version: "1.0.0",
}),
);
await writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`,
);
if (fixture.withUi !== false) {
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(
path.join(moduleDirectory, "dist/ui/index.js"),
`export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`,
);
}
}
interface InstallOptions {
readonly fixtures?: readonly ModuleFixture[];
readonly principal?: EkklesiaPrincipal;
readonly platformOwner?: boolean;
readonly configuration?: Partial<DashboardConfiguration>;
}
const defaultFixtures: readonly ModuleFixture[] = [
{ id: "video", name: "Video" },
{ id: "livestream", name: "Livestream" },
{ id: "calendar", name: "Calendar" },
];
async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-"));
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 evaluator = createAuthorizationEvaluator({
grants: { hasActiveGrant: async () => false },
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 store = {
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 },
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
moduleRoutes: Object.freeze([]),
moduleServices: Object.freeze({
database: Object.freeze({}) as unknown as ModuleDatabase,
}) as ModuleRuntimeServices,
moduleUi: createModuleUiPublicationRegistry(modules),
runtime,
trustedOrigins: [trustedOrigin],
});
return { app };
}
function signedIn(): EkklesiaPrincipal {
return createAuthenticatedPrincipal(viewerId);
}
interface NavigationEntry {
readonly name: string;
readonly module: {
readonly id: string;
readonly version: string;
readonly ui: { readonly url: string };
readonly api: { readonly baseUrl: string };
readonly permissions: readonly string[];
};
}
interface NavigationBody {
readonly groups: ReadonlyArray<{
readonly id: string;
readonly label: string;
readonly order: number;
readonly modules: readonly NavigationEntry[];
}>;
readonly ungrouped: readonly NavigationEntry[];
}
async function navigationOf(app: Hono): Promise<NavigationBody> {
const response = await app.request(DASHBOARD_NAVIGATION_PATH);
expect(response.status).toBe(200);
return (await response.json()) as NavigationBody;
}
/** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted
* manage permission exactly as in production. */
async function administrator(configuration: Partial<DashboardConfiguration> = {}) {
return install({ configuration, platformOwner: true, principal: signedIn() });
}
function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) {
return app.request(url, {
...(body === undefined
? {}
: { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
headers: {
origin: trustedOrigin,
...(body === undefined ? {} : { "content-type": "application/json" }),
},
method,
});
}
describe("Dashboard navigation — reading", () => {
it("shows every mountable module as ungrouped when nothing is configured", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
expect(navigation.groups).toEqual([]);
// A fresh installation is immediately usable: no initialization write, no manual database edit.
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Calendar",
"Livestream",
"Video",
]);
});
it("renders the module's own manifest name, never its id", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream");
expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe(
"Livestream",
);
});
it("groups and orders exactly as the stored configuration says", async () => {
const { app } = await install({
configuration: {
groups: [
{ id: "planning", label: "Planning", sortOrder: 20 },
{ id: "media", label: "Media", sortOrder: 10 },
],
placements: [
{ groupId: "media", moduleId: "livestream", sortOrder: 20 },
{ groupId: "media", moduleId: "video", sortOrder: 10 },
{ groupId: "planning", moduleId: "calendar", sortOrder: 10 },
],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(
navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]),
).toEqual([
["Media", ["Video", "Livestream"]],
["Planning", ["Calendar"]],
]);
expect(navigation.ungrouped).toEqual([]);
});
it("embeds the generic mount descriptor unchanged inside each entry", async () => {
const { app } = await install({ principal: signedIn() });
const navigation = await navigationOf(app);
const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream");
// Presentation wraps the host-neutral descriptor; it never rewrites it.
expect(livestream?.module).toEqual({
api: { baseUrl: "/api/v1/modules/livestream" },
id: "livestream",
permissions: [],
ui: { url: "/api/v1/module-ui/livestream/1.0.0" },
version: "1.0.0",
});
expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]);
});
it("agrees exactly with the generic inventory about what is mountable", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as {
modules: ReadonlyArray<{ id: string }>;
};
const navigationModules = [
...navigation.groups.flatMap((group) => group.modules),
...navigation.ungrouped,
].map((entry) => entry.module);
// Both derive from one publication registry through one shared description, so they cannot
// disagree about what exists or about any URL.
expect(navigationModules.map((module) => module.id).sort()).toEqual(
inventory.modules.map((module) => module.id).sort(),
);
});
it("keeps the generic inventory free of presentation metadata", async () => {
const { app } = await install({ principal: signedIn() });
const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text();
// The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or
// display names. That layering is the whole reason navigation is a separate route.
for (const field of ["name", "label", "group", "order", "ungrouped"]) {
expect(body, field).not.toContain(`"${field}"`);
}
});
it("omits a module that is no longer installed, without breaking navigation", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [
{ groupId: "media", moduleId: "video", sortOrder: 10 },
{ groupId: "media", moduleId: "removed-module", sortOrder: 20 },
],
},
fixtures: [{ id: "video", name: "Video" }],
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]);
expect(JSON.stringify(navigation)).not.toContain("removed-module");
});
it("keeps a newly installed module visible alongside an existing arrangement", async () => {
const { app } = await install({
configuration: {
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
},
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]);
// Calendar and Livestream were never configured and are still reachable.
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]);
});
it("excludes a module that ships no UI, exactly like the inventory does", async () => {
const { app } = await install({
fixtures: [
{ id: "video", name: "Video" },
{ id: "roster", name: "Roster", withUi: false },
],
principal: signedIn(),
});
const navigation = await navigationOf(app);
expect(JSON.stringify(navigation)).not.toContain("Roster");
});
it("leaks no filesystem path, package root, or manifest internal", async () => {
const { app } = await install({ principal: signedIn() });
const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text();
expect(body).not.toContain(tmpdir());
expect(body).not.toContain("dist");
expect(body).not.toContain("@test/module-");
expect(body).not.toContain("ekklesia.module.json");
expect(body).not.toContain("entrypoints");
expect(body).not.toContain("manifestVersion");
});
it("is per-principal, uncached, and refused to anonymous callers", async () => {
const { app: anonymous } = await install();
const { app } = await install({ principal: signedIn() });
const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH);
const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, {
headers: { origin: trustedOrigin },
});
expect(denied.status).toBe(403);
expect(await denied.text()).not.toContain("Livestream");
expect(allowed.headers.get("cache-control")).toBe("no-store");
const vary = allowed.headers.get("vary")?.toLowerCase() ?? "";
expect(vary).toContain("origin");
expect(vary).toContain("cookie");
});
});
describe("Dashboard navigation — organizing", () => {
it("creates a group and places a module, and both persist into the resolved view", async () => {
const { app } = await administrator();
expect(
(
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
})
).status,
).toBe(200);
expect(
(
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "media",
order: 10,
})
).status,
).toBe(200);
const navigation = await navigationOf(app);
expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]);
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]);
});
it("renames a group without moving anything", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }],
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Sunday Production",
order: 10,
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" });
expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]);
});
it("moves a module between groups and changes only its placement", async () => {
const { app } = await administrator({
groups: [
{ id: "media", label: "Media", sortOrder: 10 },
{ id: "sunday-production", label: "Sunday Production", sortOrder: 5 },
],
placements: [
{ groupId: "media", moduleId: "livestream", sortOrder: 10 },
{ groupId: "media", moduleId: "video", sortOrder: 20 },
],
});
const before = await navigationOf(app);
const livestreamBefore = before.groups
.flatMap((group) => group.modules)
.find((entry) => entry.module.id === "livestream");
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "sunday-production",
order: 10,
});
const after = await navigationOf(app);
const livestreamAfter = after.groups
.flatMap((group) => group.modules)
.find((entry) => entry.module.id === "livestream");
// The one property this whole pass exists to guarantee: the module — its artifact URL, its API
// root, its permissions, its version, its name — is byte-identical across the move.
expect(livestreamAfter).toEqual(livestreamBefore);
expect(
after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name),
).toEqual(["Livestream"]);
expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([
"Video",
]);
});
it("ungroups a module deliberately", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
groupId: null,
order: 5,
});
const navigation = await navigationOf(app);
expect(navigation.groups[0]?.modules).toEqual([]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Video",
"Calendar",
"Livestream",
]);
});
it("ungroups a deleted group's modules rather than losing them", async () => {
const { app } = await administrator({
groups: [{ id: "media", label: "Media", sortOrder: 10 }],
placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }],
});
const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`);
const navigation = await navigationOf(app);
expect(deleted.status).toBe(204);
expect(navigation.groups).toEqual([]);
expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([
"Video",
"Calendar",
"Livestream",
]);
});
it("refuses a placement naming a group that does not exist", async () => {
const { app } = await administrator();
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, {
groupId: "not-a-group",
order: 10,
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" });
});
it("refuses a body carrying anything beyond the declared fields", async () => {
const { app } = await administrator();
const bodies: readonly unknown[] = [
{ label: "Media", order: 10, parentGroupId: "root" },
{ label: "Media", order: 10, ownerModuleId: "video" },
{ label: "Media" },
{ label: "", order: 10 },
{ label: "Media", order: -1 },
{ label: "Media", order: 1.5 },
{ label: 42, order: 10 },
[],
null,
];
for (const body of bodies) {
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body);
expect(response.status, JSON.stringify(body)).toBe(400);
expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" });
}
});
it("refuses a malformed group identifier in the path", async () => {
const { app } = await administrator();
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, {
label: "Media",
order: 10,
});
expect(response.status).toBe(400);
});
});
describe("Dashboard navigation — authority", () => {
it("refuses organizing to a signed-in user without the manage permission", async () => {
// Reading works for any signed-in user; organizing does not.
const { app } = await install({ principal: signedIn() });
expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200);
const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("refuses organizing from an untrusted origin, before authorization even runs", async () => {
const { app } = await administrator();
const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
body: JSON.stringify({ label: "Media", order: 10 }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "PUT",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("refuses organizing to an anonymous caller", async () => {
const { app } = await install();
for (const [method, url, body] of [
["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }],
["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined],
["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }],
] as const) {
const response = await mutate(app, method, url, body);
expect(response.status, `${method} ${url}`).toBe(403);
}
});
it("grants no module authority to an administrator who reorganizes the Dashboard", async () => {
// The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That
// says nothing about any module: the hints in the navigation view are still evaluated per
// module permission, and every module route stays enforced on its own.
const { app } = await administrator();
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, {
label: "Media",
order: 10,
});
await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, {
groupId: "media",
order: 10,
});
const navigation = await navigationOf(app);
const livestream = navigation.groups[0]?.modules[0];
// The fixture modules declare no permissions at all, so an owner still holds none for them:
// moving a module into a folder cannot manufacture a permission hint, let alone a grant.
expect(livestream?.module.permissions).toEqual([]);
});
it("routes no method the navigation contract does not define", async () => {
const { app } = await administrator();
for (const method of ["POST", "PATCH"] as const) {
const response = await app.request(DASHBOARD_NAVIGATION_PATH, {
headers: { origin: trustedOrigin },
method,
});
expect(response.status, method).toBe(404);
}
});
});