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]>
341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
createAuthenticatedPrincipal,
|
|
createDevicePrincipal,
|
|
type EkklesiaPrincipal,
|
|
parseEkklesiaUserId,
|
|
} from "@ekklesia/contracts";
|
|
import { ACCOUNT_APPEARANCES, ACCOUNT_MOTION_PREFERENCES } from "@ekklesia/core/account";
|
|
import {
|
|
type AuthorizationStore,
|
|
CORE_ACCOUNT_SELF_MANAGE,
|
|
CORE_PERMISSION_REGISTRY,
|
|
createAuthorizationEvaluator,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import {
|
|
composePlatformPermissionRegistry,
|
|
createModuleUiPublicationRegistry,
|
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
|
type LegacyModuleRuntimeServices,
|
|
type ModuleDatabase,
|
|
} from "@ekklesia/core/modules";
|
|
import { EKKLESIA_APPEARANCES, EKKLESIA_MOTION_PREFERENCES } from "@ekklesia/design-tokens";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { ACCOUNT_PATH, ACCOUNT_PREFERENCES_PATH } from "../src/account-routes.js";
|
|
import { createApiApp } from "../src/app.js";
|
|
import {
|
|
createInMemoryAccountStore,
|
|
type InMemoryAccountPerson,
|
|
} from "./support/in-memory-account-store.js";
|
|
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
|
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.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/account` and `PATCH /api/v1/account/preferences` (ADR 0062): a signed-in person's
|
|
* own account and preferences, self-only by construction.
|
|
*/
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const adaId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
|
const graceId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222");
|
|
const ada = createAuthenticatedPrincipal(adaId);
|
|
|
|
const people: readonly InMemoryAccountPerson[] = [
|
|
{ email: "[email protected]", name: "Ada Lovelace", userId: adaId },
|
|
{
|
|
email: "[email protected]",
|
|
name: "Grace Hopper",
|
|
preferences: { appearance: "light", motion: "reduce" },
|
|
userId: graceId,
|
|
},
|
|
];
|
|
|
|
interface Options {
|
|
readonly principal?: EkklesiaPrincipal;
|
|
readonly evaluationFails?: boolean;
|
|
}
|
|
|
|
function build(options: Options = {}) {
|
|
const evaluator = createAuthorizationEvaluator({
|
|
grants: { hasActiveGrant: async () => false },
|
|
platformOwners: {
|
|
isPlatformOwner: async () => {
|
|
if (options.evaluationFails) {
|
|
throw new Error("database down");
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
});
|
|
const runtime: EkklesiaIdentityRuntime = {
|
|
handleAuthRequest: vi.fn(),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ada),
|
|
};
|
|
const account = createInMemoryAccountStore(people);
|
|
const app = createApiApp({
|
|
account,
|
|
authorization: {
|
|
evaluator,
|
|
registry: composePlatformPermissionRegistry([]),
|
|
store: {} as unknown as AuthorizationStore,
|
|
},
|
|
dashboard: createInMemoryDashboardStore(),
|
|
dashboardHostAssets: testDashboardHostAssets(),
|
|
health: readyHealth(),
|
|
moduleConfiguration: emptyModuleConfiguration(),
|
|
moduleLifecycle: {
|
|
snapshot: moduleLifecycleSnapshotFor([], { modules: [] }),
|
|
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: [trustedOrigin],
|
|
});
|
|
return { account, app };
|
|
}
|
|
|
|
function patch(body: unknown, init: { origin?: string | null; raw?: string } = {}): RequestInit {
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (init.origin !== null) {
|
|
headers.Origin = init.origin ?? trustedOrigin;
|
|
}
|
|
return { body: init.raw ?? JSON.stringify(body), headers, method: "PATCH" };
|
|
}
|
|
|
|
describe("core.account.self.manage", () => {
|
|
it("is an authenticated-baseline Core permission, so managing your own account needs no grant", () => {
|
|
expect(CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE)?.baseline).toBe("authenticated");
|
|
});
|
|
|
|
it("holds Core's preference values equal to the design tokens' own", () => {
|
|
expect([...ACCOUNT_APPEARANCES]).toEqual([...EKKLESIA_APPEARANCES]);
|
|
expect([...ACCOUNT_MOTION_PREFERENCES]).toEqual([...EKKLESIA_MOTION_PREFERENCES]);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/v1/account", () => {
|
|
it("returns the signed-in person's own name, email, and default preferences", async () => {
|
|
const { app } = build();
|
|
const response = await app.request(ACCOUNT_PATH);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
expect(response.headers.get("vary")).toContain("Cookie");
|
|
expect(await response.json()).toEqual({
|
|
email: "[email protected]",
|
|
name: "Ada Lovelace",
|
|
preferences: { appearance: "system", motion: "system" },
|
|
});
|
|
});
|
|
|
|
it("returns exactly name, email, and preferences — nothing else about the account", async () => {
|
|
const { app } = build();
|
|
const body = (await (await app.request(ACCOUNT_PATH)).json()) as Record<string, unknown>;
|
|
expect(Object.keys(body).sort()).toEqual(["email", "name", "preferences"]);
|
|
expect(Object.keys(body.preferences as object).sort()).toEqual(["appearance", "motion"]);
|
|
});
|
|
|
|
it("refuses an anonymous caller", async () => {
|
|
const { account, app } = build({ principal: ANONYMOUS_PRINCIPAL });
|
|
const response = await app.request(ACCOUNT_PATH);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
expect(account.requestedUserIds).toEqual([]);
|
|
});
|
|
|
|
it("refuses a paired display device, which has no account", async () => {
|
|
const { account, app } = build({
|
|
principal: createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
|
|
});
|
|
const response = await app.request(ACCOUNT_PATH);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(account.requestedUserIds).toEqual([]);
|
|
});
|
|
|
|
it("reads only the session's account, whatever the request names", async () => {
|
|
const { account, app } = build();
|
|
const response = await app.request(`${ACCOUNT_PATH}?userId=${graceId}&id=${graceId}`, {
|
|
headers: { "X-User-Id": graceId },
|
|
});
|
|
|
|
expect(((await response.json()) as { name: string }).name).toBe("Ada Lovelace");
|
|
expect(account.requestedUserIds).toEqual([adaId]);
|
|
});
|
|
|
|
it("has no route that addresses another person's account", async () => {
|
|
const { account, app } = build();
|
|
for (const path of [`${ACCOUNT_PATH}/${graceId}`, `${ACCOUNT_PREFERENCES_PATH}/${graceId}`]) {
|
|
expect((await app.request(path)).status).toBe(404);
|
|
expect((await app.request(path, patch({ appearance: "dark" }))).status).toBe(404);
|
|
}
|
|
expect(account.requestedUserIds).toEqual([]);
|
|
});
|
|
|
|
it("is not found when the session's person no longer exists", async () => {
|
|
const { app } = build({
|
|
principal: createAuthenticatedPrincipal("99999999-9999-4999-8999-999999999999"),
|
|
});
|
|
const response = await app.request(ACCOUNT_PATH);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "ACCOUNT_NOT_FOUND" });
|
|
});
|
|
|
|
it("reports a storage failure as a sanitized 503", async () => {
|
|
const { account, app } = build();
|
|
account.failWith("ACCOUNT_READ_FAILED");
|
|
const response = await app.request(ACCOUNT_PATH);
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "ACCOUNT_READ_FAILED" });
|
|
});
|
|
|
|
it("reports an authorization outage as a 503, never as a denial", async () => {
|
|
// The authenticated baseline needs no owner lookup, so an owner-lookup outage cannot deny it.
|
|
const { app } = build({ evaluationFails: true });
|
|
expect((await app.request(ACCOUNT_PATH)).status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("PATCH /api/v1/account/preferences", () => {
|
|
it.each([
|
|
["appearance", "light"],
|
|
["appearance", "dark"],
|
|
["appearance", "system"],
|
|
["motion", "reduce"],
|
|
["motion", "allow"],
|
|
["motion", "system"],
|
|
] as const)("saves %s %s and returns everything in effect", async (name, value) => {
|
|
const { app } = build();
|
|
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ [name]: value }));
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
const body = (await response.json()) as Record<string, string>;
|
|
expect(body[name]).toBe(value);
|
|
expect(Object.keys(body).sort()).toEqual(["appearance", "motion"]);
|
|
|
|
const read = (await (await app.request(ACCOUNT_PATH)).json()) as {
|
|
preferences: Record<string, string>;
|
|
};
|
|
expect(read.preferences[name]).toBe(value);
|
|
});
|
|
|
|
it("changes only the preference named", async () => {
|
|
const { app } = build();
|
|
await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
|
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ motion: "reduce" }));
|
|
|
|
expect(await response.json()).toEqual({ appearance: "dark", motion: "reduce" });
|
|
});
|
|
|
|
it.each([
|
|
["an empty object", {}],
|
|
["an unknown appearance", { appearance: "sepia" }],
|
|
["an unknown motion", { motion: "none" }],
|
|
["an unknown key", { appearance: "dark", radius: "large" }],
|
|
["another person's id", { appearance: "dark", userId: "22222222-2222-4222-8222-222222222222" }],
|
|
["an array", ["dark"]],
|
|
["null", null],
|
|
])("refuses %s with nothing changed", async (_label, body) => {
|
|
const { account, app } = build();
|
|
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(body));
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
|
expect(account.requestedUserIds).toEqual([]);
|
|
});
|
|
|
|
it("refuses a body that is not JSON", async () => {
|
|
const { app } = build();
|
|
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(undefined, { raw: "{" }));
|
|
expect(response.status).toBe(400);
|
|
});
|
|
|
|
it("refuses an untrusted or missing Origin before anything else", async () => {
|
|
const { account, app } = build();
|
|
for (const origin of ["https://elsewhere.example.test", null] as const) {
|
|
const response = await app.request(
|
|
ACCOUNT_PREFERENCES_PATH,
|
|
patch({ appearance: "dark" }, { origin }),
|
|
);
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
}
|
|
expect(account.requestedUserIds).toEqual([]);
|
|
});
|
|
|
|
it("refuses an oversized body", async () => {
|
|
const { app } = build();
|
|
const response = await app.request(
|
|
ACCOUNT_PREFERENCES_PATH,
|
|
patch(undefined, { raw: JSON.stringify({ appearance: "dark", pad: "x".repeat(20_000) }) }),
|
|
);
|
|
expect(response.status).toBe(413);
|
|
});
|
|
|
|
it("refuses an anonymous caller and a device", async () => {
|
|
for (const principal of [
|
|
ANONYMOUS_PRINCIPAL,
|
|
createDevicePrincipal("33333333-3333-4333-8333-333333333333"),
|
|
]) {
|
|
const { account, app } = build({ principal });
|
|
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
|
expect(response.status).toBe(403);
|
|
expect(account.requestedUserIds).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("changes only the session's own preferences, never another person's", async () => {
|
|
const { account, app } = build();
|
|
await app.request(
|
|
`${ACCOUNT_PREFERENCES_PATH}?userId=${graceId}`,
|
|
patch({ appearance: "dark", motion: "allow" }),
|
|
);
|
|
|
|
expect(account.requestedUserIds).toEqual([adaId]);
|
|
expect((await account.readAccount(graceId))?.preferences).toEqual({
|
|
appearance: "light",
|
|
motion: "reduce",
|
|
});
|
|
});
|
|
|
|
it("reports a storage failure as a sanitized 503", async () => {
|
|
const { account, app } = build();
|
|
account.failWith("ACCOUNT_WRITE_FAILED");
|
|
const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" }));
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ code: "ACCOUNT_WRITE_FAILED" });
|
|
});
|
|
|
|
it("answers a preflight from the Dashboard origin with PATCH allowed", async () => {
|
|
const { app } = build();
|
|
const response = await app.request(ACCOUNT_PREFERENCES_PATH, {
|
|
headers: {
|
|
"Access-Control-Request-Method": "PATCH",
|
|
Origin: trustedOrigin,
|
|
},
|
|
method: "OPTIONS",
|
|
});
|
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
|
expect(response.headers.get("access-control-allow-methods")).toContain("PATCH");
|
|
});
|
|
});
|