Files
Ekklesia/apps/api/tests/device-pairing.integration.ts
ekklesiaandClaude Opus 5.5 98e59d4ba0
CI / verify (pull_request) Successful in 1m5s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / package-release (pull_request) Successful in 35s
CI / image-build (pull_request) Successful in 31s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
CI / package-publication (pull_request) Successful in 44s
chore(release): prepare 0.2.0
Every platform workspace package and every public @ekklesia/* package
declares 0.2.0, and the deploy README's image build example names it.
templates/module keeps its own version.

0.2.0, not 0.1.3: before 1.0 a minor version is the breaking one
(docs/development/releases.md, ADR 0054), and since 0.1.2 main has
broken modules, module authors, and operators:
- #64 (ADR 0048): one generic image; installed modules live outside it;
- #66, #72 (ADR 0049, 0050): modules install as Module Package v1, and
  move to the Module Database ABI;
- #71: the manifest v8 cutover; v7 manifests are refused;
- #79 (ADR 0053): the curated SDK drops defineModuleSchema and the
  legacy ModuleDatabase type;
plus #67 (AGPL), #74, #77 (licensing, native targets), and the public
package train (#80, #81). It is also the first package train to publish:
0.1.2 never will be.

Because 0.2.0 is outside ^0.1.0, the same change moves to ^0.2.0:
- the standalone fixture's @ekklesia ranges and its exact module-pack pin;
- the compatibility ranges of the modules built for this release: the
  template, the standalone fixture, and the native proof module;
- the test fixtures that model a module of the current release, or the
  current platform's versions;
- the module-development guide's examples, and the release guide, which
  now names what a minor bump moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 17:26:13 -05:00

811 lines
30 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createAuthorizationDrizzleDatabase,
createInitialPlatformOwner,
} from "@ekklesia/core/authorization";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Device pairing end to end, against real PostgreSQL and the real HTTP transport (ADR 0036).
*
* The whole point is that none of this is mocked. A screen with no identity asks to be paired, a
* real administrator signs in through the real login transport and approves it, the host issues a
* credential in a real `Set-Cookie`, and the resulting device opens a real display descriptor and a
* real realtime stream — and is then revoked and loses all of it. Anything short of that would
* prove that rows were written, not that a television in a foyer works.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
const trackedDatabases = new Set<string>();
const trackedDirectories: string[] = [];
let databaseCounter = 0;
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
const ownerPassword = "correct horse battery staple";
const displayPermission = "announcements.slideshow.view";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function baseDatabaseUrl(): string {
const url = process.env[integrationDatabaseUrlEnvironment];
if (typeof url !== "string" || url.trim() === "") {
throw new IntegrationHarnessError("The integration database URL is not configured.");
}
return url;
}
function databaseUrl(name: string): string {
const url = new URL(baseDatabaseUrl());
url.pathname = `/${name}`;
return url.toString();
}
async function openAdministrativeClient(): Promise<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
return (await client.query<T>(text, [...values])).rows;
} catch {
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function query<T extends QueryResultRow>(
url: string,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
const client = new Client({ connectionString: url });
await client.connect();
try {
return await safeQuery<T>(client, text, values);
} finally {
await client.end().catch(() => undefined);
}
}
/** A display-capable module: one display entrypoint, one restricted permission protecting it, and
* one realtime channel gated by that same permission. */
async function createDisplayModuleDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-"));
trackedDirectories.push(directory);
const moduleDirectory = path.join(directory, "announcements");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: "@test/module-announcements",
type: "module",
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: {
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
server: "./dist/server/index.js",
},
id: "announcements",
manifestVersion: 8,
name: "Announcements fixture",
permissions: [
{
baseline: "restricted",
description: "View the announcement slideshow.",
id: displayPermission,
},
],
version: "1.0.0",
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default {
id: "announcements",
version: "1.0.0",
register({ realtime }) {
realtime.channel({ channel: "state", permission: ${JSON.stringify(displayPermission)} });
},
start({ realtime }) {
realtime.publish("state", { slides: [] });
},
stop() {},
};\n`,
),
writeFile(path.join(moduleDirectory, "dist/ui/display.js"), "export function mount() {}\n"),
]);
return directory;
}
async function createMigratedDatabase(label: string): Promise<string> {
databaseCounter += 1;
const name =
`ekk_device_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
const url = databaseUrl(name);
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-device-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModules,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModules, { force: true, recursive: true });
}
return url;
}
async function startPlatform(url: string, modulesDirectory: string): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
// The production posture: no public signup anywhere in this suite.
publicSignupEnabled: false,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "development" },
);
return startApi(config, {
modulesDirectoryOverride: modulesDirectory,
rateLimitOverride: { max: 1000, window: 1 },
});
}
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw new IntegrationHarnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
/** Matches a cookie by suffix, because Better Auth prefixes its session cookie with `__Secure-`
* whenever the configured base URL is https — which this harness's is. */
function cookiePair(response: Response, name: string): string | undefined {
for (const header of response.headers.getSetCookie()) {
const pair = header.split(";")[0]?.trim() ?? "";
const cookieName = pair.slice(0, Math.max(0, pair.indexOf("=")));
if (cookieName === name || cookieName.endsWith(name)) {
return pair;
}
}
return undefined;
}
function setCookieFor(response: Response, name: string): string | undefined {
return response.headers.getSetCookie().find((header) => header.startsWith(`${name}=`));
}
async function createOwner(url: string, origin: string): Promise<string> {
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const authorization = createAuthorizationDrizzleDatabase(url);
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createInitialPlatformOwner(
{
authorizationDb: authorization.db,
createIdentity: createBootstrapIdentityCreator(identity.db),
identityDb: identity.db,
},
{ email, name: "Platform Owner", password: ownerPassword },
);
} finally {
await Promise.all([authorization.close(), identity.close()]);
}
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password: ownerPassword }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const cookie = cookiePair(signIn, "better-auth.session_token");
if (signIn.status !== 200 || cookie === undefined) {
throw new IntegrationHarnessError("The platform owner could not sign in.");
}
return cookie;
}
interface Platform {
readonly api: RunningApi;
readonly origin: string;
readonly url: string;
readonly ownerCookie: string;
readonly close: () => Promise<void>;
}
async function platform(label: string): Promise<Platform> {
const url = await createMigratedDatabase(label);
const modulesDirectory = await createDisplayModuleDirectory();
const api = await startPlatform(url, modulesDirectory);
const origin = apiOrigin(api);
const ownerCookie = await createOwner(url, origin);
return {
api,
close: async () => {
await api.shutdown();
},
origin,
ownerCookie,
url,
};
}
/** The device's own browser: no human session, only whatever cookies the host has set on it. */
class DeviceBrowser {
#cookies = new Map<string, string>();
constructor(private readonly origin: string) {}
absorb(response: Response): void {
for (const header of response.headers.getSetCookie()) {
const pair = header.split(";")[0] ?? "";
const separator = pair.indexOf("=");
if (separator === -1) {
continue;
}
const name = pair.slice(0, separator).trim();
const value = pair.slice(separator + 1).trim();
if (value.length === 0 || /max-age=0/iu.test(header)) {
this.#cookies.delete(name);
} else {
this.#cookies.set(name, value);
}
}
}
header(): string {
return [...this.#cookies].map(([name, value]) => `${name}=${value}`).join("; ");
}
has(name: string): boolean {
return this.#cookies.has(name);
}
overwrite(name: string, value: string): void {
this.#cookies.set(name, value);
}
async request(path: string, init: RequestInit = {}): Promise<Response> {
const cookie = this.header();
const response = await fetch(`${this.origin}${path}`, {
...init,
headers: {
origin: dashboardOrigin,
...(cookie.length > 0 ? { cookie } : {}),
...(init.headers as Record<string, string> | undefined),
},
});
this.absorb(response);
return response;
}
post(path: string, body: unknown): Promise<Response> {
return this.request(path, {
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
method: "POST",
});
}
}
function adminRequest(
origin: string,
cookie: string,
path: string,
body?: unknown,
overrides: RequestInit = {},
): Promise<Response> {
return fetch(`${origin}${path}`, {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
headers: {
cookie,
origin: dashboardOrigin,
...(body === undefined ? {} : { "content-type": "application/json" }),
...(overrides.headers as Record<string, string> | undefined),
},
method: body === undefined ? "GET" : "POST",
...overrides,
});
}
/** Drives a screen from "nobody has paired me" to "I hold a device session". */
async function pairDevice(
platform: Platform,
device: DeviceBrowser,
deviceName = "Foyer TV",
): Promise<{ readonly code: string }> {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
if (requested.status !== 201) {
throw new IntegrationHarnessError("The pairing request was refused.");
}
const { code } = (await requested.json()) as { code: string };
const approved = await adminRequest(
platform.origin,
platform.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName },
);
if (approved.status !== 200) {
throw new IntegrationHarnessError("The pairing approval was refused.");
}
const claimed = await device.post("/api/v1/device-pairing/claims", {});
if (((await claimed.json()) as { status: string }).status !== "paired") {
throw new IntegrationHarnessError("The device was not issued a session.");
}
return { code };
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (admin) {
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
}
for (const directory of trackedDirectories.splice(0)) {
await rm(directory, { force: true, recursive: true }).catch(() => undefined);
}
});
describe.sequential("display device pairing, end to end", () => {
it("takes an unpaired screen all the way to a working display, and back off again", async () => {
const running = await platform("e2e");
const device = new DeviceBrowser(running.origin);
try {
// 1. The screen loads. It has no identity, so the descriptor refuses it — but the page it
// needs in order to show a code is served.
expect((await device.request("/display/announcements")).status).toBe(200);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
// 2. It asks to be paired and receives a code plus an HttpOnly claim cookie.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
expect(requested.status).toBe(201);
const body = (await requested.json()) as Record<string, unknown>;
expect(body.code).toMatch(/^[0-9]{6}$/u);
// The claim secret is never in the body — only in a cookie script cannot read.
expect(JSON.stringify(body)).not.toContain("claim");
const claimCookie = setCookieFor(requested, "ekklesia_device_pairing");
expect(claimCookie).toMatch(/HttpOnly/iu);
// 3. Nothing has happened yet: polling says pending, and the display is still refused.
expect(
(
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
status: string;
}
).status,
).toBe("pending");
// 4. The administrator looks the code up, sees what it wants, and approves it by name.
const lookup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{ code: body.code },
);
expect(lookup.status).toBe(200);
expect(await lookup.json()).toMatchObject({
moduleId: "announcements",
permissionId: displayPermission,
});
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code: body.code, deviceName: "Foyer TV" },
)
).status,
).toBe(200);
// 5. The screen collects its credential — through Set-Cookie, never through JSON.
const claimed = await device.post("/api/v1/device-pairing/claims", {});
expect(await claimed.json()).toEqual({ status: "paired" });
const sessionCookie = setCookieFor(claimed, "ekklesia_device_session");
expect(sessionCookie).toMatch(/HttpOnly/iu);
expect(sessionCookie).toMatch(/SameSite=Strict/iu);
expect(sessionCookie).toMatch(/Path=\//u);
expect(sessionCookie).not.toMatch(/Domain=/iu);
// The spent pairing cookie is cleared rather than left on the device.
expect(device.has("ekklesia_device_pairing")).toBe(false);
expect(device.has("ekklesia_device_session")).toBe(true);
// 6. The display now works, and reports the device as its viewer.
const descriptor = await device.request("/api/v1/module-displays/announcements");
expect(descriptor.status).toBe(200);
const descriptorBody = (await descriptor.json()) as {
viewer: { kind: string; deviceId?: string };
module: { permissions: string[] };
};
expect(descriptorBody.viewer.kind).toBe("device");
expect(descriptorBody.viewer.deviceId).toMatch(/^[0-9a-f-]{36}$/u);
// Exactly the one permission it was paired for.
expect(descriptorBody.module.permissions).toEqual([displayPermission]);
// 7. Realtime, through the ordinary permission-gated channel.
const stream = await device.request("/api/v1/module-realtime/announcements/state", {
headers: { accept: "text/event-stream" },
});
expect(stream.status).toBe(200);
expect(stream.headers.get("content-type")).toContain("text/event-stream");
await stream.body?.cancel();
// 8. The device is still nobody as far as the Dashboard is concerned.
expect(await (await device.request("/api/v1/principal")).json()).toEqual({
kind: "anonymous",
});
expect((await device.request("/api/v1/dashboard/navigation")).status).toBe(403);
expect((await device.request("/api/v1/devices")).status).toBe(403);
// 9. The administrator revokes it.
const devices = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
expect(devices.devices).toHaveLength(1);
expect(devices.devices[0]?.name).toBe("Foyer TV");
expect(devices.devices[0]?.permissions).toEqual([displayPermission]);
const revoked = await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${devices.devices[0]?.id}/revoke`,
{},
);
expect(revoked.status).toBe(204);
// 10. Everything it could do, it can no longer do — while still holding its cookie.
expect(device.has("ekklesia_device_session")).toBe(true);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(403);
expect(
(
await device.request("/api/v1/module-realtime/announcements/state", {
headers: { accept: "text/event-stream" },
})
).status,
).toBe(403);
// And the screen can start over: the page it needs for a new code is still served.
expect((await device.request("/display/announcements")).status).toBe(200);
} finally {
await running.close();
}
});
it("stores no usable credential, and puts none in any response body", async () => {
const running = await platform("secrets");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
expect(credential.length).toBeGreaterThan(20);
const stored = await query<{ credential_hash: string; name: string }>(
running.url,
`select credential_hash, name from ekklesia_core_device`,
);
expect(stored).toHaveLength(1);
// The raw credential is nowhere in the database — only an irreversible digest of it.
expect(stored[0]?.credential_hash).not.toContain(credential);
expect(stored[0]?.credential_hash).toMatch(/^[0-9a-f]{64}$/u);
const everything = JSON.stringify(
await query(running.url, `select * from ekklesia_core_device_pairing_request`),
);
expect(everything).not.toContain(credential);
// The descriptor a module's UI can read carries no credential either.
const descriptor = await device.request("/api/v1/module-displays/announcements");
expect(await descriptor.text()).not.toContain(credential);
} finally {
await running.close();
}
});
describe("pairing cannot be talked into anything", () => {
it("derives the permission from the display, never from the request", async () => {
const running = await platform("derive");
const device = new DeviceBrowser(running.origin);
try {
// The browser tries to name its own permission and its own module.
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
permission: "core.devices.manage",
permissionId: "core.devices.manage",
});
expect(requested.status).toBe(201);
const { code } = (await requested.json()) as { code: string };
const lookup = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/lookups",
{ code },
);
// The host recorded the display's own declared permission and ignored the rest.
expect(await lookup.json()).toMatchObject({ permissionId: displayPermission });
const stored = await query<{ permission_id: string }>(
running.url,
`select permission_id from ekklesia_core_device_pairing_request`,
);
expect(stored.map((row) => row.permission_id)).toEqual([displayPermission]);
} finally {
await running.close();
}
});
it("refuses to start pairing for a module that has no display", async () => {
const running = await platform("nodisplay");
const device = new DeviceBrowser(running.origin);
try {
for (const moduleId of ["calendar", "core", "../announcements", ""]) {
const response = await device.post("/api/v1/device-pairing/requests", { moduleId });
expect(response.status, moduleId).toBeGreaterThanOrEqual(400);
}
expect(
await query(running.url, `select id from ekklesia_core_device_pairing_request`),
).toHaveLength(0);
} finally {
await running.close();
}
});
it("issues nothing to someone who knows only the code", async () => {
const running = await platform("codeonly");
const device = new DeviceBrowser(running.origin);
const onlooker = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{
code,
deviceName: "Foyer TV",
},
);
// Someone who read the code off the television — and even guessed the request exists —
// holds no claim cookie, so there is nothing for them to collect.
const stolen = await onlooker.post("/api/v1/device-pairing/claims", {});
expect(stolen.status).toBe(400);
expect(onlooker.has("ekklesia_device_session")).toBe(false);
// The real screen still gets its credential.
const claimed = await device.post("/api/v1/device-pairing/claims", {});
expect(await claimed.json()).toEqual({ status: "paired" });
} finally {
await running.close();
}
});
});
describe("approval is an administrator's act", () => {
it("refuses an anonymous or unauthorized caller, and enforces a trusted origin", async () => {
const running = await platform("approve");
const device = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
// Anonymous.
const anonymous = await fetch(`${running.origin}/api/v1/device-pairing/approvals`, {
body: JSON.stringify({ code, deviceName: "Intruder TV" }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
expect(anonymous.status).toBe(403);
// The paired-device cookie is not an administrator either — a device can never approve
// another device, or itself.
const selfApproval = await device.post("/api/v1/device-pairing/approvals", {
code,
deviceName: "Self TV",
});
expect(selfApproval.status).toBe(403);
// A real administrator from an untrusted origin: CSRF policy applies before anything else.
const crossOrigin = await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Foyer TV" },
{ headers: { origin: untrustedOrigin } },
);
expect(crossOrigin.status).toBe(403);
// Nothing was approved by any of that.
expect(
(
(await (await device.post("/api/v1/device-pairing/claims", {})).json()) as {
status: string;
}
).status,
).toBe("pending");
} finally {
await running.close();
}
});
it("cannot approve the same code twice, and cannot mint two credentials from one", async () => {
const running = await platform("single");
const device = new DeviceBrowser(running.origin);
try {
const requested = await device.post("/api/v1/device-pairing/requests", {
moduleId: "announcements",
});
const { code } = (await requested.json()) as { code: string };
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Foyer TV" },
)
).status,
).toBe(200);
// A second approval of the same code finds nothing pending.
expect(
(
await adminRequest(
running.origin,
running.ownerCookie,
"/api/v1/device-pairing/approvals",
{ code, deviceName: "Second TV" },
)
).status,
).toBe(404);
// Two claims race for one approved request; exactly one credential may exist.
const [first, second] = await Promise.all([
device.post("/api/v1/device-pairing/claims", {}),
device.post("/api/v1/device-pairing/claims", {}),
]);
const statuses = [
((await first.json()) as { status: string }).status,
((await second.json()) as { status: string }).status,
];
expect(statuses.filter((status) => status === "paired")).toHaveLength(1);
expect(await query(running.url, `select id from ekklesia_core_device`)).toHaveLength(1);
} finally {
await running.close();
}
});
});
it("re-pairs a revoked screen into a fresh identity, leaving the old credential dead", async () => {
const running = await platform("repair");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device, "Foyer TV");
const firstCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
const listed = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ id: string }> };
await adminRequest(
running.origin,
running.ownerCookie,
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
{},
);
await pairDevice(running, device, "Foyer TV (replaced)");
const secondCredential = device.header().replace(/^.*ekklesia_device_session=/u, "");
expect(secondCredential).not.toBe(firstCredential);
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
// Re-pairing creates a new identity rather than reviving the revoked one, so the old
// credential can never come back — presenting it again is simply anonymous.
const revokedBrowser = new DeviceBrowser(running.origin);
revokedBrowser.overwrite("ekklesia_device_session", firstCredential);
expect((await revokedBrowser.request("/api/v1/module-displays/announcements")).status).toBe(
403,
);
const devices = (await (
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
).json()) as { devices: Array<{ revoked: boolean }> };
expect(devices.devices).toHaveLength(2);
expect(devices.devices.filter((entry) => entry.revoked)).toHaveLength(1);
} finally {
await running.close();
}
});
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
const running = await platform("mixed");
const device = new DeviceBrowser(running.origin);
try {
await pairDevice(running, device);
const deviceCredential = device.header();
// A browser presenting both: the person still wins, and stays a person.
const both = await fetch(`${running.origin}/api/v1/principal`, {
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
});
expect(((await both.json()) as { kind: string }).kind).toBe("authenticated");
// And that administrator can still open the display for testing, as a human.
const descriptor = await fetch(`${running.origin}/api/v1/module-displays/announcements`, {
headers: { cookie: `${running.ownerCookie}; ${deviceCredential}` },
});
expect(descriptor.status).toBe(200);
expect(((await descriptor.json()) as { viewer: { kind: string } }).viewer.kind).toBe(
"authenticated",
);
} finally {
await running.close();
}
});
});