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]>
179 lines
6.8 KiB
TypeScript
179 lines
6.8 KiB
TypeScript
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");
|
|
});
|
|
});
|