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,178 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_APPEARANCES,
|
||||
ACCOUNT_MOTION_PREFERENCES,
|
||||
ACCOUNT_PREFERENCE_NAMES,
|
||||
AccountStoreError,
|
||||
createAccountStore,
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
ekklesiaCoreUserPreference,
|
||||
parseAccountPreferencesUpdate,
|
||||
resolveAccountPreferences,
|
||||
} from "../src/account/index.js";
|
||||
|
||||
const migrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
|
||||
describe("account preferences model", () => {
|
||||
it("defaults both preferences to following the system", () => {
|
||||
expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" });
|
||||
expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true);
|
||||
});
|
||||
|
||||
it("declares a closed, ordered set of names and values", () => {
|
||||
expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]);
|
||||
expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]);
|
||||
expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]);
|
||||
});
|
||||
|
||||
it.each(
|
||||
ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const),
|
||||
)("accepts appearance %s on its own", (appearance) => {
|
||||
expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance });
|
||||
});
|
||||
|
||||
it.each(
|
||||
ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const),
|
||||
)("accepts motion %s on its own", (motion) => {
|
||||
expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion });
|
||||
});
|
||||
|
||||
it("accepts both preferences together", () => {
|
||||
expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "reduce",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty object", {}],
|
||||
["null", null],
|
||||
["an array", [["appearance", "dark"]]],
|
||||
["a string", "dark"],
|
||||
["an unknown appearance", { appearance: "sepia" }],
|
||||
["an unknown motion", { motion: "none" }],
|
||||
["a differently cased value", { appearance: "Dark" }],
|
||||
["a non-string value", { appearance: 1 }],
|
||||
["a null value", { motion: null }],
|
||||
["an unknown key beside a valid one", { appearance: "dark", radius: "large" }],
|
||||
["an unknown key alone", { theme: "dark" }],
|
||||
["a user id", { appearance: "dark", userId: "someone-else" }],
|
||||
["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')],
|
||||
[
|
||||
"a class instance",
|
||||
new (class Preferences {
|
||||
appearance = "dark";
|
||||
})(),
|
||||
],
|
||||
])("refuses %s whole", (_label, value) => {
|
||||
expect(parseAccountPreferencesUpdate(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => {
|
||||
expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual(
|
||||
DEFAULT_ACCOUNT_PREFERENCES,
|
||||
);
|
||||
expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "system",
|
||||
});
|
||||
// A value the constraint should have made impossible is never passed through.
|
||||
expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({
|
||||
appearance: "system",
|
||||
motion: "allow",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account preference storage", () => {
|
||||
it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => {
|
||||
const config = getTableConfig(ekklesiaCoreUserPreference);
|
||||
expect(config.name).toBe("ekklesia_core_user_preference");
|
||||
expect(config.columns.map((column) => column.name).sort()).toEqual([
|
||||
"appearance",
|
||||
"created_at",
|
||||
"motion",
|
||||
"updated_at",
|
||||
"user_id",
|
||||
]);
|
||||
const userId = config.columns.find((column) => column.name === "user_id");
|
||||
expect(userId?.primary).toBe(true);
|
||||
// Preference columns are nullable: `null` is "never chosen", resolved to the default on read.
|
||||
for (const name of ACCOUNT_PREFERENCE_NAMES) {
|
||||
expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false);
|
||||
}
|
||||
expect(config.foreignKeys).toHaveLength(1);
|
||||
expect(config.foreignKeys[0]?.onDelete).toBe("cascade");
|
||||
});
|
||||
|
||||
it("constrains each column in the migration to exactly the model's values", async () => {
|
||||
const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8");
|
||||
const allowed = (column: string): string[] => {
|
||||
const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql);
|
||||
return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", ""));
|
||||
};
|
||||
expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]);
|
||||
expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]);
|
||||
// No JSON, no key/value pair, nothing a client could fill with anything.
|
||||
expect(sql).not.toMatch(/jsonb?|hstore/iu);
|
||||
});
|
||||
|
||||
it("is a migration that destroys nothing", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"),
|
||||
) as { entries: { tag: string; destructive: { mode: string } }[] };
|
||||
expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({
|
||||
destructive: { mode: "none" },
|
||||
tag: "0008_user_preferences",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("account store validation", () => {
|
||||
it("refuses an invalid update before touching the database", async () => {
|
||||
const untouchable = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("the database must not be reached");
|
||||
},
|
||||
},
|
||||
);
|
||||
const store = createAccountStore(untouchable as never);
|
||||
await expect(
|
||||
store.updatePreferences("user-1" as never, { appearance: "sepia" } as never),
|
||||
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" });
|
||||
await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf(
|
||||
AccountStoreError,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a database failure without the underlying error", async () => {
|
||||
const failing = {
|
||||
select() {
|
||||
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
|
||||
},
|
||||
transaction() {
|
||||
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
|
||||
},
|
||||
};
|
||||
const store = createAccountStore(failing as never);
|
||||
const read = await store.readAccount("user-1" as never).catch((error: unknown) => error);
|
||||
expect(read).toBeInstanceOf(AccountStoreError);
|
||||
expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" });
|
||||
expect(String((read as Error).stack)).not.toContain("secret");
|
||||
const write = await store
|
||||
.updatePreferences("user-1" as never, { appearance: "dark" })
|
||||
.catch((error: unknown) => error);
|
||||
expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" });
|
||||
expect(String((write as Error).message)).not.toContain("ECONNREFUSED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type AccountStore,
|
||||
AccountStoreError,
|
||||
createAccountDrizzleView,
|
||||
createAccountStore,
|
||||
} from "../src/account/index.js";
|
||||
import { createValidatedPostgresPool } from "../src/database/index.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
|
||||
/**
|
||||
* A person's own account summary and preferences against real PostgreSQL (ADR 0062).
|
||||
*
|
||||
* The unit suite proves the model and parser; this proves what only a database can: that the
|
||||
* migration creates the table with its constraints, that a choice survives a completely new
|
||||
* connection (which is what "follows you to another device" rests on), that one person's change
|
||||
* never touches another's row, that the database itself refuses a value outside the model, and
|
||||
* that deleting a person deletes their preferences.
|
||||
*/
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The account integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The account integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 14);
|
||||
databaseCounter += 1;
|
||||
return `ekk_acct_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure stays static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The account integration database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-acct-no-modules-"));
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
"core:0002_authorization_integrity",
|
||||
"core:0003_dashboard_navigation_tables",
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const ada = "user-ada" as EkklesiaUserId;
|
||||
const grace = "user-grace" as EkklesiaUserId;
|
||||
const nobody = "user-nobody" as EkklesiaUserId;
|
||||
|
||||
async function insertPerson(
|
||||
client: Client,
|
||||
id: string,
|
||||
name: string,
|
||||
email: string,
|
||||
): Promise<void> {
|
||||
await safeQuery(
|
||||
client,
|
||||
"insert into ekklesia_core_user (id, name, email, email_verified) values ($1, $2, $3, true)",
|
||||
[id, name, email],
|
||||
);
|
||||
}
|
||||
|
||||
describe.sequential("live Core account store", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: AccountStore;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
it("creates the preference table with its constraints", async () => {
|
||||
database = await createDisposableDatabase("fresh");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
|
||||
const constraints = await safeQuery<{ constraint_name: string; constraint_type: string }>(
|
||||
admin,
|
||||
`select constraint_name, constraint_type from information_schema.table_constraints
|
||||
where table_name = 'ekklesia_core_user_preference' and constraint_type <> 'CHECK'
|
||||
or constraint_name like 'ekklesia_core_user_preference_%_check'
|
||||
order by constraint_name`,
|
||||
);
|
||||
const deleteRules = await safeQuery<{ delete_rule: string }>(
|
||||
admin,
|
||||
`select rc.delete_rule from information_schema.referential_constraints rc
|
||||
join information_schema.table_constraints tc on tc.constraint_name = rc.constraint_name
|
||||
where tc.table_name = 'ekklesia_core_user_preference'`,
|
||||
);
|
||||
|
||||
expect(constraints.map((constraint) => constraint.constraint_name)).toEqual([
|
||||
"ekklesia_core_user_preference_appearance_check",
|
||||
"ekklesia_core_user_preference_motion_check",
|
||||
"ekklesia_core_user_preference_pkey",
|
||||
"ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk",
|
||||
]);
|
||||
expect(deleteRules.map((rule) => rule.delete_rule)).toEqual(["CASCADE"]);
|
||||
|
||||
await insertPerson(admin, ada, "Ada Lovelace", "[email protected]");
|
||||
await insertPerson(admin, grace, "Grace Hopper", "[email protected]");
|
||||
|
||||
const handle = createValidatedPostgresPool(database.url);
|
||||
close = handle.close;
|
||||
store = createAccountStore(createAccountDrizzleView(handle.pool));
|
||||
});
|
||||
|
||||
it("reads a person who never chose anything with their name, email, and the defaults", async () => {
|
||||
expect(await store.readAccount(ada)).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Ada Lovelace",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
});
|
||||
const rows = await safeQuery(admin, "select * from ekklesia_core_user_preference");
|
||||
// Reading never materializes a row nobody chose.
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("answers undefined for a person who does not exist", async () => {
|
||||
expect(await store.readAccount(nobody)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["appearance", "light"],
|
||||
["appearance", "dark"],
|
||||
["appearance", "system"],
|
||||
["motion", "reduce"],
|
||||
["motion", "allow"],
|
||||
["motion", "system"],
|
||||
] as const)("saves %s %s and reads it back", async (name, value) => {
|
||||
const saved = await store.updatePreferences(ada, { [name]: value });
|
||||
expect(saved[name]).toBe(value);
|
||||
expect((await store.readAccount(ada))?.preferences[name]).toBe(value);
|
||||
});
|
||||
|
||||
it("changes only the preference named, keeping the other", async () => {
|
||||
await store.updatePreferences(ada, { appearance: "dark", motion: "reduce" });
|
||||
expect(await store.updatePreferences(ada, { motion: "allow" })).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "allow",
|
||||
});
|
||||
expect(await store.updatePreferences(ada, { appearance: "light" })).toEqual({
|
||||
appearance: "light",
|
||||
motion: "allow",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a first partial choice with the other preference still never chosen", async () => {
|
||||
expect(await store.updatePreferences(grace, { motion: "reduce" })).toEqual({
|
||||
appearance: "system",
|
||||
motion: "reduce",
|
||||
});
|
||||
const rows = await safeQuery<{ appearance: string | null; motion: string | null }>(
|
||||
admin,
|
||||
"select appearance, motion from ekklesia_core_user_preference where user_id = $1",
|
||||
[grace],
|
||||
);
|
||||
expect(rows).toEqual([{ appearance: null, motion: "reduce" }]);
|
||||
});
|
||||
|
||||
it("keeps each person's preferences to themselves", async () => {
|
||||
await store.updatePreferences(ada, { appearance: "dark", motion: "allow" });
|
||||
await store.updatePreferences(grace, { appearance: "light", motion: "reduce" });
|
||||
expect((await store.readAccount(ada))?.preferences).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "allow",
|
||||
});
|
||||
expect(await store.readAccount(grace)).toEqual({
|
||||
email: "[email protected]",
|
||||
name: "Grace Hopper",
|
||||
preferences: { appearance: "light", motion: "reduce" },
|
||||
});
|
||||
});
|
||||
|
||||
it("survives a completely new connection, as another device signing in would see", async () => {
|
||||
const reopened = createValidatedPostgresPool(database.url);
|
||||
try {
|
||||
const other = createAccountStore(createAccountDrizzleView(reopened.pool));
|
||||
expect((await other.readAccount(ada))?.preferences).toEqual({
|
||||
appearance: "dark",
|
||||
motion: "allow",
|
||||
});
|
||||
} finally {
|
||||
await reopened.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to store preferences for a person who does not exist", async () => {
|
||||
const failure = await store
|
||||
.updatePreferences(nobody, { appearance: "dark" })
|
||||
.catch((error: unknown) => error);
|
||||
expect(failure).toBeInstanceOf(AccountStoreError);
|
||||
expect(failure).toMatchObject({ code: "ACCOUNT_NOT_FOUND" });
|
||||
const rows = await safeQuery(
|
||||
admin,
|
||||
"select 1 from ekklesia_core_user_preference where user_id = $1",
|
||||
[nobody],
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses an invalid update with nothing written", async () => {
|
||||
await expect(
|
||||
store.updatePreferences(ada, { appearance: "sepia" } as never),
|
||||
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID" });
|
||||
expect((await store.readAccount(ada))?.preferences.appearance).toBe("dark");
|
||||
});
|
||||
|
||||
it("has the database itself refuse a value outside the model", async () => {
|
||||
await expect(
|
||||
admin.query(
|
||||
"update ekklesia_core_user_preference set appearance = 'sepia' where user_id = $1",
|
||||
[ada],
|
||||
),
|
||||
).rejects.toMatchObject({ code: "23514" });
|
||||
await expect(
|
||||
admin.query("update ekklesia_core_user_preference set motion = 'none' where user_id = $1", [
|
||||
ada,
|
||||
]),
|
||||
).rejects.toMatchObject({ code: "23514" });
|
||||
});
|
||||
|
||||
it("deletes a person's preferences with the person", async () => {
|
||||
await safeQuery(admin, "delete from ekklesia_core_user where id = $1", [grace]);
|
||||
const rows = await safeQuery(
|
||||
admin,
|
||||
"select 1 from ekklesia_core_user_preference where user_id = $1",
|
||||
[grace],
|
||||
);
|
||||
expect(rows).toHaveLength(0);
|
||||
expect(await store.readAccount(grace)).toBeUndefined();
|
||||
await safeEnd(admin);
|
||||
await close();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
});
|
||||
|
||||
it("contains exactly these six permissions and no others", () => {
|
||||
it("registers core.account.self.manage as authenticated: managing your own account needs no grant", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE);
|
||||
expect(declaration?.baseline).toBe("authenticated");
|
||||
expect(declaration?.id).toBe("core.account.self.manage");
|
||||
});
|
||||
|
||||
it("contains exactly these seven permissions and no others", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -178,6 +186,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
id: CORE_DEVICES_MANAGE,
|
||||
},
|
||||
{
|
||||
baseline: "authenticated",
|
||||
description: "Read your own account and read and change your own preferences.",
|
||||
id: CORE_ACCOUNT_SELF_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
|
||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
"ekklesia_core_session",
|
||||
"ekklesia_core_user",
|
||||
// A person's own preferences, one typed column each (ADR 0062).
|
||||
"ekklesia_core_user_preference",
|
||||
"ekklesia_core_verification",
|
||||
]);
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -92,7 +92,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
|
||||
@@ -170,12 +170,14 @@ describe("the browser shell reaches none of it", () => {
|
||||
|
||||
it("never puts a reset token in browser storage", async () => {
|
||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
|
||||
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
|
||||
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
|
||||
// is its own fixed key and a validated mode.
|
||||
// the life of one form and nowhere else. The shell's uses of browser storage are pinned
|
||||
// exactly: the desktop sidebar's expanded/collapsed mode (ADR 0058) and the pre-paint copy of
|
||||
// the person's appearance (ADR 0062). `localStorage` is named only by the entry point, once, to
|
||||
// hand to those two, and by the pre-paint script, once, to read its one key. Each write is its
|
||||
// own fixed key and a validated value.
|
||||
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
|
||||
const writes: string[] = [];
|
||||
const removals: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = withoutComments(file.text);
|
||||
expect(code, file.path).not.toContain("sessionStorage");
|
||||
@@ -185,18 +187,32 @@ describe("the browser shell reaches none of it", () => {
|
||||
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||
),
|
||||
);
|
||||
removals.push(
|
||||
...[...code.matchAll(/\.removeItem\(([^)]*)\)/gu)].map(
|
||||
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||
),
|
||||
);
|
||||
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toMatch(
|
||||
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
/const browserStorage = \(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
);
|
||||
expect(code).toContain("createLocalSidebarPreference(browserStorage)");
|
||||
expect(code).toContain("createLocalAppearanceCache(browserStorage)");
|
||||
} else if (file.path.endsWith(path.join("browser", "appearance-boot.ts"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toContain("window.localStorage.getItem(APPEARANCE_CACHE_KEY)");
|
||||
} else {
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
}
|
||||
}
|
||||
expect(writes).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY, serialized`,
|
||||
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
|
||||
]);
|
||||
expect(removals).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY`,
|
||||
]);
|
||||
// The files that ever hold the token touch no storage API at all.
|
||||
for (const file of files.filter((candidate) =>
|
||||
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
|
||||
|
||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||
// Core's own five declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(8);
|
||||
expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
|
||||
// Core's own seven declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
@@ -97,6 +99,7 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
|
||||
Reference in New Issue
Block a user