Files
mdg-filius/packages/server/test/access.test.ts
T
julian.kiedaischandClaude Opus 5.5 fb1a467221 feat: F6.1 FILIUS files on F: projects from examples and .fls, copies, export
/svc/examples lists FILIUS' examples, which lie beside F in the image (GPL
like this repository, kept out of the dashboard); /svc/projects makes a
project from one; /svc/projects/<id>/import takes a .fls (up to 64 MiB),
/copy copies a project as it is now, /export gives it back as a .fls with its
warnings. Large files go into the blob store on import and come back from it
on export, which is refused when one is missing.

Found on the way: Node's request body is a Buffer, and Yjs takes no Buffer
into a document, so every .fls with binary files would have failed; and
fflate, now in the bundle, imports createRequire itself, which the build's
banner declared again, so F did not start. The banner names it otherwise and
a test builds and starts the bundle. The proxy's /svc/ takes 70 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:55:11 +00:00

431 lines
25 KiB
TypeScript

// Who comes into a project at F (F5.2) with real clients and a real
// Postgres: the dashboard's ticket redeemed at /__auth for a cookie, and
// the browser sent on to a place of F's own; the session's grant decides at
// /sync — none, read or write —, and a refusal says why; a browser opening a
// second project keeps its session, the next person on a shared device gets
// one of their own; a page of another origin gets no WebSocket. The
// dashboard's service with its key: makes and deletes projects (whoever is
// in one is let go), freezes and thaws them (whoever is in one reads, and is
// told), ends a person's grant or all their sessions (whoever is connected
// is let go and refused when they come back), lists who has which grant.
import { randomUUID } from 'node:crypto';
import pg from 'pg';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import WebSocket from 'ws';
import * as Y from 'yjs';
import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc';
import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol';
import { cookieValue, PostgresSessions, PostgresStore, refusal, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
import { testDatabase, type TestDatabase } from './database.js';
import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js';
import { signTicket } from './tickets.js';
let database: TestDatabase;
let store: PostgresStore;
let sessions: PostgresSessions;
let sql: pg.Pool;
beforeAll(async () => {
database = await testDatabase();
store = PostgresStore.connect(database.url);
sessions = new PostgresSessions(store.pool);
await store.migrate();
await sessions.migrate();
sql = new pg.Pool({ connectionString: database.url });
}, 60_000);
afterAll(async () => {
await sql?.end();
await store?.close();
await database?.stop();
});
const newProject = () => makeProject(store);
const start = (options: { readonly sessions?: SessionStore; readonly secureCookie?: boolean } = {}) =>
startTestServer(store, options.sessions ?? sessions, options.secureCookie === undefined ? {} : { secureCookie: options.secureCookie });
/** A call of the dashboard's service. */
function service(server: TestServer, method: string, path: string, body?: unknown, key: string | null = SERVICE_KEY): Promise<Response> {
return fetch(`${server.base}${path}`, {
method,
headers: { 'content-type': 'application/json', ...(key === null ? {} : { 'x-filius-service-key': key }) },
...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }),
});
}
const answer = async (response: Response) => [response.status, await response.json()] as const;
const deviceCount = (doc: Y.Doc) => doc.getMap(ROOTS.nodes).size;
const place = (doc: Y.Doc, id: string) => addDevice(doc, { id, kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
/** The status a WebSocket upgrade to /sync is answered with (101 when it opens). */
function upgrade(server: TestServer, headers: Record<string, string>): Promise<number> {
return new Promise((resolve) => {
const socket = new WebSocket(`ws://127.0.0.1:${server.port}/sync`, { headers });
socket.on('open', () => {
socket.close();
resolve(101);
});
socket.on('unexpected-response', (_request, response) => resolve(response.statusCode ?? 0));
socket.on('error', () => {});
});
}
describe('the exchange at /__auth', () => {
it('redeems a ticket for a session cookie and sends the browser on', async () => {
const server = await start();
const project = await newProject();
const response = await exchange(server, project, { login: 'ab12', level: 'read' }, '/editor?mode=x#top');
expect(response.status).toBe(302);
expect(response.headers.get('location')).toBe('/editor?mode=x#top');
expect(response.headers.get('cache-control')).toBe('no-store');
const cookie = response.headers.get('set-cookie')!;
expect(cookie).toMatch(new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Max-Age=${SESSION_SECONDS}; Path=/; HttpOnly; SameSite=Lax$`));
expect(server.log.lines.find((line) => line.event === 'auth.signedIn')).toEqual({ level: 'info', event: 'auth.signedIn', project, user: 'ab12', grant: 'read' });
await server.close();
});
it('sets the cookie for HTTPS only, unless told otherwise', async () => {
const server = await start({ secureCookie: true });
const response = await exchange(server, await newProject());
expect(response.headers.get('set-cookie')).toMatch(/; HttpOnly; SameSite=Lax; Secure$/);
await server.close();
expect(sessionCookie('k', false)).toBe(`${SESSION_COOKIE}=k; Max-Age=43200; Path=/; HttpOnly; SameSite=Lax`);
});
it('refuses a bad ticket without saying why, and says why in the log', async () => {
const server = await start();
const project = await newProject();
const old = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, TICKET_SECRET, Math.floor(Date.now() / 1000) - 61);
const forged = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, 'guessed');
for (const ticket of [old, forged, '']) {
const response = await fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}`, { redirect: 'manual' });
expect(await answer(response)).toEqual([401, { error: 'invalidTicket' }]);
expect(response.headers.get('set-cookie')).toBeNull();
}
expect((await fetch(`${server.base}/__auth`, { redirect: 'manual' })).status).toBe(401);
// Only a GET exchanges: a ticket sent otherwise is not looked at.
const ticket = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, TICKET_SECRET);
expect(await answer(await fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}`, { method: 'POST', redirect: 'manual' }))).toEqual([404, { error: 'notFound' }]);
expect(server.log.lines.filter((line) => line.event === 'auth.ticketRefused').map((line) => line.reason)).toEqual(['ticket 61 s old', 'signature does not match', 'no signature', 'no signature']);
await server.close();
});
it('refuses a ticket to a project F does not know', async () => {
const server = await start();
const project = randomUUID();
expect(await answer(await exchange(server, project, { login: 'ab12' }))).toEqual([404, { error: 'unknownProject' }]);
expect(server.log.lines.find((line) => line.event === 'auth.unknownProject')).toMatchObject({ project, user: 'ab12' });
expect((await sql.query('SELECT count(*)::int AS n FROM filius_grant WHERE project_uuid = $1', [project])).rows[0].n).toBe(0);
await server.close();
});
it.each([
['/editor', '/editor'],
['/a/b?c=d#e', '/a/b?c=d#e'],
['relative', '/relative'],
[null, '/'],
['', '/'],
['//elsewhere.test/x', '/'],
['/\\elsewhere.test/x', '/'],
['/\t/elsewhere.test', '/'],
['/.//elsewhere.test', '/'],
['https://elsewhere.test/', '/'],
['javascript:alert(1)', '/'],
['http://filius.invalid/x', '/x'],
])('sends the browser after the exchange from %j to %j', (next, place) => {
expect(safeNext(next)).toBe(place);
});
it('keeps the session when the same browser opens a second project, and takes the newer level', async () => {
const server = await start();
const [first, second] = [await newProject(), await newProject()];
const user = randomUUID();
const cookie = await signIn(server, first, { user });
const again = await exchange(server, second, { user, cookie, level: 'read' });
expect(again.headers.get('set-cookie')!.split(';')[0]).toBe(cookie);
const a = await join(server, first, { cookie });
const b = await join(server, second, { cookie });
expect([a.provider.authorizedScope, b.provider.authorizedScope]).toEqual(['read-write', 'readonly']);
// The same project with a read ticket: from now on it is read.
await exchange(server, first, { user, cookie, level: 'read' });
const c = await join(server, first, { cookie });
expect(c.provider.authorizedScope).toBe('readonly');
for (const each of [a, b, c]) each.close();
await server.close();
});
it('gives the next person on the same browser a session of their own, and ends the last one', async () => {
const server = await start();
const project = await newProject();
const theirs = await signIn(server, project, { login: 'mia' });
const mine = await exchange(server, project, { login: 'ben', cookie: theirs });
expect(mine.headers.get('set-cookie')!.split(';')[0]).not.toBe(theirs);
await expect(join(server, project, { cookie: theirs })).rejects.toThrow('refused: notSignedIn');
await server.close();
});
it('lengthens a session with every ticket and forgets the ones that ran out', async () => {
const server = await start();
const project = await newProject();
const user = randomUUID();
const cookie = await signIn(server, project, { user });
await sql.query("UPDATE filius_session SET expires_at = now() + interval '1 minute' WHERE user_uuid = $1", [user]);
await exchange(server, project, { user, cookie });
const left = (await sql.query('SELECT extract(epoch FROM expires_at - now())::int AS s FROM filius_session WHERE user_uuid = $1', [user])).rows[0].s as number;
expect(left).toBeGreaterThan(SESSION_SECONDS - 60);
const gone = randomUUID();
await signIn(server, project, { user: gone });
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [gone]);
await signIn(server, project);
expect((await sql.query('SELECT count(*)::int AS n FROM filius_session WHERE user_uuid = $1', [gone])).rows[0].n).toBe(0);
await server.close();
});
});
describe('the way into a shared document', () => {
it('refuses without a session, with a session that ran out, and without a grant to the project — saying which', async () => {
const server = await start();
const [project, other] = [await newProject(), await newProject()];
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
await expect(join(server, project, { cookie: `${SESSION_COOKIE}=made-up` })).rejects.toThrow('refused: notSignedIn');
const user = randomUUID();
const cookie = await signIn(server, other, { user, login: 'ab12' });
await expect(join(server, project, { cookie })).rejects.toThrow('refused: noAccess');
await expect(join(server, 'not-a-project', { cookie })).rejects.toThrow('refused: notAProject');
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [user]);
await expect(join(server, other, { cookie })).rejects.toThrow('refused: notSignedIn');
expect(server.log.lines.filter((line) => line.event === 'sync.refused').map(({ reason, user: login }) => [reason, login])).toEqual([
['notSignedIn', undefined],
['notSignedIn', undefined],
['noAccess', 'ab12'],
['notSignedIn', undefined],
]);
expect(SYNC_REFUSALS).toEqual(['notAProject', 'notSignedIn', 'noAccess', 'unknownProject']);
await server.close();
});
it('logs who joined and whether they read', async () => {
const server = await start();
const project = await newProject();
const person = await enter(server, project, { login: 'ab12', level: 'read' });
expect(server.log.lines.find((line) => line.event === 'sync.joined')).toEqual({ level: 'info', event: 'sync.joined', project, user: 'ab12', readOnly: true });
person.close();
await server.close();
});
it("refuses a WebSocket from a page of another origin, and takes the editor's and one without an origin", async () => {
const server = await start();
expect(await upgrade(server, { origin: 'https://elsewhere.test' })).toBe(403);
expect(await upgrade(server, { origin: EDITOR_ORIGIN })).toBe(101);
expect(await upgrade(server, {})).toBe(101);
expect(server.log.lines.filter((line) => line.event === 'sync.foreignOrigin').map((line) => line.origin)).toEqual(['https://elsewhere.test']);
const project = await newProject();
const person = await enter(server, project, {}, { origin: EDITOR_ORIGIN });
expect(person.provider.isSynced).toBe(true);
person.close();
await server.close();
});
it('refuses without a word on the console: the reason goes to the editor, the log has the rest', async () => {
expect([refusal('noAccess').reason, refusal('noAccess').message]).toEqual(['noAccess', '']);
const server = await start();
const project = await newProject();
const written: unknown[] = [];
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => void written.push(args));
try {
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
} finally {
spy.mockRestore();
}
expect(written).toEqual([]);
await server.close();
});
it('reads a cookie among others, and nothing that only looks like it', () => {
expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key');
expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals');
expect(cookieValue(`x${SESSION_COOKIE}=key; =y`, SESSION_COOKIE)).toBeUndefined();
expect(cookieValue(undefined, SESSION_COOKIE)).toBeUndefined();
});
});
describe("the dashboard's service", () => {
it('lets in only with the service key', async () => {
const server = await start();
for (const key of [null, '', 'wrong', `${SERVICE_KEY}x`]) expect(await answer(await service(server, 'GET', '/svc/sessions', undefined, key))).toEqual([403, { error: 'forbidden' }]);
expect(server.log.lines.filter((line) => line.event === 'svc.forbidden')).toHaveLength(4);
expect([serviceKeyMatches('k', 'k'), serviceKeyMatches('k', ''), serviceKeyMatches('', ''), serviceKeyMatches(['k'], 'k')]).toEqual([true, false, false, false]);
await server.close();
});
it('makes an empty project once', async () => {
const server = await start();
const project = randomUUID();
expect(await answer(await service(server, 'POST', '/svc/projects', { project }))).toEqual([201, { project, warnings: [] }]);
expect(await answer(await service(server, 'POST', '/svc/projects', { project }))).toEqual([409, { error: 'exists' }]);
const doc = new Y.Doc();
Y.applyUpdate(doc, (await store.load(project))!.state);
expect(readProject(doc)).toEqual(emptyProject());
const person = await enter(server, project);
expect(deviceCount(person.doc)).toBe(0);
person.close();
expect(server.log.lines.filter((line) => line.event === 'svc.created').map((line) => line.project)).toEqual([project]);
await server.close();
});
it('answers what it cannot read, what it does not know and what is too large', async () => {
const server = await start();
expect(await answer(await service(server, 'POST', '/svc/projects', { project: 'x' }))).toEqual([400, { error: 'notAProject' }]);
expect(await answer(await service(server, 'POST', '/svc/projects', '{"project":'))).toEqual([400, { error: 'notJson' }]);
expect(await answer(await service(server, 'POST', '/svc/projects', '[1]'))).toEqual([400, { error: 'notAnObject' }]);
expect(await answer(await service(server, 'POST', '/svc/projects', 'null'))).toEqual([400, { error: 'notAnObject' }]);
expect(await answer(await service(server, 'POST', '/svc/projects', { project: 'x'.repeat(SERVICE_BODY_BYTES) }))).toEqual([413, { error: 'tooLarge' }]);
expect(await answer(await service(server, 'GET', '/svc/projects'))).toEqual([404, { error: 'notFound' }]);
expect(await answer(await service(server, 'POST', '/svc/sessions', {}))).toEqual([404, { error: 'notFound' }]);
expect(await answer(await service(server, 'PUT', `/svc/projects/${randomUUID()}`))).toEqual([404, { error: 'notFound' }]);
expect(await answer(await service(server, 'DELETE', '/svc/projects/x'))).toEqual([400, { error: 'notAProject' }]);
expect(await answer(await service(server, 'DELETE', `/svc/projects/${randomUUID()}`))).toEqual([404, { error: 'unknownProject' }]);
expect(await answer(await service(server, 'POST', `/svc/projects/${randomUUID()}/freeze`, { frozen: true }))).toEqual([404, { error: 'unknownProject' }]);
expect(await answer(await service(server, 'POST', `/svc/projects/${randomUUID()}/freeze`, { frozen: 'yes' }))).toEqual([400, { error: 'frozenNotABoolean' }]);
expect(await answer(await service(server, 'POST', '/svc/projects/x/freeze', { frozen: true }))).toEqual([400, { error: 'notAProject' }]);
expect(await answer(await service(server, 'POST', '/svc/revoke', { user: 'x' }))).toEqual([400, { error: 'notAPerson' }]);
expect(await answer(await service(server, 'POST', '/svc/revoke', { user: randomUUID(), project: 'x' }))).toEqual([400, { error: 'notAProject' }]);
await server.close();
});
it('deletes a project: whoever is in it is let go and finds no project when coming back, nothing stored after', async () => {
const server = await start();
const project = await newProject();
const cookie = await signIn(server, project);
const person = await join(server, project, { cookie });
place(person.doc, 'a');
await until(async () => (await store.pending(project)) === 1);
expect(await answer(await service(server, 'DELETE', `/svc/projects/${project}`))).toEqual([200, { deleted: true, disconnected: 1 }]);
await until(() => person.closings.length === 1);
expect(person.closings).toEqual(['deleted']);
await expect(join(server, project, { cookie })).rejects.toThrow('refused: unknownProject');
// What the person does now is stored nowhere.
place(person.doc, 'b');
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
expect(await store.load(project)).toBeUndefined();
expect(server.log.lines.filter((line) => ['store.failed', 'doc.unloadFailed'].includes(line.event as string))).toEqual([]);
expect(server.log.lines.find((line) => line.event === 'svc.deleted')).toMatchObject({ project, disconnected: 1 });
expect(await answer(await service(server, 'DELETE', `/svc/projects/${project}`))).toEqual([404, { error: 'unknownProject' }]);
expect(SYNC_CLOSINGS).toEqual(['revoked', 'deleted']);
person.close();
await server.close();
});
it('freezes a project: whoever writes in it reads from then on and is told, and writes again when it is thawed', async () => {
const server = await start();
const project = await newProject();
const [writerId, readerId] = [randomUUID(), randomUUID()];
const writer = await enter(server, project, { level: 'write', user: writerId, name: 'Mia' });
const reader = await enter(server, project, { level: 'read', user: readerId, name: 'Rita' });
// Each is told its access as it comes in, and who it is (with the colour of the ticket).
await until(() => writer.messages.length === 1 && reader.messages.length === 1);
expect([writer.messages, reader.messages]).toEqual([
[{ v: 1, kind: 'access', readOnly: false, frozen: false, you: { user: writerId, name: 'Mia', color: '#3366cc' } }],
[{ v: 1, kind: 'access', readOnly: true, frozen: false, you: { user: readerId, name: 'Rita', color: '#3366cc' } }],
]);
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]);
await until(() => writer.messages.length === 2);
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: true, frozen: true });
// Whoever comes now reads too, with a write grant; what they change is not taken.
const late = await enter(server, project, { level: 'write' });
expect(late.provider.authorizedScope).toBe('readonly');
await until(() => late.messages.length === 1);
expect(late.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true, you: expect.objectContaining({ name: 'Ada Beispiel' }) }]);
place(late.doc, 'a');
await sleep(300);
expect(await store.pending(project)).toBe(0);
expect(deviceCount(writer.doc)).toBe(0);
late.close();
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]);
await until(() => writer.messages.length === 3);
expect(writer.messages[2]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
place(writer.doc, 'b');
await until(async () => (await store.pending(project)) === 1);
await until(() => deviceCount(reader.doc) === 1);
// The reader was told nothing more: it read before and reads still.
expect(reader.messages).toHaveLength(1);
expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]);
writer.close();
reader.close();
await server.close();
});
it("ends a person's grant to one project: their connection there is let go and refused, the others stay", async () => {
const server = await start();
const [project, other] = [await newProject(), await newProject()];
const user = randomUUID();
const cookie = await signIn(server, project, { user });
await exchange(server, other, { user, cookie });
const here = await join(server, project, { cookie });
const there = await join(server, other, { cookie });
const someone = await enter(server, project);
expect(await answer(await service(server, 'POST', '/svc/revoke', { user, project }))).toEqual([200, { revoked: 1, disconnected: 1 }]);
await until(() => here.closings.length === 1);
expect(here.closings).toEqual(['revoked']);
await expect(join(server, project, { cookie })).rejects.toThrow('refused: noAccess');
expect([there.closings, someone.closings]).toEqual([[], []]);
place(there.doc, 'a');
await until(async () => (await store.pending(other)) === 1);
expect(server.log.lines.find((line) => line.event === 'svc.revoked')).toMatchObject({ user, project, revoked: 1, disconnected: 1 });
for (const each of [here, there, someone]) each.close();
await server.close();
});
it('ends all sessions of a person: every connection of theirs is let go, and they are signed out', async () => {
const server = await start();
const [project, other] = [await newProject(), await newProject()];
const user = randomUUID();
const phone = await signIn(server, project, { user });
const laptop = await signIn(server, other, { user });
const a = await join(server, project, { cookie: phone });
const b = await join(server, other, { cookie: laptop });
const someone = await enter(server, project);
expect(await answer(await service(server, 'POST', '/svc/revoke', { user }))).toEqual([200, { revoked: 2, disconnected: 2 }]);
await until(() => a.closings.length === 1 && b.closings.length === 1);
expect([a.closings, b.closings]).toEqual([['revoked'], ['revoked']]);
await expect(join(server, project, { cookie: phone })).rejects.toThrow('refused: notSignedIn');
await expect(join(server, other, { cookie: laptop })).rejects.toThrow('refused: notSignedIn');
expect(someone.closings).toEqual([]);
expect(await answer(await service(server, 'POST', '/svc/revoke', { user }))).toEqual([200, { revoked: 0, disconnected: 0 }]);
expect(server.log.lines.filter((line) => line.event === 'svc.revoked').at(-1)).not.toHaveProperty('project');
for (const each of [a, b, someone]) each.close();
await server.close();
});
it('lists who is signed in with which grant, the stronger of two devices', async () => {
await sql.query('DELETE FROM filius_session');
const server = await start();
const [project, other] = [await newProject(), await newProject()];
const [mia, ben] = [randomUUID(), randomUUID()].sort() as [string, string];
await signIn(server, project, { user: mia, level: 'read' });
await signIn(server, project, { user: mia, level: 'write' });
await signIn(server, other, { user: ben, level: 'read' });
const gone = randomUUID();
await signIn(server, other, { user: gone });
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [gone]);
const grants = [
{ user: mia, project, level: 'write' },
{ user: ben, project: other, level: 'read' },
].sort((x, y) => (x.user + x.project).localeCompare(y.user + y.project));
expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([200, { users: [mia, ben].sort(), grants }]);
await server.close();
});
it('answers 500 when something breaks, and logs it', async () => {
const broken = Object.assign(Object.create(sessions) as PostgresSessions, {
active: async () => {
throw new Error('database gone');
},
});
const server = await start({ sessions: broken });
expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([500, { error: 'internal' }]);
expect(server.log.lines.find((line) => line.event === 'http.failed')).toMatchObject({ level: 'error', path: '/svc/sessions', error: 'database gone' });
await server.close();
});
});