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
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]>
1151 lines
43 KiB
TypeScript
1151 lines
43 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 createEmptyModulesDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-device-pairing-empty-"));
|
|
trackedDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
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,
|
|
options: { readonly modules?: boolean } = {},
|
|
): Promise<Platform> {
|
|
const url = await createMigratedDatabase(label);
|
|
// `modules: false` is an installation with no module at all — nothing that shows content on a
|
|
// screen — which can still set up and pair a display (ADR 0065).
|
|
const modulesDirectory =
|
|
options.modules === false
|
|
? await createEmptyModulesDirectory()
|
|
: 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);
|
|
expect((await device.request("/api/v1/devices/display-modules")).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();
|
|
}
|
|
});
|
|
|
|
/**
|
|
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
|
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
|
*/
|
|
describe("credential lifetime", () => {
|
|
const ttlSeconds = 180 * 24 * 60 * 60;
|
|
const descriptor = "/api/v1/module-displays/announcements";
|
|
|
|
function renewal(response: Response): string | undefined {
|
|
return setCookieFor(response, "ekklesia_device_session");
|
|
}
|
|
|
|
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
|
const running = await platform("renewal");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
await pairDevice(running, device);
|
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
|
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
|
await query(
|
|
running.url,
|
|
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
|
);
|
|
|
|
const response = await device.request(descriptor);
|
|
expect(response.status).toBe(200);
|
|
// The same credential, re-issued with a full lifetime and the same protections.
|
|
const header = renewal(response);
|
|
expect(header).toBeDefined();
|
|
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
|
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
|
expect(header).toMatch(/HttpOnly/u);
|
|
expect(header).toMatch(/SameSite=Strict/u);
|
|
expect(header).toMatch(/Path=\//u);
|
|
expect(header).not.toMatch(/Domain=/iu);
|
|
|
|
// Using it moved the server's window forward too.
|
|
const [row] = await query<{ recent: boolean }>(
|
|
running.url,
|
|
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
|
);
|
|
expect(row?.recent).toBe(true);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
|
const running = await platform("legacy");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
await pairDevice(running, device);
|
|
// A production screen paired 179 days ago that checked in this morning: under the old
|
|
// rule it had one day left. It must carry straight on.
|
|
await query(
|
|
running.url,
|
|
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
|
);
|
|
expect((await device.request(descriptor)).status).toBe(200);
|
|
await query(
|
|
running.url,
|
|
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
|
);
|
|
expect((await device.request(descriptor)).status).toBe(200);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
|
const running = await platform("abandoned");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
await pairDevice(running, device);
|
|
await query(
|
|
running.url,
|
|
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
|
);
|
|
const response = await device.request(descriptor);
|
|
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
|
expect(response.status).toBe(403);
|
|
expect(renewal(response)).toBeUndefined();
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("never renews a revoked screen", async () => {
|
|
const running = await platform("revoked-renewal");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
await pairDevice(running, device);
|
|
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`,
|
|
{},
|
|
);
|
|
const response = await device.request(descriptor);
|
|
expect(response.status).toBe(403);
|
|
expect(renewal(response)).toBeUndefined();
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("does not hand a device cookie to a person viewing the display", async () => {
|
|
const running = await platform("human-renewal");
|
|
try {
|
|
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
|
expect(response.status).toBe(200);
|
|
expect(renewal(response)).toBeUndefined();
|
|
} 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();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe.sequential("platform display pairing (ADR 0065)", () => {
|
|
it("pairs a screen from /display/setup with zero display modules installed", async () => {
|
|
const running = await platform("platform-zero", { modules: false });
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
// The platform's own setup page is served with nothing installed, and nothing else is.
|
|
const page = await device.request("/display/setup");
|
|
expect(page.status).toBe(200);
|
|
expect(await page.text()).toContain("ekklesia-display");
|
|
expect((await device.request("/display/signage")).status).toBe(404);
|
|
|
|
// It asks to be paired as a platform display: no module at all.
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {});
|
|
expect(requested.status).toBe(201);
|
|
const { code } = (await requested.json()) as { code: string };
|
|
expect(code).toMatch(/^[0-9]{6}$/u);
|
|
|
|
// The administrator sees a screen with no module yet, and there is no module to choose.
|
|
const lookup = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/lookups",
|
|
{ code },
|
|
);
|
|
expect(lookup.status).toBe(200);
|
|
expect(await lookup.json()).toMatchObject({ moduleId: null, permissionId: null });
|
|
const modules = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/devices/display-modules",
|
|
);
|
|
expect(modules.headers.get("cache-control")).toBe("no-store");
|
|
expect(await modules.json()).toEqual({ modules: [] });
|
|
const approved = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code, deviceName: "Foyer TV" },
|
|
);
|
|
expect(approved.status).toBe(200);
|
|
expect(await approved.json()).toEqual({ moduleId: null, permissionId: null });
|
|
|
|
// The screen collects its credential exactly as a module display does.
|
|
const claimed = await device.post("/api/v1/device-pairing/claims", {});
|
|
expect(await claimed.json()).toEqual({ status: "paired" });
|
|
expect(device.has("ekklesia_device_session")).toBe(true);
|
|
|
|
// It is a platform display: paired, named, holding no module permission.
|
|
const self = await device.request("/api/v1/device-pairing/self");
|
|
expect(self.status).toBe(200);
|
|
expect(self.headers.get("cache-control")).toBe("no-store");
|
|
expect(setCookieFor(self, "ekklesia_device_session")).toMatch(/HttpOnly/iu);
|
|
expect(await self.json()).toEqual({ displays: [], name: "Foyer TV" });
|
|
const listed = (await (
|
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
|
).json()) as { devices: Array<{ id: string; name: string; permissions: string[] }> };
|
|
expect(listed.devices).toHaveLength(1);
|
|
expect(listed.devices[0]).toMatchObject({ name: "Foyer TV", permissions: [] });
|
|
|
|
// Only a device is answered about itself: never a person, never an unpaired screen.
|
|
expect(
|
|
(await adminRequest(running.origin, running.ownerCookie, "/api/v1/device-pairing/self"))
|
|
.status,
|
|
).toBe(403);
|
|
expect(
|
|
(await new DeviceBrowser(running.origin).request("/api/v1/device-pairing/self")).status,
|
|
).toBe(403);
|
|
|
|
// Revoking it works as for any display, and it can start over at the same page.
|
|
const revoked = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
|
{},
|
|
);
|
|
expect(revoked.status).toBe(204);
|
|
expect((await device.request("/api/v1/device-pairing/self")).status).toBe(403);
|
|
expect((await device.request("/display/setup")).status).toBe(200);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("lets the administrator choose the module a platform display shows when approving it", async () => {
|
|
const running = await platform("platform-assign");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {});
|
|
const { code } = (await requested.json()) as { code: string };
|
|
|
|
const modules = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/devices/display-modules",
|
|
);
|
|
expect(await modules.json()).toEqual({
|
|
modules: [
|
|
{
|
|
moduleId: "announcements",
|
|
name: "Announcements fixture",
|
|
permissionId: displayPermission,
|
|
},
|
|
],
|
|
});
|
|
const approved = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code, deviceName: "Hall screen", moduleId: "announcements" },
|
|
);
|
|
expect(approved.status).toBe(200);
|
|
expect(await approved.json()).toEqual({
|
|
moduleId: "announcements",
|
|
permissionId: displayPermission,
|
|
});
|
|
expect(await (await device.post("/api/v1/device-pairing/claims", {})).json()).toEqual({
|
|
status: "paired",
|
|
});
|
|
|
|
// The one platform display now holds exactly that module's display permission, so the setup
|
|
// page knows where to go and the module's display accepts it.
|
|
expect(await (await device.request("/api/v1/device-pairing/self")).json()).toEqual({
|
|
displays: [{ moduleId: "announcements", url: "/display/announcements" }],
|
|
name: "Hall screen",
|
|
});
|
|
expect((await device.request("/api/v1/module-displays/announcements")).status).toBe(200);
|
|
const listed = (await (
|
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
|
).json()) as { devices: Array<{ permissions: string[] }> };
|
|
expect(listed.devices).toHaveLength(1);
|
|
expect(listed.devices[0]?.permissions).toEqual([displayPermission]);
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
|
|
it("never takes a permission from a request, and never redirects a module display's own one", async () => {
|
|
const running = await platform("platform-refusals");
|
|
const device = new DeviceBrowser(running.origin);
|
|
try {
|
|
// A request can never name a permission: any other key is ignored, so this is a platform
|
|
// request that grants nothing. A module that is not a string is refused.
|
|
const ignored = await device.post("/api/v1/device-pairing/requests", {
|
|
permissionId: "core.devices.manage",
|
|
});
|
|
expect(ignored.status).toBe(201);
|
|
const ignoredCode = ((await ignored.json()) as { code: string }).code;
|
|
expect(
|
|
await (
|
|
await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/lookups",
|
|
{
|
|
code: ignoredCode,
|
|
},
|
|
)
|
|
).json(),
|
|
).toMatchObject({ moduleId: null, permissionId: null });
|
|
expect(
|
|
(
|
|
await new DeviceBrowser(running.origin).post("/api/v1/device-pairing/requests", {
|
|
moduleId: null,
|
|
})
|
|
).status,
|
|
).toBe(400);
|
|
|
|
const requested = await device.post("/api/v1/device-pairing/requests", {
|
|
moduleId: "announcements",
|
|
});
|
|
const { code } = (await requested.json()) as { code: string };
|
|
// A module that is not a running display is refused at approval.
|
|
expect(
|
|
(
|
|
await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{
|
|
code,
|
|
deviceName: "Screen",
|
|
moduleId: "not-a-display",
|
|
},
|
|
)
|
|
).status,
|
|
).toBe(400);
|
|
// Naming its own module again is fine: the request is unchanged.
|
|
const approved = await adminRequest(
|
|
running.origin,
|
|
running.ownerCookie,
|
|
"/api/v1/device-pairing/approvals",
|
|
{ code, deviceName: "Screen", moduleId: "announcements" },
|
|
);
|
|
expect(approved.status).toBe(200);
|
|
expect(await approved.json()).toEqual({
|
|
moduleId: "announcements",
|
|
permissionId: displayPermission,
|
|
});
|
|
} finally {
|
|
await running.close();
|
|
}
|
|
});
|
|
});
|