/** * 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>() mockNetworkInterfaces.mockReturnValue({}) const mockHomedir = vi.fn<[], string>() mockHomedir.mockReturnValue('/home/testuser') vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal() return { ...actual, default: { ...actual.default, homedir: () => mockHomedir(), networkInterfaces: () => mockNetworkInterfaces(), }, homedir: () => mockHomedir(), networkInterfaces: () => mockNetworkInterfaces(), } }) // ── mock tmux availability so resolveUseTmux's auto-detect is deterministic ── const mockTmuxAvailable = vi.fn<[], boolean>() mockTmuxAvailable.mockReturnValue(false) vi.mock('../src/session/tmux.js', () => ({ tmuxAvailable: () => mockTmuxAvailable(), tmuxName: (id: string) => `web_${id}`, hasSession: () => false, killSession: () => undefined, })) // 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) }) }) // ── MAX_SESSIONS + new operational constants ────────────────────────────────── describe('loadConfig — session/rate/timer constants', () => { beforeEach(() => { mockNetworkInterfaces.mockReturnValue({}) mockHomedir.mockReturnValue('/home/testuser') }) it('defaults maxSessions to 50', () => { expect(loadConfig({}).maxSessions).toBe(50) }) it('reads MAX_SESSIONS from env', () => { expect(loadConfig({ MAX_SESSIONS: '20' }).maxSessions).toBe(20) }) it('throws for a non-integer MAX_SESSIONS', () => { expect(() => loadConfig({ MAX_SESSIONS: 'lots' })).toThrow() }) it('defaults maxMsgsPerSec to 2000 and reads MAX_MSGS_PER_SEC', () => { expect(loadConfig({}).maxMsgsPerSec).toBe(2000) expect(loadConfig({ MAX_MSGS_PER_SEC: '500' }).maxMsgsPerSec).toBe(500) }) it('defaults permTimeoutMs / reapIntervalMs / previewBytes and reads overrides', () => { const def = loadConfig({}) expect(def.permTimeoutMs).toBe(5 * 60_000) expect(def.reapIntervalMs).toBe(60_000) expect(def.previewBytes).toBe(24 * 1024) const over = loadConfig({ PERM_TIMEOUT_MS: '1000', REAP_INTERVAL_MS: '2000', PREVIEW_BYTES: '4096' }) expect(over.permTimeoutMs).toBe(1000) expect(over.reapIntervalMs).toBe(2000) expect(over.previewBytes).toBe(4096) }) }) // ── resolveUseTmux branches (USE_TMUX env) ──────────────────────────────────── describe('loadConfig — resolveUseTmux', () => { beforeEach(() => { mockNetworkInterfaces.mockReturnValue({}) mockHomedir.mockReturnValue('/home/testuser') mockTmuxAvailable.mockReturnValue(false) }) it.each(['1', 'true', 'on', 'ON', ' True '])('forces tmux ON for %j', (v) => { expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(true) }) it.each(['0', 'false', 'off'])('forces tmux OFF for %j', (v) => { expect(loadConfig({ USE_TMUX: v }).useTmux).toBe(false) }) it('auto-detects (unset) → true when tmux is available', () => { mockTmuxAvailable.mockReturnValue(true) expect(loadConfig({}).useTmux).toBe(true) }) it('auto-detects (unset / "auto") → false when tmux is unavailable', () => { mockTmuxAvailable.mockReturnValue(false) expect(loadConfig({}).useTmux).toBe(false) expect(loadConfig({ USE_TMUX: 'auto' }).useTmux).toBe(false) }) }) // ── ALLOWED_ORIGINS scheme validation (M1) ──────────────────────────────────── describe('loadConfig — ALLOWED_ORIGINS scheme validation', () => { beforeEach(() => { mockNetworkInterfaces.mockReturnValue({}) mockHomedir.mockReturnValue('/home/testuser') }) it('rejects non-http(s) scheme entries (file:, javascript:, bare host)', () => { const cfg = loadConfig({ ALLOWED_ORIGINS: 'file:///etc/passwd,javascript:alert(1),notaurl,http://ok.local:3000', }) expect(cfg.allowedOrigins).toContain('http://ok.local:3000') expect(cfg.allowedOrigins.some((o) => o.startsWith('file:'))).toBe(false) expect(cfg.allowedOrigins.some((o) => o.startsWith('javascript:'))).toBe(false) expect(cfg.allowedOrigins).not.toContain('notaurl') }) it('keeps valid https entries', () => { const cfg = loadConfig({ ALLOWED_ORIGINS: 'https://phone.local:3000' }) expect(cfg.allowedOrigins).toContain('https://phone.local:3000') }) }) // ── 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) }) })