/** * @fileoverview Tests for hooks config generation * * Tests the generation of .claude/settings.local.json with Claude Code * hook definitions for desktop notifications. */ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawn } from 'node:child_process'; import { generateBackgroundWakeScript, generateHooksConfig, refreshStaleCodemanHooks, writeHooksConfig, } from '../src/hooks-config.js'; describe('generateHooksConfig', () => { it('should return an object with hooks key', () => { const config = generateHooksConfig(); expect(config).toHaveProperty('hooks'); }); it('should have Notification hooks array', () => { const config = generateHooksConfig(); expect(config.hooks.Notification).toBeInstanceOf(Array); expect(config.hooks.Notification).toHaveLength(3); }); it('should have Stop hooks array', () => { const config = generateHooksConfig(); expect(config.hooks.Stop).toBeInstanceOf(Array); expect(config.hooks.Stop).toHaveLength(1); }); it('should configure a self-contained Bash background-task rewake hook', () => { const config = generateHooksConfig(); const postToolHooks = config.hooks.PostToolUse as Array<{ matcher: string; hooks: Array<{ type: string; command: string; args: string[]; asyncRewake: boolean; timeout: number; }>; }>; expect(postToolHooks).toHaveLength(1); expect(postToolHooks[0].matcher).toBe('Bash'); expect(postToolHooks[0].hooks[0]).toMatchObject({ type: 'command', command: 'node', asyncRewake: true, }); expect(postToolHooks[0].hooks[0].args).toEqual(['-e', generateBackgroundWakeScript()]); expect(postToolHooks[0].hooks[0].timeout).toBeGreaterThanOrEqual(3600); }); it('should configure idle_prompt matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; const idleHook = notifHooks.find((h) => h.matcher === 'idle_prompt'); expect(idleHook).toBeDefined(); }); it('should configure permission_prompt matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; const permHook = notifHooks.find((h) => h.matcher === 'permission_prompt'); expect(permHook).toBeDefined(); }); it('should configure elicitation_dialog matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; const elicitHook = notifHooks.find((h) => h.matcher === 'elicitation_dialog'); expect(elicitHook).toBeDefined(); }); it('should use env vars in curl commands (not hardcoded URLs)', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; const cmd = notifHooks[0].hooks[0].command; expect(cmd).toContain('$CODEMAN_API_URL'); expect(cmd).toContain('$CODEMAN_SESSION_ID'); expect(cmd).not.toContain('localhost'); }); it('should include || true for silent failure', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; expect(notifHooks[0].hooks[0].command).toContain('|| true'); }); // On --https/tailscale installs CODEMAN_API_URL is HTTPS with a self-signed cert. // A `-k`-less hook curl exits 60 there, the `|| true` swallows it, and every hook // event (stop, permission_prompt, elicitation_dialog, idle_prompt, teammate_idle, // task_completed) dies silently — killing respawn's idle signals and the wait // endpoints' stop/blocked. The statusline exporter always carried -k; the hooks must too. it('every hook curl tolerates a self-signed HTTPS API (curl -sk)', () => { const serialized = JSON.stringify(generateHooksConfig()); expect(serialized).toContain('curl -sk -X POST'); expect(serialized).not.toContain('curl -s -X POST'); }); it('should set timeout to 10 seconds (hook timeout fields are seconds)', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>; expect(notifHooks[0].hooks[0].timeout).toBe(10); }); it('should include correct event names in curl payloads', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; expect(notifHooks[0].hooks[0].command).toContain('idle_prompt'); expect(notifHooks[1].hooks[0].command).toContain('permission_prompt'); expect(notifHooks[2].hooks[0].command).toContain('elicitation_dialog'); const stopHooks = config.hooks.Stop as Array<{ hooks: Array<{ command: string }> }>; expect(stopHooks[0].hooks[0].command).toContain('stop'); }); it('should read stdin and forward as data field', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; // Should capture stdin via cat and include as $HOOK_DATA expect(notifHooks[0].hooks[0].command).toContain('HOOK_DATA=$(cat'); expect(notifHooks[0].hooks[0].command).toContain('$HOOK_DATA'); }); it('should set hook type to command', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ type: string }> }>; expect(notifHooks[0].hooks[0].type).toBe('command'); const stopHooks = config.hooks.Stop as Array<{ hooks: Array<{ type: string }> }>; expect(stopHooks[0].hooks[0].type).toBe('command'); }); }); describe('writeHooksConfig', () => { const testDir = join(tmpdir(), 'codeman-hooks-test-' + Date.now()); beforeEach(() => { if (!existsSync(testDir)) { mkdirSync(testDir, { recursive: true }); } }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); it('should create .claude directory if it does not exist', async () => { await writeHooksConfig(testDir); expect(existsSync(join(testDir, '.claude'))).toBe(true); }); it('should create settings.local.json', async () => { await writeHooksConfig(testDir); const settingsPath = join(testDir, '.claude', 'settings.local.json'); expect(existsSync(settingsPath)).toBe(true); }); it('should write valid JSON', async () => { await writeHooksConfig(testDir); const settingsPath = join(testDir, '.claude', 'settings.local.json'); const content = readFileSync(settingsPath, 'utf-8'); const parsed = JSON.parse(content); expect(parsed).toHaveProperty('hooks'); expect(parsed.hooks).toHaveProperty('Notification'); expect(parsed.hooks).toHaveProperty('Stop'); }); it('should include hooks config in output', async () => { await writeHooksConfig(testDir); const settingsPath = join(testDir, '.claude', 'settings.local.json'); const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(parsed.hooks).toBeDefined(); expect(parsed.hooks.Notification).toHaveLength(3); expect(parsed.hooks.Stop).toHaveLength(1); }); it('should merge with existing settings.local.json', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); writeFileSync( join(claudeDir, 'settings.local.json'), JSON.stringify({ existingKey: 'existingValue', permissions: { allow: ['Read'] } }, null, 2) ); await writeHooksConfig(testDir); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); expect(parsed.existingKey).toBe('existingValue'); expect(parsed.permissions).toEqual({ allow: ['Read'] }); expect(parsed.hooks).toBeDefined(); }); it('should upgrade Codeman-owned hooks that predate background rewake', async () => { const claudeDir = join(testDir, '.claude'); const settingsPath = join(claudeDir, 'settings.local.json'); mkdirSync(claudeDir, { recursive: true }); const oldHooks = generateHooksConfig().hooks; delete oldHooks.PostToolUse; writeFileSync(settingsPath, JSON.stringify({ hooks: oldHooks }, null, 2)); await refreshStaleCodemanHooks(testDir); const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(parsed.hooks.PostToolUse).toHaveLength(1); expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V'); }); it('should replace an older rewake script version without duplicating it', async () => { const claudeDir = join(testDir, '.claude'); const settingsPath = join(claudeDir, 'settings.local.json'); mkdirSync(claudeDir, { recursive: true }); // Simulate a case healed by the previous release: current curls (secret present) // plus a V1 rewake handler. The version bump must swap the handler in place. const hooks = generateHooksConfig().hooks; hooks.PostToolUse = [ { matcher: 'Bash', hooks: [ { type: 'command', command: 'node', args: ['-e', 'const CODEMAN_BACKGROUND_REWAKE_V1 = true; process.exit(0);'], asyncRewake: true, timeout: 21600, }, ], }, ]; writeFileSync(settingsPath, JSON.stringify({ hooks }, null, 2)); await refreshStaleCodemanHooks(testDir); const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); const serialized = JSON.stringify(parsed.hooks.PostToolUse); expect(parsed.hooks.PostToolUse).toHaveLength(1); expect(parsed.hooks.PostToolUse[0].hooks).toHaveLength(1); expect(serialized).toContain('CODEMAN_BACKGROUND_REWAKE_V2'); expect(serialized).not.toContain('CODEMAN_BACKGROUND_REWAKE_V1'); }); it('should not add rewake hooks to a user-owned hook configuration', async () => { const claudeDir = join(testDir, '.claude'); const settingsPath = join(claudeDir, 'settings.local.json'); mkdirSync(claudeDir, { recursive: true }); const userHooks = { PostToolUse: [ { matcher: 'Write', hooks: [{ type: 'command', command: './format.sh' }], }, ], }; writeFileSync(settingsPath, JSON.stringify({ hooks: userHooks }, null, 2)); await refreshStaleCodemanHooks(testDir); const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(parsed.hooks).toEqual(userHooks); }); it('should preserve user hook events while installing Codeman hooks', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2)); await writeHooksConfig(testDir); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); expect(parsed.hooks.oldHook).toEqual([]); expect(parsed.hooks.Notification).toBeDefined(); }); it('should handle malformed existing settings.local.json', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); writeFileSync(join(claudeDir, 'settings.local.json'), 'not valid json{{{'); await writeHooksConfig(testDir); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); expect(parsed.hooks).toBeDefined(); }); it('should end file with newline', async () => { await writeHooksConfig(testDir); const content = readFileSync(join(testDir, '.claude', 'settings.local.json'), 'utf-8'); expect(content.endsWith('\n')).toBe(true); }); }); describe('background task rewake helper', () => { const testDir = join(tmpdir(), 'codeman-background-rewake-test-' + Date.now()); beforeEach(() => { mkdirSync(testDir, { recursive: true }); }); afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); function runHelper(input: Record): Promise<{ code: number | null; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(process.execPath, ['-e', generateBackgroundWakeScript()], { stdio: ['pipe', 'ignore', 'pipe'], }); let stderr = ''; const timeout = setTimeout(() => { child.kill(); reject(new Error('background rewake helper timed out')); }, 5000); child.stderr.setEncoding('utf8'); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('error', reject); child.on('close', (code) => { clearTimeout(timeout); resolve({ code, stderr }); }); child.stdin.end(JSON.stringify(input)); }); } it('exits without waiting for an ordinary Bash result', async () => { const result = await runHelper({ transcript_path: join(testDir, 'transcript.jsonl'), tool_response: { stdout: 'ordinary command completed' }, }); expect(result.code).toBe(0); expect(result.stderr).toBe(''); }); it('exits 2 when the matching background command completes', async () => { const transcriptPath = join(testDir, 'transcript.jsonl'); writeFileSync(transcriptPath, ''); const resultPromise = runHelper({ transcript_path: transcriptPath, tool_response: { stdout: 'Command running in background with ID: bg-test-1. Output is being written to: /tmp/bg-test-1.output.', }, }); await new Promise((resolve) => setTimeout(resolve, 100)); writeFileSync( transcriptPath, JSON.stringify({ type: 'queue-operation', operation: 'enqueue', content: '\nbg-test-1\ncompleted\n' + '/tmp/bg-test-1.output\n', }) + '\n' ); const result = await resultPromise; expect(result.code).toBe(2); expect(result.stderr).toContain('bg-test-1'); expect(result.stderr).toContain('completed'); expect(result.stderr).toContain('/tmp/bg-test-1.output'); }); }); // ========== Hook Event API Integration Tests ========== // Port 3130 reserved for hooks integration tests import { WebServer } from '../src/web/server.js'; const TEST_PORT = 3130; describe('Hook Event API', () => { let server: WebServer; let baseUrl: string; let testSessionId: string; beforeAll(async () => { server = new WebServer(TEST_PORT, false, true); await server.start(); baseUrl = `http://localhost:${TEST_PORT}`; // Create a test session const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); const createData = await createRes.json(); testSessionId = createData.data.session.id; }); afterAll(async () => { // Clean up the test session if (testSessionId) { await fetch(`${baseUrl}/api/sessions/${testSessionId}`, { method: 'DELETE', }); } await server.stop(); }, 60000); describe('Valid Hook Events', () => { it('should accept idle_prompt event', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'idle_prompt', sessionId: testSessionId, }), }); const data = await res.json(); expect(res.status).toBe(200); expect(data.success).toBe(true); }); it('should accept permission_prompt event', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Bash' }, }), }); const data = await res.json(); expect(res.status).toBe(200); expect(data.success).toBe(true); }); it('should accept elicitation_dialog event', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'elicitation_dialog', sessionId: testSessionId, data: { question: 'What is your name?' }, }), }); const data = await res.json(); expect(res.status).toBe(200); expect(data.success).toBe(true); }); it('should accept stop event', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'stop', sessionId: testSessionId, }), }); const data = await res.json(); expect(res.status).toBe(200); expect(data.success).toBe(true); }); it('should accept event with tool_input data', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Bash', tool_input: { command: 'ls -la', description: 'List files', }, }, }), }); const data = await res.json(); expect(res.status).toBe(200); expect(data.success).toBe(true); }); }); describe('Invalid Hook Events', () => { it('should reject invalid event types', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'invalid_event', sessionId: testSessionId, }), }); const data = await res.json(); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); it('should reject missing event field', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: testSessionId, }), }); const data = await res.json(); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); it('should reject empty event field', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: '', sessionId: testSessionId, }), }); const data = await res.json(); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); it('should reject non-existent session', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'idle_prompt', sessionId: 'fake-session-id-12345', }), }); const data = await res.json(); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); it('should reject missing sessionId', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'idle_prompt', }), }); const data = await res.json(); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); }); }); describe('Hook Data Sanitization', () => { let server: WebServer; let baseUrl: string; let testSessionId: string; beforeAll(async () => { server = new WebServer(TEST_PORT + 1, false, true); // Port 3131 await server.start(); baseUrl = `http://localhost:${TEST_PORT + 1}`; // Create a test session const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}), }); const createData = await createRes.json(); testSessionId = createData.data.session.id; }); afterAll(async () => { if (testSessionId) { await fetch(`${baseUrl}/api/sessions/${testSessionId}`, { method: 'DELETE', }); } await server.stop(); }, 60000); it('should truncate long command in tool_input (verified via API)', async () => { const longCommand = 'a'.repeat(1000); // The sanitizeHookData function truncates command to 500 chars. // We verify by checking that the API accepts it (the truncation happens // server-side before broadcast). To fully verify truncation, we'd need // to inspect the SSE output, but SSE testing in Node.js requires more setup. const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Bash', tool_input: { command: longCommand }, }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should truncate long file_path in tool_input', async () => { const longPath = '/path/' + 'a'.repeat(1000); const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Read', tool_input: { file_path: longPath }, }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should only allow safe fields through', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Bash', secret_field: 'should-be-stripped', malicious_data: { nested: 'value' }, hook_event_name: 'permission_prompt', cwd: '/home/user/project', }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should handle empty data gracefully', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'idle_prompt', sessionId: testSessionId, data: {}, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should handle null data gracefully', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'idle_prompt', sessionId: testSessionId, data: null, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should handle undefined data gracefully', async () => { const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'stop', sessionId: testSessionId, // data field omitted }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should truncate description field to 200 chars', async () => { const longDescription = 'x'.repeat(500); const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Edit', tool_input: { description: longDescription }, }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should truncate query field to 200 chars', async () => { const longQuery = 'q'.repeat(500); const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Grep', tool_input: { query: longQuery }, }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should truncate url field to 500 chars', async () => { const longUrl = 'https://example.com/' + 'u'.repeat(1000); const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'WebFetch', tool_input: { url: longUrl }, }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should truncate pattern field to 200 chars', async () => { const longPattern = 'p'.repeat(500); const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Grep', tool_input: { pattern: longPattern }, }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); it('should truncate prompt field to 200 chars', async () => { const longPrompt = 'm'.repeat(500); const res = await fetch(`${baseUrl}/api/hook-event`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'permission_prompt', sessionId: testSessionId, data: { tool_name: 'Task', tool_input: { prompt: longPrompt }, }, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); }); }); describe('Hook Config Generation - Extended', () => { it('should generate valid JSON structure', () => { const config = generateHooksConfig(); expect(config.hooks).toBeDefined(); expect(config.hooks.Notification).toHaveLength(3); expect(config.hooks.Stop).toHaveLength(1); }); it('should include all event types', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; const matchers = notifHooks.map((n) => n.matcher); expect(matchers).toContain('idle_prompt'); expect(matchers).toContain('permission_prompt'); expect(matchers).toContain('elicitation_dialog'); }); it('should use environment variable placeholders', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; const cmd = notifHooks[0].hooks[0].command; expect(cmd).toContain('$CODEMAN_API_URL'); expect(cmd).toContain('$CODEMAN_SESSION_ID'); }); it('should generate POST curl commands', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; const cmd = notifHooks[0].hooks[0].command; expect(cmd).toContain('curl'); expect(cmd).toContain('-X POST'); expect(cmd).toContain('Content-Type: application/json'); }); it('should forward event name in curl payload', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher: string; hooks: Array<{ command: string }> }>; for (const hook of notifHooks) { const cmd = hook.hooks[0].command; // The printf format string contains the event name baked in expect(cmd).toContain(`"event":"${hook.matcher}"`); } }); it('should use 2>/dev/null for curl errors', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; const cmd = notifHooks[0].hooks[0].command; expect(cmd).toContain('2>/dev/null'); }); it('should handle stdin capture for hook data', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; const cmd = notifHooks[0].hooks[0].command; expect(cmd).toContain('HOOK_DATA=$(cat'); // Data is piped to curl via stdin (--data @-) to prevent shell injection expect(cmd).toContain('$HOOK_DATA'); expect(cmd).toContain('--data @-'); }); it('should have consistent structure across all notification hooks', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher: string; hooks: Array<{ type: string; command: string; timeout: number }>; }>; for (const hook of notifHooks) { expect(hook.matcher).toBeDefined(); expect(hook.hooks).toHaveLength(1); expect(hook.hooks[0].type).toBe('command'); expect(hook.hooks[0].timeout).toBe(10); expect(hook.hooks[0].command).toBeTruthy(); } }); it('should pipe data to curl via stdin to prevent shell injection', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ command: string }> }>; const cmd = notifHooks[0].hooks[0].command; // HOOK_DATA must NOT be embedded unquoted in a -d "..." argument (shell injection vector) expect(cmd).not.toMatch(/-d\s+"[^"]*\$HOOK_DATA/); // Instead, data should be piped to curl via stdin expect(cmd).toContain('printf'); expect(cmd).toContain('| curl'); expect(cmd).toContain('--data @-'); }); it('should have stop hook without matcher (catches all)', () => { const config = generateHooksConfig(); const stopHooks = config.hooks.Stop as Array<{ matcher?: string; hooks: Array<{ command: string }> }>; expect(stopHooks).toHaveLength(1); expect(stopHooks[0].matcher).toBeUndefined(); expect(stopHooks[0].hooks[0].command).toContain('stop'); }); });