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
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]>
251 lines
8.8 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|