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:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user