A grant lasts the session's school day; a connection ends with the tab. The dashboard needs the second to end a teacher's write share that holds for one session (F7.1, "Mitarbeiten" in "Alle Projekte"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
449 lines
26 KiB
TypeScript
449 lines
26 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, open: [] }]);
|
|
await server.close();
|
|
});
|
|
|
|
it('lists who has which project open: connected, once per person and project; a closed tab is gone', 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];
|
|
const miaCookie = await signIn(server, project, { user: mia });
|
|
const tab = await join(server, project, { cookie: miaCookie });
|
|
const second = await join(server, project, { cookie: miaCookie });
|
|
await signIn(server, other, { user: ben });
|
|
const open = async () => ((await (await service(server, 'GET', '/svc/sessions')).json()) as { open: unknown }).open;
|
|
expect(await open()).toEqual([{ user: mia, project }]);
|
|
tab.close();
|
|
expect(await open()).toEqual([{ user: mia, project }]);
|
|
second.close();
|
|
await until(async () => ((await open()) as unknown[]).length === 0);
|
|
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();
|
|
});
|
|
});
|