SPB Git

Repository Public

ws.test.ts 13.6 KB 267 lines typescript

1import { randomBytes } from 'node:crypto';2import { afterAll, beforeAll, describe, expect, it } from 'vitest';3import type WebSocket from 'ws';4import type { TerminalSession } from '@cr/shared';5import { Client, PASSWORD, createHarness, login, openWs, setupAdmin, until, upgradeStatus, type Harness } from '../testkit/harness.js';67let h: Harness;8let admin: Client;9let secret: string;10let addr: string;1112const create = async (): Promise<TerminalSession> => (await admin.post('/sessions', { kind: 'shell' })).json<TerminalSession>();13const closed = (ws: WebSocket): Promise<number> => new Promise((resolve) => ws.once('close', (code) => resolve(code)));14/** Collects frames; `next()` resolves with the following frame in arrival order. */15function frames(ws: WebSocket): { next(): Promise<{ data: Buffer; isBinary: boolean }>; all: { data: Buffer; isBinary: boolean }[] } {16  const all: { data: Buffer; isBinary: boolean }[] = [];17  let read = 0;18  ws.on('message', (data, isBinary) => all.push({ data: data as Buffer, isBinary }));19  return {20    all,21    async next() {22      await until(() => all.length > read);23      return all[read++] as { data: Buffer; isBinary: boolean };24    },25  };26}27async function attach(id: string, client = admin): Promise<{ ws: WebSocket; f: ReturnType<typeof frames> }> {28  const ws = openWs(addr, `/sessions/${id}/stream?cols=100&rows=30`, { cookie: client.cookieHeader });29  const f = frames(ws);30  expect(await upgradeStatus(ws)).toBe(101);31  const ready = await f.next();32  expect(ready.isBinary).toBe(false);33  expect(JSON.parse(ready.data.toString())).toEqual({ t: 'ready', sessionId: id, cols: 100, rows: 30 });34  return { ws, f };35}3637beforeAll(async () => {38  h = await createHarness({ tweak: (c) => (c.wsAuthRecheckMs = 150) });39  ({ client: admin, secret } = await setupAdmin(h));40  addr = await h.listen();41});42afterAll(async () => {43  await h.close();44});4546describe('upgrade authorisation', () => {47  it('refuses without a cookie (401), with a wrong or missing Origin (403), before the second factor (403)', async () => {48    const s = await create();49    const path = `/sessions/${s.id}/stream`;50    expect(await upgradeStatus(openWs(addr, path))).toBe(401);51    expect(await upgradeStatus(openWs(addr, path, { cookie: 'cr_sid=' + 'B'.repeat(43) }))).toBe(401);52    expect(await upgradeStatus(openWs(addr, path, { cookie: admin.cookieHeader, origin: 'https://evil.example' }))).toBe(403);53    expect(await upgradeStatus(openWs(addr, path, { cookie: admin.cookieHeader, origin: null }))).toBe(403);54    const half = new Client(h);55    await half.post('/auth/login', { username: 'admin', password: PASSWORD });56    expect(await upgradeStatus(openWs(addr, path, { cookie: half.cookieHeader }))).toBe(403);57    for (const p of ['/events', `/tasks/${s.id}/log/stream`]) {58      expect(await upgradeStatus(openWs(addr, p))).toBe(401);59      expect(await upgradeStatus(openWs(addr, p, { cookie: admin.cookieHeader, origin: 'https://evil.example' }))).toBe(403);60    }61    expect(h.rt.attachSockets.get(s.id)?.size ?? 0).toBe(0); // nothing ever reached the runtime62  });63  it('refuses unknown, foreign and ended sessions', async () => {64    expect(await upgradeStatus(openWs(addr, '/sessions/00000000-0000-4000-8000-000000000000/stream', { cookie: admin.cookieHeader }))).toBe(404);65    expect(await upgradeStatus(openWs(addr, '/sessions/not-a-uuid/stream', { cookie: admin.cookieHeader }))).toBe(400);66    const db = h.server.ctx.db;67    const other = await db.one<{ id: string }>("INSERT INTO users (username, password_hash) VALUES ('other', 'x') RETURNING id");68    const foreign = await db.one<{ id: string }>(69      "INSERT INTO terminal_sessions (owner_id, kind, title, state, cwd_abs) VALUES ($1, 'shell', 'theirs', 'RUNNING', $2) RETURNING id", [other?.id as string, h.workspace],70    );71    expect(await upgradeStatus(openWs(addr, `/sessions/${foreign?.id}/stream`, { cookie: admin.cookieHeader }))).toBe(404);72    expect((await admin.get(`/sessions/${foreign?.id}`)).statusCode).toBe(404);73    expect((await admin.post(`/sessions/${foreign?.id}/terminate`)).statusCode).toBe(404);74    await db.q('DELETE FROM users WHERE id = $1', [other?.id as string]);75    const ended = await create();76    await admin.post(`/sessions/${ended.id}/terminate`);77    expect(await upgradeStatus(openWs(addr, `/sessions/${ended.id}/stream`, { cookie: admin.cookieHeader }))).toBe(410);78  });79});8081describe('terminal stream', () => {82  it('echoes binary through the runtime byte-for-byte, including a 1 MiB frame', async () => {83    const s = await create();84    const { ws, f } = await attach(s.id);85    ws.send(Buffer.from([0, 255, 27, 91, 65, 10, 13]), { binary: true });86    const small = await f.next();87    expect(small.isBinary).toBe(true);88    expect([...small.data]).toEqual([0, 255, 27, 91, 65, 10, 13]);89    const big = randomBytes(1024 * 1024);90    ws.send(big, { binary: true });91    const echoed = await f.next();92    expect(echoed.isBinary).toBe(true);93    expect(echoed.data.length).toBe(big.length);94    expect(echoed.data.equals(big)).toBe(true);95    ws.close();96    await closed(ws);97  });98  it('passes resize/ping control frames, keeps text frames text, drops invalid ones', async () => {99    const s = await create();100    const { ws, f } = await attach(s.id);101    ws.send(JSON.stringify({ t: 'resize', cols: 80, rows: 24 }));102    ws.send('not json');103    ws.send(JSON.stringify({ t: 'resize', cols: 99999, rows: 24 }));104    ws.send(JSON.stringify({ t: 'exec', cmd: 'id' }));105    ws.send(JSON.stringify({ t: 'ping', n: 7 }));106    const pong = await f.next();107    expect(pong.isBinary).toBe(false);108    expect(JSON.parse(pong.data.toString())).toEqual({ t: 'pong', n: 7 });109    expect(h.rt.controls.filter((c) => c.sessionId === s.id).map((c) => c.msg)).toEqual([{ t: 'resize', cols: 80, rows: 24 }, { t: 'ping', n: 7 }]);110    ws.close();111    await closed(ws);112    await until(() => (h.rt.attachSockets.get(s.id)?.size ?? 0) === 0); // closing the browser side detaches upstream113  });114  it('frames above 1 MiB are rejected', async () => {115    const s = await create();116    const { ws, f } = await attach(s.id);117    const done = closed(ws);118    ws.send(Buffer.alloc(1024 * 1024 + 1), { binary: true });119    // 1009 (message too big); 1006 is possible when the peer is cut while still writing.120    expect([1009, 1006]).toContain(await done);121    expect(f.all.length).toBe(1); // only `ready`: the oversized frame was never relayed and echoed122    await until(() => (h.rt.attachSockets.get(s.id)?.size ?? 0) === 0);123  });124  it('two sessions are isolated', async () => {125    const a = await create();126    const b = await create();127    const A = await attach(a.id);128    const B = await attach(b.id);129    A.ws.send(Buffer.from('only-a'), { binary: true });130    B.ws.send(Buffer.from('only-b'), { binary: true });131    expect((await A.f.next()).data.toString()).toBe('only-a');132    expect((await B.f.next()).data.toString()).toBe('only-b');133    await new Promise((r) => setTimeout(r, 100));134    expect(A.f.all.length).toBe(2);135    expect(B.f.all.length).toBe(2);136    const status = (await admin.get('/system/status')).json<{ counts: { wsClients: number; liveSessions: number } }>();137    expect(status.counts.wsClients).toBe(2);138    A.ws.close();139    B.ws.close();140    await Promise.all([closed(A.ws), closed(B.ws)]);141    await until(async () => (await admin.get('/system/status')).json<{ counts: { wsClients: number } }>().counts.wsClients === 0);142  });143  it('session state follows the proxied streams: RUNNING at once, DETACHED after the last one, WAITING_FOR_INPUT untouched', async () => {144    // No runtime events and no reconciler in this harness: only the server's own knowledge145    // of its streams (+ the runtime query after a detach) can move the state.146    const get = async (id: string): Promise<TerminalSession> => (await admin.get(`/sessions/${id}`)).json<TerminalSession>();147    const hook = (id: string, event: string): Promise<void> => h.server.ctx.sessions.onRuntimeEvent({ type: 'claude.hook', id, event, conversationId: null, message: null });148    const s = await create();149    expect(s).toMatchObject({ state: 'DETACHED', attachedClients: 0 });150151    const t0 = Date.now();152    const A = await attach(s.id);153    await until(async () => (await get(s.id)).state === 'RUNNING', 1000);154    expect(Date.now() - t0).toBeLessThan(1000);155    expect((await get(s.id)).attachedClients).toBe(1);156    const B = await attach(s.id);157    await until(async () => (await get(s.id)).attachedClients === 2, 1000);158159    A.ws.close();160    await closed(A.ws);161    await until(async () => (await get(s.id)).attachedClients === 1, 1000);162    await new Promise((r) => setTimeout(r, 600)); // past the post-detach runtime check163    expect(await get(s.id)).toMatchObject({ state: 'RUNNING', attachedClients: 1 });164165    B.ws.close();166    await closed(B.ws);167    await until(async () => (await get(s.id)).state === 'DETACHED', 3000);168    expect((await get(s.id)).attachedClients).toBe(0);169    const transitions = await h.server.ctx.db.q<{ detail: { from: string; to: string; source: string } }>(170      "SELECT detail FROM terminal_events WHERE session_id = $1 AND kind = 'state' ORDER BY id", [s.id]);171    expect(transitions.map((r) => `${r.detail.to}:${r.detail.source ?? ''}`).slice(-2)).toEqual(['RUNNING:attach', 'DETACHED:reconcile']);172173    // WAITING_FOR_INPUT (hook) survives a detach and a re-attach; only the hook clears it.174    const C = await attach(s.id);175    await until(async () => (await get(s.id)).state === 'RUNNING', 1000);176    await hook(s.id, 'Notification');177    expect((await get(s.id)).state).toBe('WAITING_FOR_INPUT');178    C.ws.close();179    await closed(C.ws);180    await until(async () => (await get(s.id)).attachedClients === 0, 1000);181    await new Promise((r) => setTimeout(r, 2000)); // both post-detach checks have run182    expect(await get(s.id)).toMatchObject({ state: 'WAITING_FOR_INPUT', attachedClients: 0 });183    const D = await attach(s.id);184    await until(async () => (await get(s.id)).attachedClients === 1, 1000);185    expect((await get(s.id)).state).toBe('WAITING_FOR_INPUT');186    await hook(s.id, 'UserPromptSubmit');187    expect(await get(s.id)).toMatchObject({ state: 'RUNNING', attachedClients: 1, waitingReason: null });188    D.ws.close();189    await closed(D.ws);190    await until(async () => (await get(s.id)).state === 'DETACHED', 3000);191  });192  it('the runtime ending the session closes the browser socket with its code', async () => {193    const s = await create();194    const { ws } = await attach(s.id);195    const done = closed(ws);196    await admin.post(`/sessions/${s.id}/terminate`);197    expect(await done).toBe(4410);198  });199  it('runtime unreachable ⇒ 4503', async () => {200    const s = await create();201    await h.rt.stop();202    const ws = openWs(addr, `/sessions/${s.id}/stream`, { cookie: admin.cookieHeader });203    expect(await upgradeStatus(ws)).toBe(101);204    expect(await closed(ws)).toBe(4503);205    await h.rt.start();206  });207});208209describe('auth session lifetime on open sockets', () => {210  it('logout closes the terminal socket with 4401 and detaches upstream', async () => {211    const c = await login(h, secret);212    const s = await create();213    const { ws } = await attach(s.id, c);214    const done = closed(ws);215    await c.post('/auth/logout');216    expect(await done).toBe(4401);217    await until(() => (h.rt.attachSockets.get(s.id)?.size ?? 0) === 0);218  });219  it('a session revoked or expired behind our back is caught by the periodic re-check', async () => {220    const c = await login(h, secret);221    const s = await create();222    const { ws } = await attach(s.id, c);223    const done = closed(ws);224    const sid = (await c.get('/auth/devices')).json<{ id: string; current: boolean }[]>().find((d) => d.current)?.id as string;225    await h.server.ctx.db.q("UPDATE auth_sessions SET expires_at = now() - interval '1 second' WHERE id = $1", [sid]);226    expect(await done).toBe(4401);227  });228});229230describe('events stream', () => {231  it('sends hello, then ServerEvents, then auth.expired before closing', async () => {232    const c = await login(h, secret);233    const ws = openWs(addr, '/events', { cookie: c.cookieHeader });234    const f = frames(ws);235    expect(await upgradeStatus(ws)).toBe(101);236    const hello = JSON.parse((await f.next()).data.toString());237    expect(hello.type).toBe('hello');238    expect(Number.isNaN(Date.parse(hello.serverTime))).toBe(false);239    const s = await create();240    await until(() => f.all.some((m) => m.data.toString().includes(s.id)));241    const ev = JSON.parse((f.all.find((m) => m.data.toString().includes(s.id)) as { data: Buffer }).data.toString());242    expect(ev).toMatchObject({ type: 'session.updated', session: { id: s.id } });243    expect(JSON.stringify(ev)).not.toContain(h.workspace);244    const done = closed(ws);245    await c.post('/auth/logout');246    expect(await done).toBe(4401);247    expect(JSON.parse((f.all.at(-1) as { data: Buffer }).data.toString())).toEqual({ type: 'auth.expired' });248  });249});250251describe('task log stream', () => {252  it('streams the runtime log bytes and ignores client frames', async () => {253    const t = (await admin.post('/tasks', { title: 't', command: 'echo hi' })).json<{ id: string }>();254    h.rt.taskLogs.set(t.id, Buffer.from('line 1\nline 2\n'));255    const ws = openWs(addr, `/tasks/${t.id}/log/stream?offset=0`, { cookie: admin.cookieHeader });256    const f = frames(ws);257    const done = closed(ws);258    expect(await upgradeStatus(ws)).toBe(101);259    ws.send(Buffer.from('ignored'), { binary: true });260    const first = await f.next();261    expect(first.isBinary).toBe(true);262    expect(first.data.toString()).toBe('line 1\nline 2\n');263    expect(await done).toBe(1000);264    expect(await upgradeStatus(openWs(addr, '/tasks/00000000-0000-4000-8000-000000000000/log/stream', { cookie: admin.cookieHeader }))).toBe(404);265  });266});267
Files main
Menu

Theme