`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
634 lines
23 KiB
TypeScript
634 lines
23 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";
|
|
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.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),
|
|
moduleStorage: moduleStorageOptionFor(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);
|
|
}
|
|
});
|
|
});
|