Files
Ekklesia/apps/api/tests/dashboard-settings-routes.test.ts
ekklesiaandClaude Opus 5.5 cd0287537e
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Add the account area and account-synced Appearance preferences
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance
chooses System, Light, or Dark and reduced motion (System, Reduce, Allow).

- Core: ekklesia_core_user_preference, one typed, CHECK-constrained,
  nullable column per preference (null = never chosen), migration
  0008_user_preferences (additive), @ekklesia/core/account store, and the
  authenticated-baseline permission core.account.self.manage.
- API: GET /api/v1/account (name, email, preferences) and partial
  PATCH /api/v1/account/preferences, self-only by construction: no user id
  is accepted anywhere; trusted Origin and body limit on the mutation.
- Shell: the account control shows name, email, and initials (never the
  unvetted image URL) and offers Account, Appearance, Sign out; native
  radio groups apply a choice at once through the ADR 0060 controller and
  save it; the server's answer wins and a failed save reverts.
- First paint: a classic, same-origin, render-blocking appearance.js in
  <head> applies a two-word localStorage cache before paint (no inline
  script, CSP unchanged). The account replaces the cache on every load,
  sign-out clears it, and other tabs follow via the storage event.
- Deploy smoke: real-Chromium checks for light/dark, OS emulation,
  overrides, reload persistence, pre-paint, Shadow DOM modules, reduced
  motion, the display staying light, and phone width; the browser
  container gets a 512 MiB /dev/shm so a long session no longer fails
  requests with ERR_INSUFFICIENT_RESOURCES.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 23:56:50 -05:00

251 lines
8.8 KiB
TypeScript

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 { createInMemoryAccountStore } from "./support/in-memory-account-store.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 },
account: createInMemoryAccountStore(),
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);
}
});
});