feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
The Settings foundation (ADR 0061):
- URL model. /settings is a real landing page. The platform sections
are /settings/general, /branding, /displays, and /system. Module
lifecycle is Discover: /settings/discover/browse, /installed, and
/updates. /settings/discover is respelled in place to Browse's one
canonical address. A module's settings are /settings/<module-id>,
never nested under Discover. Deeper Settings addresses are
reserved. "Modules" stays the user-facing word: "Discover modules",
"Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
names (branding, discover, displays, general, system) as a second
documented group, so no module id can shadow a section; `modules` is
an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
into Platform and Modules (and Discover's three views) while in
Settings, and the phone drawer has the same hierarchy. The rest of
the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
Core's evaluator, which destinations the viewer may see:
- Displays: core.devices.manage
- Discover: core.modules.lifecycle.manage
- a module's settings: core.modules.configuration.manage, for each
module that declares configuration (ADR 0029)
- General, Branding, System: the platform owner, until each has its
own permission
These are presentation hints; every data route keeps its own
enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
module's settings say it is turned off; a section or module the
person cannot see says so without detail; an unknown name is not
found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
enters Discover, opens the module's settings, uses the phone
drawer, and returns to the Dashboard.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
34692ac05f
commit
89ef4b5ff6
@@ -31,6 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
import { createDashboardSettingsRoutes } from "./dashboard-settings-routes.js";
|
||||
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
@@ -455,6 +456,19 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// Which Settings destinations the viewer may see (ADR 0061): server-evaluated presentation hints
|
||||
// beside the navigation, never authority. See `./dashboard-settings-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createDashboardSettingsRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
platformOwners: options.authorization.store,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
snapshot: options.moduleLifecycle.snapshot,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationPlatformOwnerPort,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { ModuleLifecycleSnapshot } from "@ekklesia/core/modules";
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "@ekklesia/shell/dashboard-routes";
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
} from "./authorization-middleware.js";
|
||||
|
||||
export const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
|
||||
|
||||
/** One module's settings destination: its id (the `/settings/<module-id>` segment), its display
|
||||
* name, and whether it is running. A disabled or blocked module is listed so its address can say it
|
||||
* is unavailable, and flagged so the Dashboard never offers it in navigation. */
|
||||
export interface DashboardSettingsModuleView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly available: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardSettingsResponse {
|
||||
/** The platform sections this viewer may see, in navigation order. */
|
||||
readonly sections: readonly SettingsSection[];
|
||||
/** Modules whose settings this viewer may see, by name. */
|
||||
readonly modules: readonly DashboardSettingsModuleView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may see each platform section (ADR 0061).
|
||||
*
|
||||
* A section whose administration already has a Core permission uses it, so it is delegable today.
|
||||
* General, Branding, and System have no permission yet — nothing in them exists to authorize — so
|
||||
* they are shown only to the **platform owner**, the one principal who already holds every
|
||||
* permission; each gains its own delegable permission in the work that builds it.
|
||||
*/
|
||||
const SECTION_ACCESS: Readonly<Record<SettingsSection, PermissionId | "platform-owner">> =
|
||||
Object.freeze({
|
||||
branding: "platform-owner",
|
||||
// Finding, installing, turning on and off, and updating modules: deciding which modules run.
|
||||
discover: CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
displays: CORE_DEVICES_MANAGE,
|
||||
general: "platform-owner",
|
||||
system: "platform-owner",
|
||||
});
|
||||
|
||||
export interface CreateDashboardSettingsRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
/** The boot snapshot: every module this process discovered, and which it started. A module's
|
||||
* settings exist when its manifest declares configuration (ADR 0029). */
|
||||
readonly snapshot: ModuleLifecycleSnapshot;
|
||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Dashboard's Settings navigation, evaluated for the viewer.
|
||||
*
|
||||
* Like the module permission hints in the navigation (ADR 0026), these are **presentation hints,
|
||||
* not authority**: they decide which Settings destinations the Dashboard offers, so nobody is shown
|
||||
* administration they cannot carry out (DESIGN.md §9.2). Every route behind a section keeps its own
|
||||
* enforcement, unchanged. Each answer goes through Core's one evaluator — baselines, grants, expiry,
|
||||
* and the owner bypass behave exactly as on a real request — and an evaluation failure is a `503`,
|
||||
* never an empty answer that would look like a permissions problem.
|
||||
*
|
||||
* Reading requires `core.modules.ui.read`, the same permission the navigation it sits beside
|
||||
* requires: any signed-in person may ask, and most will be told there is nothing for them.
|
||||
*/
|
||||
export function createDashboardSettingsRoutes(options: CreateDashboardSettingsRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireRead = requirePermission(options, CORE_MODULES_UI_READ);
|
||||
const started = new Set(options.snapshot.startedModuleIds);
|
||||
const modulesWithSettings = options.snapshot.discoveredModules
|
||||
.filter((module) => module.manifest.configuration.length > 0)
|
||||
.map((module) => ({ id: module.manifest.id, name: module.manifest.name }))
|
||||
.sort((left, right) => compareText(left.name, right.name) || compareText(left.id, right.id));
|
||||
|
||||
const holds = async (
|
||||
principal: EkklesiaPrincipal,
|
||||
permission: PermissionId,
|
||||
): Promise<boolean> => {
|
||||
const declaration = options.registry.get(permission);
|
||||
if (!declaration) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
await options.evaluator.authorize({
|
||||
baseline: declaration.baseline,
|
||||
permission,
|
||||
principal,
|
||||
})
|
||||
).allowed;
|
||||
};
|
||||
|
||||
routes.get(DASHBOARD_SETTINGS_PATH, requireRead, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
c.header("Vary", "Origin, Cookie");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
try {
|
||||
let owner: boolean | undefined;
|
||||
const isOwner = async (): Promise<boolean> => {
|
||||
owner ??=
|
||||
principal.kind === "authenticated" &&
|
||||
(await options.platformOwners.isPlatformOwner(principal.userId));
|
||||
return owner;
|
||||
};
|
||||
const sections: SettingsSection[] = [];
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
const access = SECTION_ACCESS[section];
|
||||
if (access === "platform-owner" ? await isOwner() : await holds(principal, access)) {
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
const modules = (await holds(principal, CORE_MODULES_CONFIGURATION_MANAGE))
|
||||
? modulesWithSettings.map((module) => ({ ...module, available: started.has(module.id) }))
|
||||
: [];
|
||||
return c.json({ modules, sections } satisfies DashboardSettingsResponse, 200);
|
||||
} catch {
|
||||
return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503);
|
||||
}
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleConfiguration,
|
||||
RESERVED_MODULE_IDS,
|
||||
RESERVED_SETTINGS_NAMES,
|
||||
RESERVED_TOP_LEVEL_NAMES,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -40,7 +42,7 @@ import {
|
||||
DashboardHostConfigurationError,
|
||||
createDashboardHostRoutes,
|
||||
} from "../src/dashboard-host-routes.js";
|
||||
import { DASHBOARD_PLATFORM_PAGES } from "@ekklesia/shell/dashboard-routes";
|
||||
import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } from "@ekklesia/shell/dashboard-routes";
|
||||
import {
|
||||
MODULE_DISPLAY_DESCRIPTOR_PREFIX,
|
||||
MODULE_DISPLAY_PAGE_PREFIX,
|
||||
@@ -391,7 +393,7 @@ describe("the platform's reserved top-level names (ADR 0057)", () => {
|
||||
]) {
|
||||
expect(isReservedModuleId(path.split("/")[1] ?? ""), path).toBe(true);
|
||||
}
|
||||
expect([...RESERVED_MODULE_IDS].sort()).toEqual([
|
||||
expect([...RESERVED_TOP_LEVEL_NAMES].sort()).toEqual([
|
||||
"account",
|
||||
"api",
|
||||
"assets",
|
||||
@@ -402,9 +404,19 @@ describe("the platform's reserved top-level names (ADR 0057)", () => {
|
||||
|
||||
it("include every Dashboard platform page the shell interprets", () => {
|
||||
for (const page of DASHBOARD_PLATFORM_PAGES) {
|
||||
expect(RESERVED_MODULE_IDS, page).toContain(page);
|
||||
expect(RESERVED_TOP_LEVEL_NAMES, page).toContain(page);
|
||||
}
|
||||
});
|
||||
|
||||
it("include exactly the Settings sections the shell interprets, so /settings/<module-id> never collides (ADR 0061)", () => {
|
||||
expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]);
|
||||
for (const section of SETTINGS_SECTIONS) {
|
||||
expect(isReservedModuleId(section), section).toBe(true);
|
||||
}
|
||||
expect(RESERVED_MODULE_IDS).toEqual(
|
||||
[...RESERVED_TOP_LEVEL_NAMES, ...RESERVED_SETTINGS_NAMES].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
parseEkklesiaUserId,
|
||||
parseModuleManifest,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
type LegacyModuleRuntimeServices,
|
||||
type ModuleDatabase,
|
||||
type ModuleLifecycleConfiguration,
|
||||
} from "@ekklesia/core/modules";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import {
|
||||
DASHBOARD_SETTINGS_PATH,
|
||||
type DashboardSettingsResponse,
|
||||
} from "../src/dashboard-settings-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { emptyModuleStorage } from "./support/in-memory-module-storage.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
|
||||
/**
|
||||
* `GET /api/v1/dashboard/settings` (ADR 0061): which Settings destinations the viewer may see,
|
||||
* evaluated by the server through Core's one authorization evaluator. Presentation hints, never
|
||||
* authority: every Settings data route keeps its own enforcement.
|
||||
*/
|
||||
|
||||
const viewerId = parseEkklesiaUserId("44444444-4444-4444-8444-444444444444");
|
||||
const viewer = createAuthenticatedPrincipal(viewerId);
|
||||
|
||||
function discovered(
|
||||
id: string,
|
||||
options: { readonly name?: string; readonly settings: boolean },
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
capabilities: [],
|
||||
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||
configuration: options.settings
|
||||
? [
|
||||
{
|
||||
default: "Hello",
|
||||
description: "A greeting.",
|
||||
key: "greeting",
|
||||
maxLength: 40,
|
||||
type: "string",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 8,
|
||||
name: options.name ?? `${id} module`,
|
||||
permissions: [],
|
||||
version: "0.1.0",
|
||||
});
|
||||
return {
|
||||
contributions: [],
|
||||
directory: `/modules/${id}`,
|
||||
entrypoints: {
|
||||
server: `/modules/${id}/dist/server/index.js`,
|
||||
ui: `/modules/${id}/dist/ui/index.js`,
|
||||
},
|
||||
manifest,
|
||||
manifestPath: `/modules/${id}/ekklesia.module.json`,
|
||||
packageName: `@test/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
interface Options {
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
readonly owner?: boolean;
|
||||
readonly grants?: readonly string[];
|
||||
readonly modules?: readonly DiscoveredModule[];
|
||||
readonly configuration?: ModuleLifecycleConfiguration;
|
||||
readonly ownerLookupFails?: boolean;
|
||||
}
|
||||
|
||||
function build(options: Options = {}) {
|
||||
const modules = options.modules ?? [
|
||||
discovered("signage", { name: "Signage", settings: true }),
|
||||
discovered("calendar", { name: "Calendar", settings: true }),
|
||||
discovered("retired", { name: "Archive", settings: true }),
|
||||
discovered("plain", { name: "Plain", settings: false }),
|
||||
];
|
||||
const isPlatformOwner = vi.fn(async () => {
|
||||
if (options.ownerLookupFails) {
|
||||
throw new Error("database down");
|
||||
}
|
||||
return options.owner === true;
|
||||
});
|
||||
const grants = new Set(options.grants ?? []);
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async ({ permission }) => grants.has(String(permission)) },
|
||||
platformOwners: { isPlatformOwner },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? viewer),
|
||||
};
|
||||
const store = {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn(),
|
||||
isPlatformOwner,
|
||||
listGrantsForUser: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(
|
||||
modules,
|
||||
options.configuration ?? { modules: [{ enabled: false, moduleId: "retired" }] },
|
||||
),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: Object.freeze({
|
||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||
}) as LegacyModuleRuntimeServices,
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleUi: createModuleUiPublicationRegistry([]),
|
||||
runtime,
|
||||
trustedOrigins: ["https://dashboard.example.test"],
|
||||
});
|
||||
return { app, isPlatformOwner };
|
||||
}
|
||||
|
||||
async function settings(options: Options = {}) {
|
||||
const { app } = build(options);
|
||||
const response = await app.request(DASHBOARD_SETTINGS_PATH);
|
||||
const body =
|
||||
response.status === 200 ? ((await response.json()) as DashboardSettingsResponse) : undefined;
|
||||
return { body, response };
|
||||
}
|
||||
|
||||
describe("GET /api/v1/dashboard/settings", () => {
|
||||
it("shows a signed-in person with no administrative permission nothing at all", async () => {
|
||||
const { body, response } = await settings();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body).toEqual({ modules: [], sections: [] });
|
||||
});
|
||||
|
||||
it("refuses an anonymous viewer, like the navigation it sits beside", async () => {
|
||||
const { response } = await settings({ principal: ANONYMOUS_PRINCIPAL });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("shows the platform owner every section, in order, and every module with settings", async () => {
|
||||
const { body } = await settings({ owner: true });
|
||||
|
||||
expect(body).toEqual({
|
||||
modules: [
|
||||
{ available: false, id: "retired", name: "Archive" },
|
||||
{ available: true, id: "calendar", name: "Calendar" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: ["general", "branding", "displays", "discover", "system"],
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Displays exactly to whoever may manage devices", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_DEVICES_MANAGE)] });
|
||||
|
||||
expect(body).toEqual({ modules: [], sections: ["displays"] });
|
||||
});
|
||||
|
||||
it("shows Discover — module lifecycle — exactly to whoever may manage which modules run", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_MODULES_LIFECYCLE_MANAGE)] });
|
||||
|
||||
expect(body).toEqual({ modules: [], sections: ["discover"] });
|
||||
});
|
||||
|
||||
it("lists modules' settings exactly to whoever may configure modules, flagging disabled ones", async () => {
|
||||
const { body } = await settings({ grants: [String(CORE_MODULES_CONFIGURATION_MANAGE)] });
|
||||
|
||||
// A module that declares no configuration has no settings page; a disabled module is listed so
|
||||
// its address can say it is unavailable, but marked so the Dashboard does not navigate to it.
|
||||
expect(body).toEqual({
|
||||
modules: [
|
||||
{ available: false, id: "retired", name: "Archive" },
|
||||
{ available: true, id: "calendar", name: "Calendar" },
|
||||
{ available: true, id: "signage", name: "Signage" },
|
||||
],
|
||||
sections: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps General, Branding, and System to the platform owner until each has a permission of its own", async () => {
|
||||
const { body } = await settings({
|
||||
grants: [
|
||||
String(CORE_DEVICES_MANAGE),
|
||||
String(CORE_MODULES_LIFECYCLE_MANAGE),
|
||||
String(CORE_MODULES_CONFIGURATION_MANAGE),
|
||||
],
|
||||
});
|
||||
|
||||
expect(body?.sections).toEqual(["displays", "discover"]);
|
||||
});
|
||||
|
||||
it("is a 503, never an empty answer, when authorization cannot be evaluated", async () => {
|
||||
const { response } = await settings({ ownerLookupFails: true });
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
});
|
||||
|
||||
it("is a hint, not authority: the data behind a section stays refused without its permission", async () => {
|
||||
const { app } = build({ grants: [] });
|
||||
|
||||
for (const path of [
|
||||
"/api/v1/module-lifecycle",
|
||||
"/api/v1/module-configuration/signage",
|
||||
"/api/v1/grants",
|
||||
]) {
|
||||
const response = await app.request(path);
|
||||
expect(response.status, path).toBe(403);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user