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