Files
Ekklesia/core/tests/account-store.integration.ts
T
ekklesiaandClaude Opus 5.5 300d9eab51
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 45s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m34s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 3m52s
Pair displays at the platform's own /display/setup, with or without modules
Displays are platform-owned, but a screen could only start pairing on a
module's display page: every pairing request named a module, so an
installation with no module that uses displays could not set one up.

- /display/setup is the platform's own display page, served whenever
  the deployment has device support, whatever is installed. It pairs a
  platform display with no module. Once paired, it opens the module it
  is set to show; with none set, it shows its name and waits, rechecking
  so it stays alive and notices revocation. "setup" is now a reserved
  module id.
- Migration core:0009_platform_display_pairing lets a pairing request
  name no module. It relaxes module_id and permission_id and adds a
  both-or-neither check. It is additive and destroys nothing.
- POST /api/v1/device-pairing/requests accepts {} for a platform
  display. Approval accepts an optional moduleId, so the administrator
  can choose an installed display module and the device gets that
  module's declared permission. A module display's own request can't be
  redirected.
- GET /api/v1/device-pairing/self lets a paired device describe itself:
  its name and the module displays it may show. It renews the device's
  cookie.
- GET /api/v1/devices/setup is replaced by
  GET /api/v1/devices/display-modules: the modules a display can be set
  to show.
- Settings -> Displays always offers the /display/setup address, Copy
  setup link and Add display. Add display has an optional "Show on this
  screen" choice, and each paired display says what it shows.
- /display/<module-id> is unchanged, so released Signage 0.1.5 works as
  before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 16:43:02 -05:00

380 lines
13 KiB
TypeScript

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",
"core:0009_platform_display_pairing",
]);
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();
});
});