Files
web-terminal/test/session.test.ts
Yaojia Wang d22dcd24f7 fix: address review report across security, architecture, quality, tests
Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review).
typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage
thresholds (80%) enforced in vitest.config.ts.

Critical:
- multi-device approval race: release held approval only when the last
  client detaches (closing one mirror no longer cancels another's prompt)
- unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS),
  enforced in manager via the existing M4 exit(-1) path
- signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close()
- terminal-session initialInput timer tracked + cleared on dispose
- tabs.addEntry null-as-cast type hole removed (build session before entry)

Should-fix:
- security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id]
- history.ts converted to fs/promises (async /sessions handler)
- removed dead clientDims map + blur protocol message end-to-end
- per-connection WS message rate limit (Config.maxMsgsPerSec)
- /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7)

Tests:
- new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites
- extended history/config/manager/integration coverage incl. regressions

Hygiene:
- parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation
- log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped
- operational constants moved into Config
- extracted public/preview-grid.ts (DRY launcher/manage)
- doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
2026-06-20 18:27:45 +02:00

410 lines
14 KiB
TypeScript

/**
* T12 tests — session/session.ts (PTY lifecycle + multi-client sharing).
*
* ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes:
* - M4: spawn failure THROWS, never swallowed.
* - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
* - L1: detach-then-exit keeps the session (exit not delivered when no client attached).
* - L4: writeInput/setClientDims are no-ops once the PTY has exited.
* - v0.4: a session may hold MANY clients (multi-device mirror). Output/exit are
* broadcast to all; the PTY size is the MIN cols/rows across clients.
*
* node-pty is mocked (`vi.mock`) so `spawn` returns a MockIPty — the sandbox blocks
* real posix_spawn, and the mock lets us drive onData/onExit deterministically.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { Config, Dims, Session, ServerMessage, WebSocketLike } from '../src/types.js';
import { WS_OPEN } from '../src/types.js';
import { createMockPty, type MockIPty } from './helpers/mock-pty.js';
// ── node-pty mock ───────────────────────────────────────────────────────────
let nextPty: MockIPty;
let spawnError: Error | null = null;
const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = [];
vi.mock('node-pty', () => ({
spawn: (file: string, args: string[] | string, options: unknown) => {
spawnCalls.push({ file, args, options });
if (spawnError) throw spawnError;
return nextPty;
},
}));
// Imported AFTER vi.mock so the module under test binds to the mocked spawn.
const { createSession, attachWs, detachWs, writeInput, setClientDims, kill } =
await import('../src/session/session.js');
// ── helpers ─────────────────────────────────────────────────────────────────
const CFG: Config = {
port: 3000,
bindHost: '0.0.0.0',
shellPath: '/bin/zsh',
homeDir: '/home/tester',
idleTtlMs: 86_400_000,
scrollbackBytes: 2 * 1024 * 1024,
maxPayloadBytes: 1024 * 1024,
wsPath: '/term',
maxSessions: 50,
maxMsgsPerSec: 2000,
permTimeoutMs: 300_000,
reapIntervalMs: 60_000,
previewBytes: 24 * 1024,
useTmux: false,
allowedOrigins: [],
};
const DIMS: Dims = { cols: 80, rows: 24 };
interface MockWs extends WebSocketLike {
readyState: number;
sent: string[];
closed: boolean;
}
function createMockWs(readyState: number = WS_OPEN): MockWs {
const sent: string[] = [];
return {
readyState,
sent,
closed: false,
send(data: string) {
sent.push(data);
},
close() {
this.closed = true;
},
};
}
/** Decode the JSON frames a ws received back into ServerMessages. */
function received(ws: MockWs): ServerMessage[] {
return ws.sent.map((s) => JSON.parse(s) as ServerMessage);
}
function newSession(onExit: (s: Session) => void = () => {}): Session {
nextPty = createMockPty();
return createSession(CFG, DIMS, 1_000, onExit);
}
beforeEach(() => {
spawnError = null;
spawnCalls.length = 0;
});
// ── createSession / spawn ─────────────────────────────────────────────────────
describe('createSession', () => {
it('spawns the PTY via node-pty with shellPath, cwd and dims', () => {
const s = newSession();
expect(spawnCalls).toHaveLength(1);
expect(spawnCalls[0]!.file).toBe(CFG.shellPath);
const opts = spawnCalls[0]!.options as { cwd?: string; cols?: number; rows?: number };
expect(opts.cwd).toBe(CFG.homeDir);
expect(opts.cols).toBe(DIMS.cols);
expect(opts.rows).toBe(DIMS.rows);
expect(s.pty).toBe(nextPty);
expect(s.meta.shellPath).toBe(CFG.shellPath);
expect(s.meta.createdAt).toBe(1_000);
expect(s.clients.size).toBe(0);
expect(s.exitedAt).toBeNull();
expect(s.exitCode).toBeNull();
});
it('THROWS (does not swallow) when spawn fails (M4)', () => {
spawnError = new Error('spawn failed: /no/such/shell ENOENT');
nextPty = createMockPty();
expect(() => createSession(CFG, DIMS, 1_000, () => {})).toThrow(/spawn failed/);
});
});
// ── onData → buffer + broadcast ────────────────────────────────────────────────
describe('onData', () => {
it('appends output to the ring buffer and refreshes lastOutputAt', () => {
const s = newSession();
const pty = s.pty as MockIPty;
pty.emitData('hello ');
pty.emitData('world');
expect(s.buffer.snapshot()).toBe('\x1b[0mhello world');
expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000);
});
it('broadcasts output to the attached client as an `output` message', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
ws.sent.length = 0; // drop the replay frame from attach
(s.pty as MockIPty).emitData('abc');
expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]);
});
it('broadcasts output to EVERY client sharing the session (multi-device)', () => {
const s = newSession();
const a = createMockWs();
const b = createMockWs();
attachWs(s, a);
attachWs(s, b);
a.sent.length = 0;
b.sent.length = 0;
(s.pty as MockIPty).emitData('shared');
expect(received(a)).toEqual([{ type: 'output', data: 'shared' }]);
expect(received(b)).toEqual([{ type: 'output', data: 'shared' }]);
});
it('still buffers output when no client is attached (does not throw)', () => {
const s = newSession();
expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow();
expect(s.buffer.snapshot()).toContain('orphan');
});
it('does NOT send to a client whose readyState is not OPEN (M5)', () => {
const s = newSession();
const ws = createMockWs(WS_OPEN);
attachWs(s, ws);
ws.sent.length = 0;
ws.readyState = 3; // CLOSED
(s.pty as MockIPty).emitData('lost');
expect(ws.sent).toHaveLength(0);
});
});
// ── attachWs (join, no kick) ──────────────────────────────────────────────────
describe('attachWs', () => {
it('replays the buffer snapshot to the newly attached client', () => {
const s = newSession();
(s.pty as MockIPty).emitData('prior output');
const ws = createMockWs();
attachWs(s, ws);
expect(s.clients.has(ws)).toBe(true);
expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]);
});
it('a second attach JOINS (does not kick) — both clients stay connected', () => {
const s = newSession();
const a = createMockWs();
attachWs(s, a);
const b = createMockWs();
attachWs(s, b);
// Both present; the first was NOT closed.
expect(s.clients.has(a)).toBe(true);
expect(s.clients.has(b)).toBe(true);
expect(a.closed).toBe(false);
// Only the joining client got the replay; live output reaches both.
a.sent.length = 0;
b.sent.length = 0;
(s.pty as MockIPty).emitData('live');
expect(received(a)).toEqual([{ type: 'output', data: 'live' }]);
expect(received(b)).toEqual([{ type: 'output', data: 'live' }]);
});
it('attaching alone does NOT change the PTY size (the active device keeps it)', () => {
const s = newSession(); // spawn dims 80x24
const wide = createMockWs();
setClientDims(s, wide, 200, 50);
expect(s.pty.cols).toBe(200);
const hidden = createMockWs();
attachWs(s, hidden); // joins but never reports dims (background mirror)
// The join must not change the PTY — the active viewer keeps 200x50.
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
});
it('latest-writer-wins: the most recent client dims drive the PTY size', () => {
const s = newSession();
const desktop = createMockWs();
const ipad = createMockWs();
setClientDims(s, desktop, 200, 50);
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
// The device used next (smaller iPad) takes over — it is now full-screen.
setClientDims(s, ipad, 100, 40);
expect(s.pty.cols).toBe(100);
expect(s.pty.rows).toBe(40);
// Switching back to the desktop (re-fit on focus) reclaims its size.
setClientDims(s, desktop, 200, 50);
expect(s.pty.cols).toBe(200);
expect(s.pty.rows).toBe(50);
});
});
// ── detachWs (remove one client) ──────────────────────────────────────────────
describe('detachWs', () => {
it('removes the client and stamps detachedAt only when the LAST one leaves', () => {
const s = newSession();
const a = createMockWs();
const b = createMockWs();
attachWs(s, a);
attachWs(s, b);
detachWs(s, a, 5_000);
// one client remains → still "attached", no detach stamp
expect(s.clients.has(a)).toBe(false);
expect(s.clients.has(b)).toBe(true);
expect(s.detachedAt).toBeNull();
detachWs(s, b, 6_000);
// last client gone → detached
expect(s.clients.size).toBe(0);
expect(s.detachedAt).toBe(6_000);
expect((s.pty as MockIPty).killed).toBe(false);
});
it('does NOT resize when a client leaves (the remaining device keeps its size)', () => {
const s = newSession();
const wide = createMockWs();
const narrow = createMockWs();
attachWs(s, wide);
setClientDims(s, wide, 200, 50);
attachWs(s, narrow);
setClientDims(s, narrow, 90, 30); // narrow is the latest writer → 90x30
detachWs(s, narrow, 5_000);
// detach does not resize; PTY stays at the last set size until a client re-fits.
expect(s.pty.cols).toBe(90);
expect(s.pty.rows).toBe(30);
});
it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
detachWs(s, ws, 5_000);
(s.pty as MockIPty).emitData('background work');
expect((s.pty as MockIPty).killed).toBe(false);
expect(s.buffer.snapshot()).toContain('background work');
});
});
// ── onExit ────────────────────────────────────────────────────────────────────
describe('onExit', () => {
it('sets exitedAt/exitCode, broadcasts `exit` to all clients, and calls injected onExit', () => {
const onExit = vi.fn();
nextPty = createMockPty();
const s = createSession(CFG, DIMS, 1_000, onExit);
const a = createMockWs();
const b = createMockWs();
attachWs(s, a);
attachWs(s, b);
a.sent.length = 0;
b.sent.length = 0;
(s.pty as MockIPty).emitExit(0);
expect(s.exitedAt).not.toBeNull();
expect(s.exitCode).toBe(0);
expect(received(a)).toEqual([{ type: 'exit', code: 0 }]);
expect(received(b)).toEqual([{ type: 'exit', code: 0 }]);
expect(onExit).toHaveBeenCalledWith(s);
});
it('after the last detach, exit is NOT delivered but the session is preserved (L1)', () => {
const onExit = vi.fn();
nextPty = createMockPty();
const s = createSession(CFG, DIMS, 1_000, onExit);
const ws = createMockWs();
attachWs(s, ws);
detachWs(s, ws, 5_000);
ws.sent.length = 0;
(s.pty as MockIPty).emitExit(137);
expect(s.exitedAt).not.toBeNull();
expect(s.exitCode).toBe(137);
expect(ws.sent).toHaveLength(0);
expect(onExit).toHaveBeenCalledWith(s);
});
it('does not send exit to a client whose readyState is not OPEN (M5)', () => {
nextPty = createMockPty();
const s = createSession(CFG, DIMS, 1_000, () => {});
const ws = createMockWs(WS_OPEN);
attachWs(s, ws);
ws.sent.length = 0;
ws.readyState = 3; // CLOSED
(s.pty as MockIPty).emitExit(1);
expect(ws.sent).toHaveLength(0);
});
});
// ── writeInput / setClientDims after exit (L4) + dims idempotence ──────────────
describe('writeInput / setClientDims', () => {
it('writeInput forwards to pty.write while alive', () => {
const s = newSession();
writeInput(s, 'ls\r');
expect((s.pty as MockIPty).writes).toEqual(['ls\r']);
});
it('setClientDims forwards to pty.resize while alive', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
setClientDims(s, ws, 100, 40);
expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 });
});
it('setClientDims is idempotent: same min cols/rows are skipped', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws); // 80x24, equals spawn dims → no resize yet
expect((s.pty as MockIPty).resizes).toHaveLength(0);
setClientDims(s, ws, 90, 24); // changed → applied
setClientDims(s, ws, 90, 24); // unchanged again → skipped
expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]);
});
it('ignores writeInput once the PTY has exited (L4)', () => {
const s = newSession();
(s.pty as MockIPty).emitExit(0);
writeInput(s, 'should be dropped');
expect((s.pty as MockIPty).writes).toHaveLength(0);
});
it('ignores setClientDims once the PTY has exited (L4)', () => {
const s = newSession();
const ws = createMockWs();
attachWs(s, ws);
(s.pty as MockIPty).emitExit(0);
setClientDims(s, ws, 200, 50);
expect((s.pty as MockIPty).resizes).toHaveLength(0);
});
});
// ── kill ──────────────────────────────────────────────────────────────────────
describe('kill', () => {
it('kills the underlying PTY', () => {
const s = newSession();
kill(s);
expect((s.pty as MockIPty).killed).toBe(true);
});
});