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: "ada@example.test", name: "Ada Lovelace", userId: adaId }, { email: "grace@example.test", 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 = { "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: "ada@example.test", 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; 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; 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; }; 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"); }); });