Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]>
632 lines
22 KiB
TypeScript
632 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";
|
|
import {
|
|
createInMemoryModuleLifecycleStore,
|
|
moduleLifecycleSnapshotFor,
|
|
} from "./support/in-memory-module-lifecycle.js";
|
|
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.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: 5,
|
|
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),
|
|
moduleLifecycle: {
|
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
|
store: createInMemoryModuleLifecycleStore(),
|
|
},
|
|
moduleConfiguration: moduleConfigurationOptionFor(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);
|
|
}
|
|
});
|
|
});
|