An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing Origin/CSRF model — never replacing it — and is fully inert when unset. - src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32 bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws. parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled. - WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start. - GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>). - When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a central authGate over all remote HTTP require the cookie. Open: /login, /auth. Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local browser too). - Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged). Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale substitute — on bare ws:// the token is cleartext + replayable; only hardens the TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at --test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red); auth.ts 100% line coverage.
889 lines
33 KiB
TypeScript
889 lines
33 KiB
TypeScript
/**
|
||
* 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(),
|
||
}
|
||
})
|
||
|
||
// ── 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 maxFanoutLanes to 6 (W5)', () => {
|
||
expect(loadConfig({}).maxFanoutLanes).toBe(6)
|
||
})
|
||
|
||
it('reads MAX_FANOUT_LANES from env', () => {
|
||
expect(loadConfig({ MAX_FANOUT_LANES: '3' }).maxFanoutLanes).toBe(3)
|
||
})
|
||
|
||
it('throws for a negative MAX_FANOUT_LANES (fail-fast, like MAX_SESSIONS)', () => {
|
||
expect(() => loadConfig({ MAX_FANOUT_LANES: '-1' })).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)
|
||
})
|
||
})
|
||
|
||
// ── v0.6 project discovery config ───────────────────────────────────────────────
|
||
describe('loadConfig — project discovery (v0.6)', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('defaults: projectRoots=[homeDir], depth 4, ttl 10000, dirtyCheck true', () => {
|
||
const cfg = loadConfig({})
|
||
expect(cfg.projectRoots).toEqual(['/home/testuser'])
|
||
expect(cfg.projectScanDepth).toBe(4)
|
||
expect(cfg.projectScanTtlMs).toBe(10_000)
|
||
expect(cfg.projectDirtyCheck).toBe(true)
|
||
expect(Object.isFrozen(cfg.projectRoots)).toBe(true)
|
||
})
|
||
|
||
it('PROJECT_ROOTS: comma-separated absolute paths, trimmed', () => {
|
||
const cfg = loadConfig({ PROJECT_ROOTS: '/a, /b/c ,/d' })
|
||
expect(cfg.projectRoots).toEqual(['/a', '/b/c', '/d'])
|
||
})
|
||
|
||
it('PROJECT_ROOTS: expands ~ and ~/sub to homeDir', () => {
|
||
const cfg = loadConfig({ PROJECT_ROOTS: '~,~/code' })
|
||
expect(cfg.projectRoots).toEqual(['/home/testuser', '/home/testuser/code'])
|
||
})
|
||
|
||
it('PROJECT_ROOTS: empty / whitespace falls back to [homeDir]', () => {
|
||
expect(loadConfig({ PROJECT_ROOTS: '' }).projectRoots).toEqual(['/home/testuser'])
|
||
expect(loadConfig({ PROJECT_ROOTS: ' ' }).projectRoots).toEqual(['/home/testuser'])
|
||
})
|
||
|
||
it('PROJECT_ROOTS: a relative path fails fast with a clear error', () => {
|
||
expect(() => loadConfig({ PROJECT_ROOTS: 'relative/dir' })).toThrow(/absolute path/)
|
||
expect(() => loadConfig({ PROJECT_ROOTS: '/ok,./bad' })).toThrow(/PROJECT_ROOTS/)
|
||
})
|
||
|
||
it('PROJECT_SCAN_DEPTH / PROJECT_SCAN_TTL override and validate', () => {
|
||
const cfg = loadConfig({ PROJECT_SCAN_DEPTH: '2', PROJECT_SCAN_TTL: '0' })
|
||
expect(cfg.projectScanDepth).toBe(2)
|
||
expect(cfg.projectScanTtlMs).toBe(0)
|
||
expect(() => loadConfig({ PROJECT_SCAN_DEPTH: 'deep' })).toThrow(/PROJECT_SCAN_DEPTH/)
|
||
expect(() => loadConfig({ PROJECT_SCAN_TTL: '-1' })).toThrow(/PROJECT_SCAN_TTL/)
|
||
})
|
||
|
||
it('PROJECT_DIRTY_CHECK: parses on/off forms, defaults true', () => {
|
||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '0' }).projectDirtyCheck).toBe(false)
|
||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'off' }).projectDirtyCheck).toBe(false)
|
||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'false' }).projectDirtyCheck).toBe(false)
|
||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'on' }).projectDirtyCheck).toBe(true)
|
||
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
|
||
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'garbage' }).projectDirtyCheck).toBe(true)
|
||
})
|
||
|
||
it('EDITOR_CMD: defaults to "code", trims, falls back on blank', () => {
|
||
expect(loadConfig({}).editorCmd).toBe('code')
|
||
expect(loadConfig({ EDITOR_CMD: 'cursor' }).editorCmd).toBe('cursor')
|
||
expect(loadConfig({ EDITOR_CMD: ' code-insiders ' }).editorCmd).toBe('code-insiders')
|
||
expect(loadConfig({ EDITOR_CMD: ' ' }).editorCmd).toBe('code')
|
||
})
|
||
})
|
||
|
||
// ── v0.7 Walk-away Workbench — new env vars ───────────────────────────────────
|
||
describe('loadConfig — v0.7 A1 push notifications', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: unset → both undefined', () => {
|
||
const cfg = loadConfig({})
|
||
expect(cfg.vapidPublicKey).toBeUndefined()
|
||
expect(cfg.vapidPrivateKey).toBeUndefined()
|
||
})
|
||
|
||
it('VAPID_PUBLIC_KEY/PRIVATE_KEY: reads from env', () => {
|
||
const cfg = loadConfig({ VAPID_PUBLIC_KEY: 'pubkey123', VAPID_PRIVATE_KEY: 'privkey456' })
|
||
expect(cfg.vapidPublicKey).toBe('pubkey123')
|
||
expect(cfg.vapidPrivateKey).toBe('privkey456')
|
||
})
|
||
|
||
it('VAPID_SUBJECT: unset → undefined, reads from env', () => {
|
||
expect(loadConfig({}).vapidSubject).toBeUndefined()
|
||
expect(loadConfig({ VAPID_SUBJECT: 'mailto:admin@example.com' }).vapidSubject).toBe('mailto:admin@example.com')
|
||
})
|
||
|
||
it('PUSH_STORE_PATH: default is <homeDir>/.web-terminal-push-subs.json', () => {
|
||
const cfg = loadConfig({})
|
||
expect(cfg.pushStorePath).toBe('/home/testuser/.web-terminal-push-subs.json')
|
||
})
|
||
|
||
it('PUSH_STORE_PATH: reads from env', () => {
|
||
const cfg = loadConfig({ PUSH_STORE_PATH: '/tmp/subs.json' })
|
||
expect(cfg.pushStorePath).toBe('/tmp/subs.json')
|
||
})
|
||
|
||
it('PUSH_MAX_SUBS: default 50, reads from env', () => {
|
||
expect(loadConfig({}).pushMaxSubs).toBe(50)
|
||
expect(loadConfig({ PUSH_MAX_SUBS: '20' }).pushMaxSubs).toBe(20)
|
||
})
|
||
|
||
it('PUSH_MAX_SUBS: throws for non-integer', () => {
|
||
expect(() => loadConfig({ PUSH_MAX_SUBS: 'many' })).toThrow(/PUSH_MAX_SUBS/)
|
||
})
|
||
|
||
it('NOTIFY_DONE: default true (1), parses on/off', () => {
|
||
expect(loadConfig({}).notifyDone).toBe(true)
|
||
expect(loadConfig({ NOTIFY_DONE: '0' }).notifyDone).toBe(false)
|
||
expect(loadConfig({ NOTIFY_DONE: 'false' }).notifyDone).toBe(false)
|
||
expect(loadConfig({ NOTIFY_DONE: '1' }).notifyDone).toBe(true)
|
||
})
|
||
|
||
it('NOTIFY_DND: default false (0), parses on/off', () => {
|
||
expect(loadConfig({}).notifyDnd).toBe(false)
|
||
expect(loadConfig({ NOTIFY_DND: '1' }).notifyDnd).toBe(true)
|
||
expect(loadConfig({ NOTIFY_DND: 'on' }).notifyDnd).toBe(true)
|
||
expect(loadConfig({ NOTIFY_DND: '0' }).notifyDnd).toBe(false)
|
||
})
|
||
|
||
it('DECISION_TOKEN_TTL_MS: default = permTimeoutMs', () => {
|
||
const cfg = loadConfig({})
|
||
expect(cfg.decisionTokenTtlMs).toBe(cfg.permTimeoutMs)
|
||
})
|
||
|
||
it('DECISION_TOKEN_TTL_MS: readable from env, overrides the default', () => {
|
||
const cfg = loadConfig({ DECISION_TOKEN_TTL_MS: '120000' })
|
||
expect(cfg.decisionTokenTtlMs).toBe(120000)
|
||
})
|
||
|
||
it('DECISION_TOKEN_TTL_MS: throws for non-integer', () => {
|
||
expect(() => loadConfig({ DECISION_TOKEN_TTL_MS: 'never' })).toThrow(/DECISION_TOKEN_TTL_MS/)
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — v0.7 A4 timeline + A5 stuck detection', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('TIMELINE_MAX: default 200, reads from env', () => {
|
||
expect(loadConfig({}).timelineMax).toBe(200)
|
||
expect(loadConfig({ TIMELINE_MAX: '50' }).timelineMax).toBe(50)
|
||
})
|
||
|
||
it('TIMELINE_MAX: throws for non-integer', () => {
|
||
expect(() => loadConfig({ TIMELINE_MAX: 'lots' })).toThrow(/TIMELINE_MAX/)
|
||
})
|
||
|
||
it('TIMELINE_ENABLED: default true, parses on/off', () => {
|
||
expect(loadConfig({}).timelineEnabled).toBe(true)
|
||
expect(loadConfig({ TIMELINE_ENABLED: '0' }).timelineEnabled).toBe(false)
|
||
expect(loadConfig({ TIMELINE_ENABLED: 'false' }).timelineEnabled).toBe(false)
|
||
expect(loadConfig({ TIMELINE_ENABLED: '1' }).timelineEnabled).toBe(true)
|
||
})
|
||
|
||
it('STUCK_TTL: default 600000ms (600s), reads from env in seconds', () => {
|
||
expect(loadConfig({}).stuckTtlMs).toBe(600_000)
|
||
expect(loadConfig({ STUCK_TTL: '300' }).stuckTtlMs).toBe(300_000)
|
||
expect(loadConfig({ STUCK_TTL: '0' }).stuckTtlMs).toBe(0)
|
||
})
|
||
|
||
it('STUCK_TTL: throws for non-integer', () => {
|
||
expect(() => loadConfig({ STUCK_TTL: 'soon' })).toThrow(/STUCK_TTL/)
|
||
})
|
||
|
||
it('STUCK_ALERT: default true, parses on/off', () => {
|
||
expect(loadConfig({}).stuckAlert).toBe(true)
|
||
expect(loadConfig({ STUCK_ALERT: '0' }).stuckAlert).toBe(false)
|
||
expect(loadConfig({ STUCK_ALERT: 'off' }).stuckAlert).toBe(false)
|
||
expect(loadConfig({ STUCK_ALERT: '1' }).stuckAlert).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — v0.7 B1 diff + B2 statusline', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('DIFF_TIMEOUT_MS: default 2000, reads from env', () => {
|
||
expect(loadConfig({}).diffTimeoutMs).toBe(2000)
|
||
expect(loadConfig({ DIFF_TIMEOUT_MS: '5000' }).diffTimeoutMs).toBe(5000)
|
||
})
|
||
|
||
it('DIFF_MAX_BYTES: default 2MB, reads from env', () => {
|
||
expect(loadConfig({}).diffMaxBytes).toBe(2 * 1024 * 1024)
|
||
expect(loadConfig({ DIFF_MAX_BYTES: '1048576' }).diffMaxBytes).toBe(1048576)
|
||
})
|
||
|
||
it('DIFF_MAX_FILES: default 300, reads from env', () => {
|
||
expect(loadConfig({}).diffMaxFiles).toBe(300)
|
||
expect(loadConfig({ DIFF_MAX_FILES: '100' }).diffMaxFiles).toBe(100)
|
||
})
|
||
|
||
it('DIFF_* vars throw for non-integer', () => {
|
||
expect(() => loadConfig({ DIFF_TIMEOUT_MS: 'fast' })).toThrow(/DIFF_TIMEOUT_MS/)
|
||
expect(() => loadConfig({ DIFF_MAX_BYTES: 'big' })).toThrow(/DIFF_MAX_BYTES/)
|
||
expect(() => loadConfig({ DIFF_MAX_FILES: 'many' })).toThrow(/DIFF_MAX_FILES/)
|
||
})
|
||
|
||
it('STATUSLINE_TTL_MS: default 30000, reads from env', () => {
|
||
expect(loadConfig({}).statuslineTtlMs).toBe(30_000)
|
||
expect(loadConfig({ STATUSLINE_TTL_MS: '60000' }).statuslineTtlMs).toBe(60_000)
|
||
})
|
||
|
||
it('STATUSLINE_TTL_MS: throws for non-integer', () => {
|
||
expect(() => loadConfig({ STATUSLINE_TTL_MS: 'long' })).toThrow(/STATUSLINE_TTL_MS/)
|
||
})
|
||
})
|
||
|
||
// ── W3 quick-wins (b) cost budget guard ───────────────────────────────────────
|
||
describe('loadConfig — W3 COST_BUDGET_USD', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('defaults costBudgetUsd to 0 (disabled) when unset', () => {
|
||
expect(loadConfig({}).costBudgetUsd).toBe(0)
|
||
})
|
||
|
||
it('parses a float dollar value', () => {
|
||
expect(loadConfig({ COST_BUDGET_USD: '5.50' }).costBudgetUsd).toBe(5.5)
|
||
})
|
||
|
||
it('accepts an explicit 0 (disabled)', () => {
|
||
expect(loadConfig({ COST_BUDGET_USD: '0' }).costBudgetUsd).toBe(0)
|
||
})
|
||
|
||
it('throws for a non-numeric value', () => {
|
||
expect(() => loadConfig({ COST_BUDGET_USD: 'abc' })).toThrow(/COST_BUDGET_USD/)
|
||
})
|
||
|
||
it('throws for a negative value', () => {
|
||
expect(() => loadConfig({ COST_BUDGET_USD: '-1' })).toThrow(/COST_BUDGET_USD/)
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — v0.7 B3 worktree', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('WORKTREE_ENABLED: default true, parses on/off', () => {
|
||
expect(loadConfig({}).worktreeEnabled).toBe(true)
|
||
expect(loadConfig({ WORKTREE_ENABLED: '0' }).worktreeEnabled).toBe(false)
|
||
expect(loadConfig({ WORKTREE_ENABLED: 'false' }).worktreeEnabled).toBe(false)
|
||
})
|
||
|
||
it('WORKTREE_ROOT: default undefined, reads from env', () => {
|
||
expect(loadConfig({}).worktreeRoot).toBeUndefined()
|
||
expect(loadConfig({ WORKTREE_ROOT: '/data/worktrees' }).worktreeRoot).toBe('/data/worktrees')
|
||
})
|
||
|
||
it('WORKTREE_TIMEOUT_MS: default 10000, reads from env', () => {
|
||
expect(loadConfig({}).worktreeTimeoutMs).toBe(10_000)
|
||
expect(loadConfig({ WORKTREE_TIMEOUT_MS: '20000' }).worktreeTimeoutMs).toBe(20_000)
|
||
})
|
||
|
||
it('WORKTREE_TIMEOUT_MS: throws for non-integer', () => {
|
||
expect(() => loadConfig({ WORKTREE_TIMEOUT_MS: 'slow' })).toThrow(/WORKTREE_TIMEOUT_MS/)
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — W4 git write (stage / commit / push)', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('GIT_OPS_ENABLED: default true, parses on/off', () => {
|
||
expect(loadConfig({}).gitOpsEnabled).toBe(true)
|
||
expect(loadConfig({ GIT_OPS_ENABLED: '0' }).gitOpsEnabled).toBe(false)
|
||
expect(loadConfig({ GIT_OPS_ENABLED: 'false' }).gitOpsEnabled).toBe(false)
|
||
})
|
||
|
||
it('GIT_OPS_TIMEOUT_MS: default 10000, reads from env', () => {
|
||
expect(loadConfig({}).gitOpsTimeoutMs).toBe(10_000)
|
||
expect(loadConfig({ GIT_OPS_TIMEOUT_MS: '5000' }).gitOpsTimeoutMs).toBe(5_000)
|
||
})
|
||
|
||
it('GIT_PUSH_TIMEOUT_MS: default 120000, reads from env', () => {
|
||
expect(loadConfig({}).gitPushTimeoutMs).toBe(120_000)
|
||
expect(loadConfig({ GIT_PUSH_TIMEOUT_MS: '30000' }).gitPushTimeoutMs).toBe(30_000)
|
||
})
|
||
|
||
it('COMMIT_MSG_MAX_LEN: default 5000, reads from env', () => {
|
||
expect(loadConfig({}).commitMsgMaxLen).toBe(5_000)
|
||
expect(loadConfig({ COMMIT_MSG_MAX_LEN: '200' }).commitMsgMaxLen).toBe(200)
|
||
})
|
||
|
||
it('throws for a non-integer numeric var', () => {
|
||
expect(() => loadConfig({ GIT_OPS_TIMEOUT_MS: 'slow' })).toThrow(/GIT_OPS_TIMEOUT_MS/)
|
||
expect(() => loadConfig({ COMMIT_MSG_MAX_LEN: 'lots' })).toThrow(/COMMIT_MSG_MAX_LEN/)
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — v0.7 B4 permission mode', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('DEFAULT_PERMISSION_MODE: default "default"', () => {
|
||
expect(loadConfig({}).defaultPermissionMode).toBe('default')
|
||
})
|
||
|
||
it('DEFAULT_PERMISSION_MODE: accepts all 4 valid values', () => {
|
||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'default' }).defaultPermissionMode).toBe('default')
|
||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'acceptEdits' }).defaultPermissionMode).toBe('acceptEdits')
|
||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'plan' }).defaultPermissionMode).toBe('plan')
|
||
expect(loadConfig({ DEFAULT_PERMISSION_MODE: 'auto' }).defaultPermissionMode).toBe('auto')
|
||
})
|
||
|
||
it('DEFAULT_PERMISSION_MODE: throws for invalid values', () => {
|
||
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'bypassPermissions' })).toThrow(/DEFAULT_PERMISSION_MODE/)
|
||
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: 'dontAsk' })).toThrow(/DEFAULT_PERMISSION_MODE/)
|
||
expect(() => loadConfig({ DEFAULT_PERMISSION_MODE: '' })).toThrow(/DEFAULT_PERMISSION_MODE/)
|
||
})
|
||
|
||
it('ALLOW_AUTO_MODE: default false, parses on/off', () => {
|
||
expect(loadConfig({}).allowAutoMode).toBe(false)
|
||
expect(loadConfig({ ALLOW_AUTO_MODE: '1' }).allowAutoMode).toBe(true)
|
||
expect(loadConfig({ ALLOW_AUTO_MODE: 'true' }).allowAutoMode).toBe(true)
|
||
expect(loadConfig({ ALLOW_AUTO_MODE: '0' }).allowAutoMode).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — v0.7 L5 permTimeoutMs validation', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('PERM_TIMEOUT_MS=0 throws (must be > 0 for --max-time integration)', () => {
|
||
expect(() => loadConfig({ PERM_TIMEOUT_MS: '0' })).toThrow(/PERM_TIMEOUT_MS/)
|
||
})
|
||
|
||
it('PERM_TIMEOUT_MS=1 is valid (positive integer)', () => {
|
||
expect(loadConfig({ PERM_TIMEOUT_MS: '1' }).permTimeoutMs).toBe(1)
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — v0.7 SEC-C5 VAPID key confidentiality', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('vapidPrivateKey field name is distinct (caller must avoid logging it)', () => {
|
||
// The private key must never appear in logs. Config stores the raw value
|
||
// under "vapidPrivateKey" — callers that log config should skip this field.
|
||
const cfg = loadConfig({ VAPID_PRIVATE_KEY: 'super-secret-key' })
|
||
expect(cfg.vapidPrivateKey).toBe('super-secret-key')
|
||
// Verify the field name so callers can explicitly skip it in log output
|
||
expect(Object.keys(cfg)).toContain('vapidPrivateKey')
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — v0.7 all new fields present in frozen result', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('returned object is frozen and contains all 21 new v0.7 fields', () => {
|
||
const cfg = loadConfig({})
|
||
expect(Object.isFrozen(cfg)).toBe(true)
|
||
|
||
// A1 push
|
||
expect(cfg).toHaveProperty('vapidPublicKey')
|
||
expect(cfg).toHaveProperty('vapidPrivateKey')
|
||
expect(cfg).toHaveProperty('vapidSubject')
|
||
expect(cfg).toHaveProperty('pushStorePath')
|
||
expect(cfg).toHaveProperty('pushMaxSubs')
|
||
expect(cfg).toHaveProperty('notifyDone')
|
||
expect(cfg).toHaveProperty('notifyDnd')
|
||
expect(cfg).toHaveProperty('decisionTokenTtlMs')
|
||
|
||
// A4 timeline
|
||
expect(cfg).toHaveProperty('timelineMax')
|
||
expect(cfg).toHaveProperty('timelineEnabled')
|
||
|
||
// A5 stuck
|
||
expect(cfg).toHaveProperty('stuckTtlMs')
|
||
expect(cfg).toHaveProperty('stuckAlert')
|
||
|
||
// B1 diff
|
||
expect(cfg).toHaveProperty('diffTimeoutMs')
|
||
expect(cfg).toHaveProperty('diffMaxBytes')
|
||
expect(cfg).toHaveProperty('diffMaxFiles')
|
||
|
||
// B2 statusline
|
||
expect(cfg).toHaveProperty('statuslineTtlMs')
|
||
|
||
// B3 worktree
|
||
expect(cfg).toHaveProperty('worktreeEnabled')
|
||
expect(cfg).toHaveProperty('worktreeRoot')
|
||
expect(cfg).toHaveProperty('worktreeTimeoutMs')
|
||
|
||
// B4 permission mode
|
||
expect(cfg).toHaveProperty('defaultPermissionMode')
|
||
expect(cfg).toHaveProperty('allowAutoMode')
|
||
})
|
||
})
|
||
|
||
// ── W2 inject-queue config ────────────────────────────────────────────────────
|
||
describe('loadConfig — W2 inject queue', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('defaults: queueEnabled true, queueMaxItems 10, queueItemMaxBytes 4096, queueSettleMs 1500', () => {
|
||
const cfg = loadConfig({})
|
||
expect(cfg.queueEnabled).toBe(true)
|
||
expect(cfg.queueMaxItems).toBe(10)
|
||
expect(cfg.queueItemMaxBytes).toBe(4096)
|
||
expect(cfg.queueSettleMs).toBe(1500)
|
||
})
|
||
|
||
it('reads env overrides', () => {
|
||
const cfg = loadConfig({
|
||
QUEUE_ENABLED: '0',
|
||
QUEUE_MAX_ITEMS: '3',
|
||
QUEUE_ITEM_MAX_BYTES: '256',
|
||
QUEUE_SETTLE_MS: '500',
|
||
})
|
||
expect(cfg.queueEnabled).toBe(false)
|
||
expect(cfg.queueMaxItems).toBe(3)
|
||
expect(cfg.queueItemMaxBytes).toBe(256)
|
||
expect(cfg.queueSettleMs).toBe(500)
|
||
})
|
||
|
||
it('throws for a negative QUEUE_MAX_ITEMS (fail-fast)', () => {
|
||
expect(() => loadConfig({ QUEUE_MAX_ITEMS: '-1' })).toThrow()
|
||
})
|
||
|
||
it('throws for a non-integer QUEUE_SETTLE_MS', () => {
|
||
expect(() => loadConfig({ QUEUE_SETTLE_MS: 'soon' })).toThrow()
|
||
})
|
||
})
|
||
|
||
describe('loadConfig — w5-access-token (WEBTERM_TOKEN)', () => {
|
||
beforeEach(() => {
|
||
mockNetworkInterfaces.mockReturnValue({})
|
||
mockHomedir.mockReturnValue('/home/testuser')
|
||
})
|
||
|
||
it('is undefined when WEBTERM_TOKEN is unset (auth disabled — LAN zero-config)', () => {
|
||
expect(loadConfig({}).webtermToken).toBeUndefined()
|
||
})
|
||
|
||
it('is undefined when WEBTERM_TOKEN is the empty string (treated as unset)', () => {
|
||
expect(loadConfig({ WEBTERM_TOKEN: '' }).webtermToken).toBeUndefined()
|
||
})
|
||
|
||
it('stores a valid token verbatim (≥16 chars, safe charset)', () => {
|
||
const t = 'super-secret-token-1234'
|
||
expect(loadConfig({ WEBTERM_TOKEN: t }).webtermToken).toBe(t)
|
||
})
|
||
|
||
it('throws for a too-short token (< 16 chars)', () => {
|
||
expect(() => loadConfig({ WEBTERM_TOKEN: 'abc' })).toThrow(/WEBTERM_TOKEN/)
|
||
})
|
||
|
||
it('throws for a token with an unsafe char (semicolon → Set-Cookie injection)', () => {
|
||
expect(() => loadConfig({ WEBTERM_TOKEN: 'has;semicolon;inside0' })).toThrow(/WEBTERM_TOKEN/)
|
||
})
|
||
|
||
it('throws for a token with a space', () => {
|
||
expect(() => loadConfig({ WEBTERM_TOKEN: 'has a space in it here' })).toThrow(/WEBTERM_TOKEN/)
|
||
})
|
||
|
||
it('throws for a token with a control char', () => {
|
||
expect(() => loadConfig({ WEBTERM_TOKEN: 'ctrl |