Files
web-terminal/test/config.test.ts
Yaojia Wang 469037cb94 feat(auth): optional WEBTERM_TOKEN access-token gate (W5)
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.
2026-07-13 05:25:07 +02:00

889 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: 'ctrlchar-injection00' })).toThrow(/WEBTERM_TOKEN/)
})
it('never leaks the token value in the validation error message (secret hygiene)', () => {
const badToken = 'secretbutbad;value00'
expect(() => loadConfig({ WEBTERM_TOKEN: badToken })).toThrow()
try {
loadConfig({ WEBTERM_TOKEN: badToken })
} catch (e) {
expect(String((e as Error).message)).not.toContain(badToken)
}
})
})