Add the account area and account-synced Appearance preferences
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8571e6854c
commit
cd0287537e
@@ -0,0 +1,340 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user