feat: W1 batch — config, protocol, ring-buffer, origin, mock-pty (TDD)
Parallel module-builder subagents, disjoint file ownership, shared tree: - T4 src/config.ts — loadConfig+M1 origins (32 tests) - T5 src/protocol.ts — parse/serialize+SESSION_ID_RE+M7 (60 tests) - T6 src/session/ring-buffer.ts — byte-accurate, no UTF-8/ANSI split+M2 (15 tests) - T7 src/http/origin.ts — host+port match, undefined rejected (12 tests) - T3 test/helpers/mock-pty.ts — IPty double w/ emitData/emitExit (9 tests) Verified by orchestrator: tsc --noEmit clean; full suite 128 tests pass.
This commit is contained in:
157
src/config.ts
Normal file
157
src/config.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* src/config.ts — T4: environment config loader
|
||||
*
|
||||
* Reads env vars once at startup, validates them, derives allowedOrigins from
|
||||
* local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
|
||||
* and returns a frozen Config object.
|
||||
*/
|
||||
|
||||
import os from 'node:os'
|
||||
import type { Config, EnvLike } from './types.js'
|
||||
|
||||
// ── constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_PORT = 3000
|
||||
const DEFAULT_BIND_HOST = '0.0.0.0'
|
||||
const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
|
||||
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
|
||||
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
|
||||
const DEFAULT_WS_PATH = '/term'
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function parsePositiveInt(
|
||||
raw: string | undefined,
|
||||
label: string,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (raw === undefined) return fallback
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
throw new Error(
|
||||
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer`,
|
||||
)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function parsePort(raw: string | undefined): number {
|
||||
if (raw === undefined) return DEFAULT_PORT
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
throw new Error(
|
||||
`Invalid config: PORT=${JSON.stringify(raw)} — must be an integer 1–65535`,
|
||||
)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function parseIdleTtl(raw: string | undefined): number {
|
||||
if (raw === undefined) return DEFAULT_IDLE_TTL_SEC * 1000
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
throw new Error(
|
||||
`Invalid config: IDLE_TTL=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
|
||||
)
|
||||
}
|
||||
return n * 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive allowedOrigins from host NIC IPs (M1).
|
||||
*
|
||||
* Rules:
|
||||
* - Enumerate os.networkInterfaces(), pick IPv4 entries.
|
||||
* - Skip internal (loopback) entries — 127.0.0.1 / localhost are added explicitly.
|
||||
* - For every qualifying address, produce both http:// and https:// origins.
|
||||
* - Append ALLOWED_ORIGINS env-var items (comma-separated).
|
||||
* - Deduplicate.
|
||||
* - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
|
||||
*/
|
||||
function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
|
||||
const set = new Set<string>()
|
||||
|
||||
// 1. Always include localhost / loopback
|
||||
const loopbackHosts = ['localhost', '127.0.0.1']
|
||||
for (const host of loopbackHosts) {
|
||||
set.add(`http://${host}:${port}`)
|
||||
set.add(`https://${host}:${port}`)
|
||||
}
|
||||
|
||||
// 2. Enumerate NIC IPv4 addresses — skip internal (loopback already handled above)
|
||||
const ifaces = os.networkInterfaces()
|
||||
for (const ifaceList of Object.values(ifaces)) {
|
||||
if (!ifaceList) continue
|
||||
for (const entry of ifaceList) {
|
||||
// Only IPv4, non-internal entries; never 0.0.0.0
|
||||
if (
|
||||
entry.family === 'IPv4' &&
|
||||
!entry.internal &&
|
||||
entry.address !== '0.0.0.0'
|
||||
) {
|
||||
set.add(`http://${entry.address}:${port}`)
|
||||
set.add(`https://${entry.address}:${port}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings)
|
||||
if (extraEnv) {
|
||||
for (const raw of extraEnv.split(',')) {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed) set.add(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze([...set])
|
||||
}
|
||||
|
||||
// ── loadConfig ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read and validate environment variables; fill in defaults for missing keys.
|
||||
* Invalid values (port out of range, TTL non-integer, etc.) throw immediately
|
||||
* so the server never starts in a misconfigured state (fail-fast).
|
||||
*
|
||||
* The returned Config is Object.freeze'd — immutable after construction.
|
||||
*/
|
||||
export function loadConfig(env: EnvLike): Config {
|
||||
const port = parsePort(env['PORT'])
|
||||
|
||||
const bindHost = env['BIND_HOST'] ?? DEFAULT_BIND_HOST
|
||||
|
||||
const shellPath =
|
||||
env['SHELL_PATH'] ?? (process.env['SHELL'] as string | undefined) ?? '/bin/zsh'
|
||||
|
||||
const homeDir = os.homedir()
|
||||
|
||||
const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])
|
||||
|
||||
const scrollbackBytes = parsePositiveInt(
|
||||
env['SCROLLBACK_BYTES'],
|
||||
'SCROLLBACK_BYTES',
|
||||
DEFAULT_SCROLLBACK_BYTES,
|
||||
)
|
||||
|
||||
const maxPayloadBytes = parsePositiveInt(
|
||||
env['MAX_PAYLOAD_BYTES'],
|
||||
'MAX_PAYLOAD_BYTES',
|
||||
DEFAULT_MAX_PAYLOAD_BYTES,
|
||||
)
|
||||
|
||||
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
|
||||
|
||||
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
|
||||
|
||||
return Object.freeze({
|
||||
port,
|
||||
bindHost,
|
||||
shellPath,
|
||||
homeDir,
|
||||
idleTtlMs,
|
||||
scrollbackBytes,
|
||||
maxPayloadBytes,
|
||||
wsPath,
|
||||
allowedOrigins,
|
||||
} satisfies Config)
|
||||
}
|
||||
53
src/http/origin.ts
Normal file
53
src/http/origin.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* src/http/origin.ts — Origin whitelist check (T7)
|
||||
*
|
||||
* Pure function, no side effects. Security-critical:
|
||||
* this is the CSWSH (Cross-Site WebSocket Hijacking) defence described in
|
||||
* TECH_DOC §7 and ARCHITECTURE §3.3.
|
||||
*
|
||||
* Strategy:
|
||||
* - Parse the incoming Origin as a URL.
|
||||
* - Compare scheme + hostname + port against every entry in the allowed list.
|
||||
* - Both host AND port must match (ARCHITECTURE §3.3: "host+port 双匹配").
|
||||
* - undefined Origin (non-browser clients: curl, scripts) → reject by default.
|
||||
* This is the single central policy point — no scattered `if` checks elsewhere.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Returns true iff `origin` exactly matches one of the `allowed` origins.
|
||||
*
|
||||
* Matching is scheme + hostname + port (all three must agree).
|
||||
* `undefined` → false (non-browser client; default-deny, no scattered if-chains).
|
||||
*/
|
||||
export function isOriginAllowed(
|
||||
origin: string | undefined,
|
||||
allowed: readonly string[],
|
||||
): boolean {
|
||||
// Central policy: undefined Origin is rejected. No other code path handles this.
|
||||
if (origin === undefined || origin === '') {
|
||||
return false
|
||||
}
|
||||
|
||||
let incomingUrl: URL
|
||||
try {
|
||||
incomingUrl = new URL(origin)
|
||||
} catch {
|
||||
// Malformed Origin value → reject.
|
||||
return false
|
||||
}
|
||||
|
||||
return allowed.some((entry) => {
|
||||
let allowedUrl: URL
|
||||
try {
|
||||
allowedUrl = new URL(entry)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
incomingUrl.protocol === allowedUrl.protocol &&
|
||||
incomingUrl.hostname === allowedUrl.hostname &&
|
||||
incomingUrl.port === allowedUrl.port
|
||||
)
|
||||
})
|
||||
}
|
||||
158
src/protocol.ts
Normal file
158
src/protocol.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* src/protocol.ts — WebSocket message codec + validation (T5, pure functions)
|
||||
*
|
||||
* Invariant #3 (ARCHITECTURE §8): parseClientMessage NEVER throws.
|
||||
* All invalid input flows through ParseResult.ok=false.
|
||||
*
|
||||
* Imports only from src/types.ts (frozen shared contract, T2).
|
||||
*/
|
||||
|
||||
import type { ClientMessage, ServerMessage, ParseResult } from './types.js'
|
||||
|
||||
// ─── SESSION_ID_RE (M7) ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* UUID v4 pattern.
|
||||
* - 8-4-4-4-12 hex groups
|
||||
* - version nibble must be '4'
|
||||
* - variant nibble must be 8, 9, a, or b (10xx in binary)
|
||||
*
|
||||
* M7: strings like "abc123" must be rejected; crypto.randomUUID() values must pass.
|
||||
*/
|
||||
export const SESSION_ID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
// ─── Type whitelist ───────────────────────────────────────────────────────────
|
||||
|
||||
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize'])
|
||||
|
||||
// ─── parseClientMessage ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse and validate a raw WS text frame as a ClientMessage.
|
||||
*
|
||||
* Steps (ARCHITECTURE §3.2 / TECH_DOC §5.3):
|
||||
* 1. JSON.parse — bad JSON → ok=false
|
||||
* 2. Must be a plain object (not null, array, etc.)
|
||||
* 3. type must be in whitelist {'attach','input','resize'}
|
||||
* 4. Per-type field validation:
|
||||
* - resize: cols and rows are integers in [1, 1000]
|
||||
* - input: data is a string (passed through verbatim, invariant #9)
|
||||
* - attach: sessionId is null OR a string matching SESSION_ID_RE (M7)
|
||||
*
|
||||
* NEVER throws — all errors return { ok: false, error: <message> }.
|
||||
*/
|
||||
export function parseClientMessage(raw: string): ParseResult {
|
||||
// Step 1: JSON parse
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return { ok: false, error: 'invalid JSON' }
|
||||
}
|
||||
|
||||
// Step 2: Must be a plain object
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'message must be a JSON object' }
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
|
||||
// Step 3: type whitelist
|
||||
const type = obj['type']
|
||||
if (typeof type !== 'string' || !ALLOWED_TYPES.has(type)) {
|
||||
return { ok: false, error: `unknown or missing type: ${String(type)}` }
|
||||
}
|
||||
|
||||
// Step 4: per-type validation
|
||||
if (type === 'resize') {
|
||||
return validateResize(obj)
|
||||
}
|
||||
|
||||
if (type === 'input') {
|
||||
return validateInput(obj)
|
||||
}
|
||||
|
||||
// type === 'attach'
|
||||
return validateAttach(obj)
|
||||
}
|
||||
|
||||
// ─── Per-type validators ──────────────────────────────────────────────────────
|
||||
|
||||
function validateResize(obj: Record<string, unknown>): ParseResult {
|
||||
const { cols, rows } = obj
|
||||
|
||||
if (!isValidDimension(cols)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `resize.cols must be an integer in [1, 1000], got ${String(cols)}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidDimension(rows)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `resize.rows must be an integer in [1, 1000], got ${String(rows)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: { type: 'resize', cols: cols as number, rows: rows as number },
|
||||
}
|
||||
}
|
||||
|
||||
function isValidDimension(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000
|
||||
}
|
||||
|
||||
function validateInput(obj: Record<string, unknown>): ParseResult {
|
||||
const { data } = obj
|
||||
|
||||
if (typeof data !== 'string') {
|
||||
return { ok: false, error: `input.data must be a string, got ${typeof data}` }
|
||||
}
|
||||
|
||||
// Invariant #9: data passed through verbatim, no content filtering
|
||||
return { ok: true, message: { type: 'input', data } }
|
||||
}
|
||||
|
||||
function validateAttach(obj: Record<string, unknown>): ParseResult {
|
||||
const { sessionId } = obj
|
||||
|
||||
// sessionId must be explicitly present as null or a UUID v4 string (M7)
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, 'sessionId')) {
|
||||
return { ok: false, error: 'attach.sessionId field is required' }
|
||||
}
|
||||
|
||||
if (sessionId === null) {
|
||||
return { ok: true, message: { type: 'attach', sessionId: null } }
|
||||
}
|
||||
|
||||
if (typeof sessionId !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
error: `attach.sessionId must be null or a UUID v4 string, got ${typeof sessionId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!SESSION_ID_RE.test(sessionId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `attach.sessionId is not a valid UUID v4: ${sessionId}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, message: { type: 'attach', sessionId } }
|
||||
}
|
||||
|
||||
// ─── serialize ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serialize a ServerMessage to a JSON text frame.
|
||||
*
|
||||
* Internal trusted data — JSON.stringify directly (ARCHITECTURE §3.2).
|
||||
*/
|
||||
export function serialize(msg: ServerMessage): string {
|
||||
return JSON.stringify(msg)
|
||||
}
|
||||
65
src/session/ring-buffer.ts
Normal file
65
src/session/ring-buffer.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* src/session/ring-buffer.ts (T6) — output scrollback for reconnect replay.
|
||||
*
|
||||
* M2 (the easy-to-get-wrong part). PTY output is an opaque ANSI byte stream; we
|
||||
* must NEVER cut a multi-byte UTF-8 codepoint or an ANSI escape sequence in half,
|
||||
* or the first screen on reconnect renders as garbage — exactly the vibe-coding
|
||||
* "refresh and the Claude TUI is still there" path. So:
|
||||
*
|
||||
* - capacity is measured in REAL UTF-8 bytes (Buffer.byteLength), because
|
||||
* `string.length` counts UTF-16 code units, not bytes;
|
||||
* - eviction happens on whole append-chunk boundaries (drop the oldest chunk),
|
||||
* so no chunk is ever sliced (the trade-off is the live size floats a little
|
||||
* above/below maxBytes);
|
||||
* - snapshot() prepends a soft-reset `\x1b[0m` so that even if the oldest
|
||||
* surviving chunk started mid-attribute, the terminal is reset first.
|
||||
*
|
||||
* Implements the frozen `RingBuffer` contract in src/types.ts (read-only import).
|
||||
*/
|
||||
|
||||
import type { RingBuffer } from '../types.js';
|
||||
|
||||
const SOFT_RESET = '\x1b[0m';
|
||||
|
||||
interface Chunk {
|
||||
readonly text: string;
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ring buffer holding at most ~`maxBytes` UTF-8 bytes of recent output.
|
||||
*
|
||||
* @param maxBytes capacity in real UTF-8 bytes (e.g. `cfg.scrollbackBytes`).
|
||||
*/
|
||||
export function createRingBuffer(maxBytes: number): RingBuffer {
|
||||
// Immutable-update style: each append produces a new chunk list / total rather
|
||||
// than mutating shared state in place (per coding-style immutability rule).
|
||||
let chunks: readonly Chunk[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
function append(chunk: string): void {
|
||||
if (chunk.length === 0) return; // nothing to store; keeps accounting clean
|
||||
|
||||
const bytes = Buffer.byteLength(chunk, 'utf8');
|
||||
let nextChunks: Chunk[] = [...chunks, { text: chunk, bytes }];
|
||||
let nextTotal = totalBytes + bytes;
|
||||
|
||||
// Evict whole oldest chunks until we fit — but never drop the only remaining
|
||||
// chunk, so a lone chunk larger than capacity is kept intact (cannot split).
|
||||
while (nextTotal > maxBytes && nextChunks.length > 1) {
|
||||
const oldest = nextChunks[0]!;
|
||||
nextChunks = nextChunks.slice(1);
|
||||
nextTotal -= oldest.bytes;
|
||||
}
|
||||
|
||||
chunks = nextChunks;
|
||||
totalBytes = nextTotal;
|
||||
}
|
||||
|
||||
function snapshot(): string {
|
||||
// Soft-reset first (M2 safety net), then the current buffer verbatim.
|
||||
return SOFT_RESET + chunks.map((c) => c.text).join('');
|
||||
}
|
||||
|
||||
return { append, snapshot };
|
||||
}
|
||||
289
test/config.test.ts
Normal file
289
test/config.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* test/config.test.ts — T4: config module unit tests (TDD)
|
||||
*
|
||||
* Strategy:
|
||||
* - Mock `node:os` networkInterfaces to inject fake NICs (M1)
|
||||
* - Assert defaults, env overrides, validation errors, allowedOrigins shape
|
||||
*
|
||||
* vi.mock is hoisted to the top of the file by vitest — the factory runs before
|
||||
* any import, so config.ts will receive the mocked os module.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { NetworkInterfaceInfo } from 'node:os'
|
||||
|
||||
// ── mock node:os ─────────────────────────────────────────────────────────────
|
||||
// vi.mock is hoisted by vitest, so this factory runs before config.ts is imported.
|
||||
|
||||
const mockNetworkInterfaces = vi.fn<[], ReturnType<typeof import('node:os').networkInterfaces>>()
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
|
||||
const mockHomedir = vi.fn<[], string>()
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>()
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual.default,
|
||||
homedir: () => mockHomedir(),
|
||||
networkInterfaces: () => mockNetworkInterfaces(),
|
||||
},
|
||||
homedir: () => mockHomedir(),
|
||||
networkInterfaces: () => mockNetworkInterfaces(),
|
||||
}
|
||||
})
|
||||
|
||||
// Import config AFTER mock is set up (dynamic import ensures mock applies)
|
||||
const { loadConfig } = await import('../src/config.js')
|
||||
|
||||
// Helper: build a fake IPv4 NIC entry
|
||||
function makeNic(address: string, internal: boolean): NetworkInterfaceInfo {
|
||||
return {
|
||||
address,
|
||||
netmask: '255.255.255.0',
|
||||
family: 'IPv4',
|
||||
mac: '00:00:00:00:00:00',
|
||||
internal,
|
||||
cidr: `${address}/24`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default values ────────────────────────────────────────────────────────────
|
||||
describe('loadConfig — defaults', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('uses PORT 3000', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.port).toBe(3000)
|
||||
})
|
||||
|
||||
it('uses BIND_HOST 0.0.0.0', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.bindHost).toBe('0.0.0.0')
|
||||
})
|
||||
|
||||
it('uses IDLE_TTL 24h in ms', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.idleTtlMs).toBe(24 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
it('uses SCROLLBACK_BYTES 2MB', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.scrollbackBytes).toBe(2 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('uses MAX_PAYLOAD_BYTES 1MB', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.maxPayloadBytes).toBe(1 * 1024 * 1024)
|
||||
})
|
||||
|
||||
it('uses WS_PATH /term', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.wsPath).toBe('/term')
|
||||
})
|
||||
|
||||
it('uses homeDir from os.homedir()', () => {
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.homeDir).toBe('/home/testuser')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Env overrides ─────────────────────────────────────────────────────────────
|
||||
describe('loadConfig — env overrides', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('reads PORT from env', () => {
|
||||
const cfg = loadConfig({ PORT: '8080' })
|
||||
expect(cfg.port).toBe(8080)
|
||||
})
|
||||
|
||||
it('reads BIND_HOST from env', () => {
|
||||
const cfg = loadConfig({ BIND_HOST: '127.0.0.1' })
|
||||
expect(cfg.bindHost).toBe('127.0.0.1')
|
||||
})
|
||||
|
||||
it('reads SHELL_PATH from env', () => {
|
||||
const cfg = loadConfig({ SHELL_PATH: '/bin/bash' })
|
||||
expect(cfg.shellPath).toBe('/bin/bash')
|
||||
})
|
||||
|
||||
it('reads IDLE_TTL (seconds) and converts to ms', () => {
|
||||
const cfg = loadConfig({ IDLE_TTL: '3600' })
|
||||
expect(cfg.idleTtlMs).toBe(3600 * 1000)
|
||||
})
|
||||
|
||||
it('reads SCROLLBACK_BYTES from env', () => {
|
||||
const cfg = loadConfig({ SCROLLBACK_BYTES: '1048576' })
|
||||
expect(cfg.scrollbackBytes).toBe(1048576)
|
||||
})
|
||||
|
||||
it('reads MAX_PAYLOAD_BYTES from env', () => {
|
||||
const cfg = loadConfig({ MAX_PAYLOAD_BYTES: '524288' })
|
||||
expect(cfg.maxPayloadBytes).toBe(524288)
|
||||
})
|
||||
|
||||
it('reads WS_PATH from env', () => {
|
||||
const cfg = loadConfig({ WS_PATH: '/ws' })
|
||||
expect(cfg.wsPath).toBe('/ws')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Validation errors ─────────────────────────────────────────────────────────
|
||||
describe('loadConfig — invalid values throw', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
})
|
||||
|
||||
it('throws for PORT below 1', () => {
|
||||
expect(() => loadConfig({ PORT: '0' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws for PORT above 65535', () => {
|
||||
expect(() => loadConfig({ PORT: '65536' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws for PORT that is not a number', () => {
|
||||
expect(() => loadConfig({ PORT: 'abc' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws for IDLE_TTL that is not a number', () => {
|
||||
expect(() => loadConfig({ IDLE_TTL: 'abc' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws for IDLE_TTL that is negative', () => {
|
||||
expect(() => loadConfig({ IDLE_TTL: '-1' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws for SCROLLBACK_BYTES that is not a number', () => {
|
||||
expect(() => loadConfig({ SCROLLBACK_BYTES: 'nope' })).toThrow()
|
||||
})
|
||||
|
||||
it('throws for MAX_PAYLOAD_BYTES that is not a number', () => {
|
||||
expect(() => loadConfig({ MAX_PAYLOAD_BYTES: 'nope' })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ── allowedOrigins — M1 core tests ──────────────────────────────────────────
|
||||
describe('loadConfig — allowedOrigins (M1)', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('always includes localhost variants', () => {
|
||||
const cfg = loadConfig({})
|
||||
const origins = cfg.allowedOrigins
|
||||
expect(origins).toContain('http://localhost:3000')
|
||||
expect(origins).toContain('http://127.0.0.1:3000')
|
||||
})
|
||||
|
||||
it('never contains 0.0.0.0 (M1: bindHost must not bleed in)', () => {
|
||||
// even if BIND_HOST is 0.0.0.0 (default)
|
||||
const cfg = loadConfig({ BIND_HOST: '0.0.0.0' })
|
||||
const hasWildcard = cfg.allowedOrigins.some((o) => o.includes('0.0.0.0'))
|
||||
expect(hasWildcard).toBe(false)
|
||||
})
|
||||
|
||||
it('includes injected LAN NIC IPv4', () => {
|
||||
mockNetworkInterfaces.mockReturnValue({
|
||||
en0: [makeNic('192.168.1.100', false)],
|
||||
})
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.allowedOrigins).toContain('http://192.168.1.100:3000')
|
||||
})
|
||||
|
||||
it('includes https:// variant for injected NIC', () => {
|
||||
mockNetworkInterfaces.mockReturnValue({
|
||||
en0: [makeNic('10.0.0.5', false)],
|
||||
})
|
||||
const cfg = loadConfig({})
|
||||
expect(cfg.allowedOrigins).toContain('https://10.0.0.5:3000')
|
||||
})
|
||||
|
||||
it('excludes internal (loopback) NICs from LAN list (127.x added separately)', () => {
|
||||
mockNetworkInterfaces.mockReturnValue({
|
||||
lo0: [makeNic('127.0.0.1', true)],
|
||||
})
|
||||
const cfg = loadConfig({})
|
||||
// 127.0.0.1 added via loopback constants; no duplicates beyond http+https
|
||||
const count127 = cfg.allowedOrigins.filter((o) =>
|
||||
o.includes('127.0.0.1'),
|
||||
).length
|
||||
// http and https = 2
|
||||
expect(count127).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('skips IPv6 entries on NICs', () => {
|
||||
mockNetworkInterfaces.mockReturnValue({
|
||||
en0: [
|
||||
{
|
||||
address: 'fe80::1',
|
||||
netmask: 'ffff:ffff:ffff:ffff::',
|
||||
family: 'IPv6',
|
||||
mac: '00:00:00:00:00:00',
|
||||
internal: false,
|
||||
cidr: 'fe80::1/64',
|
||||
scopeid: 0,
|
||||
} as NetworkInterfaceInfo,
|
||||
makeNic('192.168.1.50', false),
|
||||
],
|
||||
})
|
||||
const cfg = loadConfig({})
|
||||
const hasIPv6 = cfg.allowedOrigins.some((o) => o.includes('fe80'))
|
||||
expect(hasIPv6).toBe(false)
|
||||
expect(cfg.allowedOrigins).toContain('http://192.168.1.50:3000')
|
||||
})
|
||||
|
||||
it('merges ALLOWED_ORIGINS env var into the list', () => {
|
||||
const extra = 'http://mymac.local:3000,https://mymac.local:3000'
|
||||
const cfg = loadConfig({ ALLOWED_ORIGINS: extra })
|
||||
expect(cfg.allowedOrigins).toContain('http://mymac.local:3000')
|
||||
expect(cfg.allowedOrigins).toContain('https://mymac.local:3000')
|
||||
})
|
||||
|
||||
it('respects custom PORT when building origins', () => {
|
||||
mockNetworkInterfaces.mockReturnValue({
|
||||
en0: [makeNic('172.16.0.1', false)],
|
||||
})
|
||||
const cfg = loadConfig({ PORT: '4000' })
|
||||
expect(cfg.allowedOrigins).toContain('http://172.16.0.1:4000')
|
||||
expect(cfg.allowedOrigins).toContain('https://172.16.0.1:4000')
|
||||
})
|
||||
|
||||
it('deduplicates origins when ALLOWED_ORIGINS overlaps with derived set', () => {
|
||||
const cfg = loadConfig({
|
||||
ALLOWED_ORIGINS: 'http://localhost:3000,http://localhost:3000',
|
||||
})
|
||||
const localhostCount = cfg.allowedOrigins.filter(
|
||||
(o) => o === 'http://localhost:3000',
|
||||
).length
|
||||
expect(localhostCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Immutability ──────────────────────────────────────────────────────────────
|
||||
describe('loadConfig — returned object is frozen', () => {
|
||||
beforeEach(() => {
|
||||
mockNetworkInterfaces.mockReturnValue({})
|
||||
mockHomedir.mockReturnValue('/home/testuser')
|
||||
})
|
||||
|
||||
it('result is Object.frozen', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(Object.isFrozen(cfg)).toBe(true)
|
||||
})
|
||||
|
||||
it('allowedOrigins array is frozen', () => {
|
||||
const cfg = loadConfig({})
|
||||
expect(Object.isFrozen(cfg.allowedOrigins)).toBe(true)
|
||||
})
|
||||
})
|
||||
119
test/helpers/mock-pty.test.ts
Normal file
119
test/helpers/mock-pty.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createMockPty } from './mock-pty'
|
||||
|
||||
describe('createMockPty', () => {
|
||||
it('emitData triggers all onData listeners', () => {
|
||||
const pty = createMockPty()
|
||||
const listener1Results: string[] = []
|
||||
const listener2Results: string[] = []
|
||||
|
||||
pty.onData((data) => {
|
||||
listener1Results.push(data)
|
||||
})
|
||||
pty.onData((data) => {
|
||||
listener2Results.push(data)
|
||||
})
|
||||
|
||||
pty.emitData('hello')
|
||||
pty.emitData(' world')
|
||||
|
||||
expect(listener1Results).toEqual(['hello', ' world'])
|
||||
expect(listener2Results).toEqual(['hello', ' world'])
|
||||
})
|
||||
|
||||
it('onData returns a disposable that unsubscribes', () => {
|
||||
const pty = createMockPty()
|
||||
const results: string[] = []
|
||||
|
||||
const disposable = pty.onData((data) => {
|
||||
results.push(data)
|
||||
})
|
||||
|
||||
pty.emitData('first')
|
||||
disposable.dispose()
|
||||
pty.emitData('second')
|
||||
|
||||
expect(results).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('records write calls', () => {
|
||||
const pty = createMockPty()
|
||||
|
||||
pty.write('input 1')
|
||||
pty.write('input 2')
|
||||
|
||||
expect(pty.writes).toEqual(['input 1', 'input 2'])
|
||||
})
|
||||
|
||||
it('records resize calls', () => {
|
||||
const pty = createMockPty()
|
||||
|
||||
pty.resize(100, 50)
|
||||
pty.resize(120, 60)
|
||||
|
||||
expect(pty.resizes).toEqual([
|
||||
{ cols: 100, rows: 50 },
|
||||
{ cols: 120, rows: 60 },
|
||||
])
|
||||
})
|
||||
|
||||
it('emitExit triggers onExit listeners', () => {
|
||||
const pty = createMockPty()
|
||||
const results: Array<{ exitCode: number; signal?: number }> = []
|
||||
|
||||
pty.onExit((event) => {
|
||||
results.push(event)
|
||||
})
|
||||
|
||||
pty.emitExit(0)
|
||||
pty.emitExit(1, 9)
|
||||
|
||||
expect(results).toEqual([
|
||||
{ exitCode: 0 },
|
||||
{ exitCode: 1, signal: 9 },
|
||||
])
|
||||
})
|
||||
|
||||
it('onExit returns a disposable that unsubscribes', () => {
|
||||
const pty = createMockPty()
|
||||
const results: Array<{ exitCode: number; signal?: number }> = []
|
||||
|
||||
const disposable = pty.onExit((event) => {
|
||||
results.push(event)
|
||||
})
|
||||
|
||||
pty.emitExit(0)
|
||||
disposable.dispose()
|
||||
pty.emitExit(1)
|
||||
|
||||
expect(results).toEqual([{ exitCode: 0 }])
|
||||
})
|
||||
|
||||
it('kill marks killed flag', () => {
|
||||
const pty = createMockPty()
|
||||
|
||||
expect(pty.killed).toBe(false)
|
||||
pty.kill()
|
||||
expect(pty.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('kill can accept an optional signal', () => {
|
||||
const pty = createMockPty()
|
||||
|
||||
pty.kill('SIGTERM')
|
||||
expect(pty.killed).toBe(true)
|
||||
})
|
||||
|
||||
it('has IPty interface properties', () => {
|
||||
const pty = createMockPty()
|
||||
|
||||
expect(typeof pty.pid).toBe('number')
|
||||
expect(typeof pty.cols).toBe('number')
|
||||
expect(typeof pty.rows).toBe('number')
|
||||
expect(typeof pty.onData).toBe('function')
|
||||
expect(typeof pty.onExit).toBe('function')
|
||||
expect(typeof pty.write).toBe('function')
|
||||
expect(typeof pty.resize).toBe('function')
|
||||
expect(typeof pty.kill).toBe('function')
|
||||
})
|
||||
})
|
||||
123
test/helpers/mock-pty.ts
Normal file
123
test/helpers/mock-pty.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { IPty, PtyEvent, IDisposable } from '../../src/types'
|
||||
|
||||
/**
|
||||
* Mock IPty for testing.
|
||||
*
|
||||
* Implements the full IPty interface with manual trigger methods
|
||||
* (emitData, emitExit) and recording of calls (writes, resizes, killed).
|
||||
*
|
||||
* All properties are mutable for testing convenience.
|
||||
*/
|
||||
export interface MockIPty extends IPty {
|
||||
/** Recorded write() calls. */
|
||||
writes: string[]
|
||||
/** Recorded resize() calls. */
|
||||
resizes: Array<{ cols: number; rows: number }>
|
||||
/** Whether kill() was called. */
|
||||
killed: boolean
|
||||
/** Manually trigger onData listeners. */
|
||||
emitData(data: string): void
|
||||
/** Manually trigger onExit listeners. */
|
||||
emitExit(exitCode: number, signal?: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock PTY for testing.
|
||||
*
|
||||
* pid, cols, rows are fixed but can be overridden via options for specific tests.
|
||||
*/
|
||||
export function createMockPty(options?: {
|
||||
pid?: number
|
||||
cols?: number
|
||||
rows?: number
|
||||
}): MockIPty {
|
||||
const pid = options?.pid ?? 12345
|
||||
let cols = options?.cols ?? 80
|
||||
let rows = options?.rows ?? 24
|
||||
|
||||
const writes: string[] = []
|
||||
const resizes: Array<{ cols: number; rows: number }> = []
|
||||
let killed = false
|
||||
|
||||
// Event listeners: store arrays of listeners for each event type
|
||||
const dataListeners: Array<(data: string) => void> = []
|
||||
const exitListeners: Array<(event: { exitCode: number; signal?: number }) => void> = []
|
||||
|
||||
const onData: PtyEvent<string> = (listener: (e: string) => void): IDisposable => {
|
||||
dataListeners.push(listener)
|
||||
return {
|
||||
dispose() {
|
||||
const index = dataListeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
dataListeners.splice(index, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const onExit: PtyEvent<{ exitCode: number; signal?: number }> = (
|
||||
listener: (e: { exitCode: number; signal?: number }) => void,
|
||||
): IDisposable => {
|
||||
exitListeners.push(listener)
|
||||
return {
|
||||
dispose() {
|
||||
const index = exitListeners.indexOf(listener)
|
||||
if (index !== -1) {
|
||||
exitListeners.splice(index, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const write = (data: string): void => {
|
||||
writes.push(data)
|
||||
}
|
||||
|
||||
const resize = (newCols: number, newRows: number): void => {
|
||||
cols = newCols
|
||||
rows = newRows
|
||||
resizes.push({ cols: newCols, rows: newRows })
|
||||
}
|
||||
|
||||
const kill = (signal?: string): void => {
|
||||
killed = true
|
||||
}
|
||||
|
||||
const emitData = (data: string): void => {
|
||||
for (const listener of dataListeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
|
||||
const emitExit = (exitCode: number, signal?: number): void => {
|
||||
const event: { exitCode: number; signal?: number } = { exitCode }
|
||||
if (signal !== undefined) {
|
||||
event.signal = signal
|
||||
}
|
||||
for (const listener of exitListeners) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pid,
|
||||
get cols() {
|
||||
return cols
|
||||
},
|
||||
get rows() {
|
||||
return rows
|
||||
},
|
||||
onData,
|
||||
onExit,
|
||||
write,
|
||||
resize,
|
||||
kill,
|
||||
writes,
|
||||
resizes,
|
||||
get killed() {
|
||||
return killed
|
||||
},
|
||||
emitData,
|
||||
emitExit,
|
||||
}
|
||||
}
|
||||
78
test/origin.test.ts
Normal file
78
test/origin.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* test/origin.test.ts — TDD for src/http/origin.ts (T7)
|
||||
*
|
||||
* Covers ARCHITECTURE §3.3 and TECH_DOC §7 (CSWSH defence):
|
||||
* - whitelist hit → allow
|
||||
* - wrong hostname → deny
|
||||
* - wrong port → deny
|
||||
* - undefined Origin (non-browser client) → deny
|
||||
* - evil.com → deny
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isOriginAllowed } from '../src/http/origin.js'
|
||||
|
||||
const ALLOWED: readonly string[] = [
|
||||
'http://localhost:3000',
|
||||
'http://192.168.1.42:3000',
|
||||
'http://my-host.local:3000',
|
||||
]
|
||||
|
||||
describe('isOriginAllowed', () => {
|
||||
// ── whitelist hits ────────────────────────────────────────────────
|
||||
it('allows an exact localhost origin', () => {
|
||||
expect(isOriginAllowed('http://localhost:3000', ALLOWED)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows an exact LAN IP origin', () => {
|
||||
expect(isOriginAllowed('http://192.168.1.42:3000', ALLOWED)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows an exact hostname.local origin', () => {
|
||||
expect(isOriginAllowed('http://my-host.local:3000', ALLOWED)).toBe(true)
|
||||
})
|
||||
|
||||
// ── hostname mismatch ─────────────────────────────────────────────
|
||||
it('rejects when the hostname does not match (evil.com)', () => {
|
||||
expect(isOriginAllowed('http://evil.com:3000', ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a subdomain of an allowed hostname', () => {
|
||||
expect(isOriginAllowed('http://sub.localhost:3000', ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a hostname that merely contains an allowed hostname', () => {
|
||||
// "evillocalhost:3000" should NOT match "localhost:3000"
|
||||
expect(isOriginAllowed('http://evillocalhost:3000', ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
// ── port mismatch ─────────────────────────────────────────────────
|
||||
it('rejects when the port does not match (correct host, wrong port)', () => {
|
||||
expect(isOriginAllowed('http://localhost:9999', ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when port is absent but allowed origin has a port', () => {
|
||||
// Browser sends Origin without port only when it is the default (80/443).
|
||||
// Our server runs on 3000, so no-port origins must not sneak in.
|
||||
expect(isOriginAllowed('http://localhost', ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
// ── undefined Origin (non-browser clients: curl, scripts) ─────────
|
||||
it('rejects undefined Origin by default', () => {
|
||||
expect(isOriginAllowed(undefined, ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
// ── empty / malformed origins ─────────────────────────────────────
|
||||
it('rejects an empty string origin', () => {
|
||||
expect(isOriginAllowed('', ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a completely different scheme (https vs http in allowed)', () => {
|
||||
// Allowed list uses http://; https:// should not silently match.
|
||||
expect(isOriginAllowed('https://localhost:3000', ALLOWED)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when allowed list is empty', () => {
|
||||
expect(isOriginAllowed('http://localhost:3000', [])).toBe(false)
|
||||
})
|
||||
})
|
||||
344
test/protocol.test.ts
Normal file
344
test/protocol.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* test/protocol.test.ts — TDD tests for src/protocol.ts (T5)
|
||||
*
|
||||
* Coverage:
|
||||
* - SESSION_ID_RE: accepts valid UUID v4, rejects "abc123" and other non-UUIDs
|
||||
* - parseClientMessage: all valid paths + every invalid branch (never throws)
|
||||
* - serialize: all three ServerMessage types
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { SESSION_ID_RE, parseClientMessage, serialize } from '../src/protocol'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const VALID_UUID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
|
||||
|
||||
// ─── SESSION_ID_RE ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SESSION_ID_RE', () => {
|
||||
it('accepts a valid UUID v4', () => {
|
||||
expect(SESSION_ID_RE.test(VALID_UUID)).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts UUID v4 with uppercase hex digits', () => {
|
||||
expect(SESSION_ID_RE.test('F47AC10B-58CC-4372-A567-0E02B2C3D479')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts another valid UUID v4 (variant bits 8, version 4)', () => {
|
||||
// 41d4 → version nibble = 4 ✓; 8716 → variant nibble = 8 ✓
|
||||
expect(SESSION_ID_RE.test('550e8400-e29b-41d4-8716-446655440000')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects "abc123" (M7)', () => {
|
||||
expect(SESSION_ID_RE.test('abc123')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(SESSION_ID_RE.test('')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects UUID v1 (version bit = 1)', () => {
|
||||
expect(SESSION_ID_RE.test('550e8400-e29b-11d4-a716-446655440000')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects UUID v4 with wrong variant (not 8/9/a/b)', () => {
|
||||
// variant bits should be 10xx → 8,9,a,b; 'c' is 1100 = wrong
|
||||
expect(SESSION_ID_RE.test('f47ac10b-58cc-4372-c567-0e02b2c3d479')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── parseClientMessage — valid paths ─────────────────────────────────────────
|
||||
|
||||
describe('parseClientMessage — valid messages', () => {
|
||||
it('parses a valid attach message with null sessionId', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: null }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual({ type: 'attach', sessionId: null })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses a valid attach message with a UUID v4 sessionId', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: VALID_UUID }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual({ type: 'attach', sessionId: VALID_UUID })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses a valid input message', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'input', data: 'ls -la\r' }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual({ type: 'input', data: 'ls -la\r' })
|
||||
}
|
||||
})
|
||||
|
||||
it('passes input.data through verbatim (raw keyboard bytes)', () => {
|
||||
const raw = '\x1b[A\x03\r\t\x1b[Z'
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'input', data: raw }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok && result.message.type === 'input') {
|
||||
expect(result.message.data).toBe(raw)
|
||||
}
|
||||
})
|
||||
|
||||
it('parses a valid resize message with boundary values (1)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1, rows: 1 }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual({ type: 'resize', cols: 1, rows: 1 })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses a valid resize message with boundary values (1000)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1000, rows: 1000 }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual({ type: 'resize', cols: 1000, rows: 1000 })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses a valid resize message with typical values', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 120, rows: 40 }))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── parseClientMessage — invalid / error paths ───────────────────────────────
|
||||
|
||||
describe('parseClientMessage — invalid input (ok=false, never throws)', () => {
|
||||
// Bad JSON
|
||||
it('returns ok=false for bad JSON', () => {
|
||||
const result = parseClientMessage('not json at all')
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for empty string', () => {
|
||||
const result = parseClientMessage('')
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for JSON null', () => {
|
||||
const result = parseClientMessage('null')
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for JSON array', () => {
|
||||
const result = parseClientMessage('[]')
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for JSON number', () => {
|
||||
const result = parseClientMessage('42')
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
// Unknown type
|
||||
it('returns ok=false for unknown type "ping"', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'ping' }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for missing type field', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ data: 'hello' }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for type=number', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 42 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
// resize: cols out of range
|
||||
it('returns ok=false for resize cols=0 (below range)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 0, rows: 40 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for resize cols=1001 (above range)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1001, rows: 40 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for resize rows=0 (below range)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 0 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for resize rows=1001 (above range)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 1001 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
// resize: non-integer cols/rows
|
||||
it('returns ok=false for resize cols=80.5 (non-integer)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80.5, rows: 40 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for resize rows=24.9 (non-integer)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 24.9 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for resize cols="80" (string, not number)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: '80', rows: 40 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for resize missing cols', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', rows: 40 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for resize missing rows', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
// input.data non-string
|
||||
it('returns ok=false when input.data is a number', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'input', data: 42 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false when input.data is null', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'input', data: null }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false when input.data is missing', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'input' }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false when input.data is an object', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'input', data: {} }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
// attach.sessionId: "abc123" rejected (M7)
|
||||
it('returns ok=false for attach.sessionId="abc123" (M7)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 'abc123' }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for attach.sessionId="" (empty string, not null or UUID)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: '' }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for attach.sessionId=42 (not string or null)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 42 }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false for attach.sessionId=undefined (missing field)', () => {
|
||||
// JSON.stringify omits undefined keys → { type: 'attach' } — no sessionId property
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'attach' }))
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
// attach.sessionId: valid UUID passes (M7 positive case)
|
||||
it('returns ok=true for attach.sessionId = valid UUID v4 (M7 positive)', () => {
|
||||
const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: VALID_UUID }))
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── parseClientMessage — fuzzing: must never throw ───────────────────────────
|
||||
|
||||
describe('parseClientMessage — fuzzy inputs never throw', () => {
|
||||
const fuzzInputs: unknown[] = [
|
||||
undefined,
|
||||
null,
|
||||
0,
|
||||
true,
|
||||
{},
|
||||
[],
|
||||
'\x00\x01\xFF',
|
||||
'{"type":"attach","sessionId":{"$ne":null}}',
|
||||
'{"type":"resize","cols":null,"rows":null}',
|
||||
'{"type":"input","data":["a","b"]}',
|
||||
'{"type":"attach","sessionId":' + 'x'.repeat(10000) + '}',
|
||||
// valid JSON but with extra fields
|
||||
'{"type":"resize","cols":80,"rows":40,"extra":"ignored"}',
|
||||
]
|
||||
|
||||
for (const input of fuzzInputs) {
|
||||
it(`does not throw for input: ${JSON.stringify(input)?.slice(0, 60) ?? String(input)}`, () => {
|
||||
expect(() => parseClientMessage(input as string)).not.toThrow()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ─── serialize ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('serialize', () => {
|
||||
it('serializes "attached" message', () => {
|
||||
const msg = { type: 'attached' as const, sessionId: VALID_UUID }
|
||||
const json = serialize(msg)
|
||||
expect(JSON.parse(json)).toEqual(msg)
|
||||
})
|
||||
|
||||
it('serializes "output" message', () => {
|
||||
const msg = { type: 'output' as const, data: '\x1b[1;32mHello\x1b[0m' }
|
||||
const json = serialize(msg)
|
||||
expect(JSON.parse(json)).toEqual(msg)
|
||||
})
|
||||
|
||||
it('serializes "exit" message with code only', () => {
|
||||
const msg = { type: 'exit' as const, code: 0 }
|
||||
const json = serialize(msg)
|
||||
expect(JSON.parse(json)).toEqual(msg)
|
||||
})
|
||||
|
||||
it('serializes "exit" message with code and reason', () => {
|
||||
const msg = { type: 'exit' as const, code: -1, reason: 'spawn failed: /bin/badshell' }
|
||||
const json = serialize(msg)
|
||||
expect(JSON.parse(json)).toEqual(msg)
|
||||
})
|
||||
|
||||
it('serialize output is a string', () => {
|
||||
expect(typeof serialize({ type: 'output', data: 'x' })).toBe('string')
|
||||
})
|
||||
|
||||
it('round-trip: parseClientMessage(JSON) produces the right attach message', () => {
|
||||
const original: { type: 'attach'; sessionId: string | null } = {
|
||||
type: 'attach',
|
||||
sessionId: VALID_UUID,
|
||||
}
|
||||
const result = parseClientMessage(JSON.stringify(original))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual(original)
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trip: parseClientMessage(JSON) produces the right input message', () => {
|
||||
const original: { type: 'input'; data: string } = { type: 'input', data: 'echo hi\r' }
|
||||
const result = parseClientMessage(JSON.stringify(original))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual(original)
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trip: parseClientMessage(JSON) produces the right resize message', () => {
|
||||
const original: { type: 'resize'; cols: number; rows: number } = {
|
||||
type: 'resize',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}
|
||||
const result = parseClientMessage(JSON.stringify(original))
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.message).toEqual(original)
|
||||
}
|
||||
})
|
||||
})
|
||||
193
test/ring-buffer.test.ts
Normal file
193
test/ring-buffer.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRingBuffer } from '../src/session/ring-buffer.js';
|
||||
import type { RingBuffer } from '../src/types.js';
|
||||
|
||||
/**
|
||||
* T6 / M2 tests — ring-buffer.ts
|
||||
*
|
||||
* Invariants under test (ARCHITECTURE §3.4 / types.ts impl anchor):
|
||||
* - capacity measured in REAL UTF-8 bytes (Buffer.byteLength), not string.length;
|
||||
* - over-capacity evicts the WHOLE oldest chunk (append boundary), never splitting a
|
||||
* multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way;
|
||||
* - snapshot() prepends a soft-reset "\x1b[0m" then the current buffer.
|
||||
*/
|
||||
|
||||
const SOFT_RESET = '\x1b[0m';
|
||||
|
||||
/** snapshot() minus the soft-reset prefix = the live buffer content. */
|
||||
function body(rb: RingBuffer): string {
|
||||
const snap = rb.snapshot();
|
||||
expect(snap.startsWith(SOFT_RESET)).toBe(true);
|
||||
return snap.slice(SOFT_RESET.length);
|
||||
}
|
||||
|
||||
describe('createRingBuffer', () => {
|
||||
describe('snapshot()', () => {
|
||||
it('prepends a soft-reset escape even when empty', () => {
|
||||
const rb = createRingBuffer(1024);
|
||||
expect(rb.snapshot()).toBe(SOFT_RESET);
|
||||
});
|
||||
|
||||
it('prepends soft-reset then the current buffer', () => {
|
||||
const rb = createRingBuffer(1024);
|
||||
rb.append('hello');
|
||||
expect(rb.snapshot()).toBe(SOFT_RESET + 'hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('within capacity', () => {
|
||||
it('replays everything appended, in order', () => {
|
||||
const rb = createRingBuffer(1024);
|
||||
rb.append('a');
|
||||
rb.append('b');
|
||||
rb.append('c');
|
||||
expect(body(rb)).toBe('abc');
|
||||
});
|
||||
|
||||
it('keeps full content when total exactly equals capacity', () => {
|
||||
const rb = createRingBuffer(6); // "abcdef" = 6 bytes
|
||||
rb.append('abc');
|
||||
rb.append('def');
|
||||
expect(body(rb)).toBe('abcdef');
|
||||
});
|
||||
});
|
||||
|
||||
describe('over capacity — evict oldest whole chunk', () => {
|
||||
it('drops the oldest chunk(s) when capacity is exceeded', () => {
|
||||
const rb = createRingBuffer(6);
|
||||
rb.append('aaa'); // 3 bytes
|
||||
rb.append('bbb'); // 3 bytes -> total 6, fits
|
||||
rb.append('ccc'); // 3 bytes -> total 9 > 6, evict oldest 'aaa'
|
||||
expect(body(rb)).toBe('bbbccc');
|
||||
});
|
||||
|
||||
it('evicts multiple oldest chunks if needed to fit a large new chunk', () => {
|
||||
const rb = createRingBuffer(5);
|
||||
rb.append('11'); // 2
|
||||
rb.append('22'); // 2 -> total 4
|
||||
rb.append('33333'); // 5 bytes; must evict '11' and '22' to fit
|
||||
expect(body(rb)).toBe('33333');
|
||||
});
|
||||
|
||||
it('evicts on chunk boundaries — never a partial chunk', () => {
|
||||
const rb = createRingBuffer(4);
|
||||
rb.append('xxx'); // 3
|
||||
rb.append('yy'); // 2 -> total 5 > 4, evict whole 'xxx', leaving 'yy'
|
||||
// 'xxx' is removed entirely, not trimmed to fit exactly 4 bytes.
|
||||
expect(body(rb)).toBe('yy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-byte UTF-8 is never split mid-codepoint', () => {
|
||||
it('does not corrupt Chinese characters on eviction', () => {
|
||||
// '中' is 3 UTF-8 bytes.
|
||||
const oneChar = '中';
|
||||
expect(Buffer.byteLength(oneChar, 'utf8')).toBe(3);
|
||||
|
||||
// Capacity holds ~3 of these chars (9 bytes). Append many in separate chunks.
|
||||
const rb = createRingBuffer(9);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
rb.append('中'); // each chunk = one 3-byte codepoint
|
||||
}
|
||||
const out = body(rb);
|
||||
|
||||
// No replacement char (U+FFFD) => nothing was split into invalid UTF-8.
|
||||
expect(out).not.toContain('<27>');
|
||||
// Every char is a clean '中'.
|
||||
expect([...out].every((c) => c === '中')).toBe(true);
|
||||
// Byte budget respected.
|
||||
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(9);
|
||||
});
|
||||
|
||||
it('round-trips through Buffer without producing replacement chars', () => {
|
||||
const rb = createRingBuffer(12); // 4 chars * 3 bytes
|
||||
const chars = ['日', '本', '語', '中', '文', '字'];
|
||||
for (const c of chars) rb.append(c);
|
||||
const out = body(rb);
|
||||
// Re-encode/decode: if any codepoint were split, we'd see U+FFFD.
|
||||
const reDecoded = Buffer.from(out, 'utf8').toString('utf8');
|
||||
expect(reDecoded).toBe(out);
|
||||
expect(out).not.toContain('<27>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ANSI escape sequences are never cut mid-sequence', () => {
|
||||
it('keeps escape sequences whole when each is its own chunk', () => {
|
||||
const rb = createRingBuffer(20);
|
||||
// Each chunk is a complete colored token.
|
||||
rb.append('\x1b[1;32mA\x1b[0m'); // green A + reset
|
||||
rb.append('\x1b[1;31mB\x1b[0m'); // red B + reset
|
||||
rb.append('\x1b[1;34mC\x1b[0m'); // blue C + reset
|
||||
const out = body(rb);
|
||||
|
||||
// The oldest chunk(s) may be evicted, but whichever remain are intact:
|
||||
// every ESC '[' begins a complete sequence ending in 'm'.
|
||||
const escIndexes = [...out.matchAll(/\x1b/g)].map((m) => m.index!);
|
||||
for (const idx of escIndexes) {
|
||||
const seq = out.slice(idx);
|
||||
expect(seq).toMatch(/^\x1b\[[0-9;]*m/);
|
||||
}
|
||||
// No dangling ESC at the very end (would mean a cut sequence).
|
||||
expect(out.endsWith('\x1b')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not slice a single ANSI token in half on eviction', () => {
|
||||
const rb = createRingBuffer(8);
|
||||
const token = '\x1b[1;32m'; // 7 bytes, a complete SGR sequence
|
||||
rb.append(token);
|
||||
rb.append(token); // total 14 > 8 -> evict whole first token, keep second intact
|
||||
const out = body(rb);
|
||||
expect(out).toBe(token); // exactly one whole token, not a fragment
|
||||
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('byte accounting (not string.length)', () => {
|
||||
it('uses real UTF-8 byte count for capacity, not UTF-16 code units', () => {
|
||||
// '😀' is 2 UTF-16 code units (string.length === 2) but 4 UTF-8 bytes.
|
||||
const emoji = '😀';
|
||||
expect(emoji.length).toBe(2);
|
||||
expect(Buffer.byteLength(emoji, 'utf8')).toBe(4);
|
||||
|
||||
// Capacity 8 bytes = exactly two emoji.
|
||||
const rb = createRingBuffer(8);
|
||||
rb.append(emoji); // 4 bytes
|
||||
rb.append(emoji); // 8 bytes total, fits
|
||||
rb.append(emoji); // 12 > 8 -> evict oldest, leaving two emoji (8 bytes)
|
||||
const out = body(rb);
|
||||
|
||||
expect(Buffer.byteLength(out, 'utf8')).toBe(8);
|
||||
expect([...out].length).toBe(2);
|
||||
expect(out).not.toContain('<27>');
|
||||
});
|
||||
|
||||
it('counts multi-byte content by bytes when deciding eviction', () => {
|
||||
// If accounting used string.length, '中中' (length 2) would seem to fit a
|
||||
// capacity-of-3 buffer; by bytes it's 6 and must not both stay.
|
||||
const rb = createRingBuffer(3);
|
||||
rb.append('中'); // 3 bytes, fits exactly
|
||||
rb.append('中'); // 6 > 3 -> evict the first, keep the second
|
||||
const out = body(rb);
|
||||
expect(out).toBe('中');
|
||||
expect(Buffer.byteLength(out, 'utf8')).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('ignores empty appends', () => {
|
||||
const rb = createRingBuffer(8);
|
||||
rb.append('');
|
||||
rb.append('ab');
|
||||
rb.append('');
|
||||
expect(body(rb)).toBe('ab');
|
||||
});
|
||||
|
||||
it('a single chunk larger than capacity is kept as the sole chunk (cannot split)', () => {
|
||||
// Eviction is on whole-chunk boundaries; a lone oversized chunk has nothing
|
||||
// older to drop, so it stays intact rather than being sliced (M2: never split).
|
||||
const rb = createRingBuffer(4);
|
||||
rb.append('abcdefgh'); // 8 bytes > 4
|
||||
expect(body(rb)).toBe('abcdefgh');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user