|
1 | 1 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
2 | 2 | import { flushDnsCache } from './dns-cache.js'; |
3 | 3 |
|
4 | | -// Mock child_process spawn |
5 | | -const mockOn = vi.fn(); |
6 | | -const mockStdout = { on: vi.fn() }; |
7 | | -const mockStderr = { on: vi.fn() }; |
| 4 | +interface FakeProcess { |
| 5 | + stdout?: string; |
| 6 | + stderr?: string; |
| 7 | + code?: number; |
| 8 | + error?: Error; |
| 9 | +} |
| 10 | + |
| 11 | +const spawnCalls: Array<{ command: string; args: string[] }> = []; |
| 12 | +let respond: (command: string, args: string[]) => FakeProcess; |
8 | 13 |
|
9 | 14 | vi.mock('child_process', () => ({ |
10 | | - spawn: vi.fn(() => { |
| 15 | + spawn: (command: string, args: string[]) => { |
| 16 | + spawnCalls.push({ command, args }); |
| 17 | + const result = respond(command, args); |
| 18 | + |
11 | 19 | return { |
12 | | - stdout: mockStdout, |
13 | | - stderr: mockStderr, |
14 | | - on: (event: string, callback: (code: number) => void) => { |
15 | | - if (event === 'close') { |
16 | | - // Simulate successful execution by default |
17 | | - setTimeout(() => callback(0), 0); |
| 20 | + stdout: { |
| 21 | + on: (event: string, callback: (data: Buffer) => void) => { |
| 22 | + if (event === 'data' && result.stdout) { |
| 23 | + setTimeout(() => callback(Buffer.from(result.stdout as string)), 0); |
| 24 | + } |
| 25 | + }, |
| 26 | + }, |
| 27 | + stderr: { |
| 28 | + on: (event: string, callback: (data: Buffer) => void) => { |
| 29 | + if (event === 'data' && result.stderr) { |
| 30 | + setTimeout(() => callback(Buffer.from(result.stderr as string)), 0); |
| 31 | + } |
| 32 | + }, |
| 33 | + }, |
| 34 | + on: (event: string, callback: (arg?: number | Error) => void) => { |
| 35 | + if (event === 'error' && result.error) { |
| 36 | + setTimeout(() => callback(result.error), 0); |
| 37 | + } |
| 38 | + if (event === 'close' && !result.error) { |
| 39 | + setTimeout(() => callback(result.code ?? 0), 0); |
18 | 40 | } |
19 | | - return mockOn(event, callback); |
20 | 41 | }, |
21 | 42 | }; |
22 | | - }), |
| 43 | + }, |
23 | 44 | })); |
24 | 45 |
|
25 | | -describe('dns-cache', () => { |
| 46 | +describe('flushDnsCache', () => { |
26 | 47 | beforeEach(() => { |
27 | | - vi.clearAllMocks(); |
28 | | - mockStdout.on.mockReset(); |
29 | | - mockStderr.on.mockReset(); |
30 | | - mockOn.mockReset(); |
31 | | - |
32 | | - // Setup default mock behavior |
33 | | - mockStdout.on.mockImplementation(() => { |
34 | | - // No output by default |
35 | | - }); |
36 | | - mockStderr.on.mockImplementation(() => { |
37 | | - // No error output by default |
38 | | - }); |
| 48 | + spawnCalls.length = 0; |
| 49 | + respond = () => ({ code: 0 }); |
| 50 | + vi.spyOn(process, 'getuid').mockReturnValue(501); |
39 | 51 | }); |
40 | 52 |
|
41 | 53 | afterEach(() => { |
42 | 54 | vi.restoreAllMocks(); |
43 | 55 | }); |
44 | 56 |
|
45 | | - describe('flushDnsCache', () => { |
46 | | - it('should return a MaintenanceResult', async () => { |
47 | | - const result = await flushDnsCache(); |
| 57 | + it('should require sudo when not root and sudo needs a password', async () => { |
| 58 | + respond = () => ({ code: 1, stderr: 'a password is required' }); |
48 | 59 |
|
49 | | - expect(result).toHaveProperty('success'); |
50 | | - expect(result).toHaveProperty('message'); |
51 | | - expect(typeof result.success).toBe('boolean'); |
52 | | - expect(typeof result.message).toBe('string'); |
53 | | - }); |
| 60 | + const result = await flushDnsCache(); |
54 | 61 |
|
55 | | - it('should have error property when fails', async () => { |
56 | | - // The function checks for sudo permissions first |
57 | | - // If not running as root and can't sudo, it should fail with requiresSudo |
58 | | - const result = await flushDnsCache(); |
59 | | - |
60 | | - // Either it succeeds (if running with sudo) or it fails with proper error |
61 | | - expect(result).toHaveProperty('success'); |
62 | | - expect(result).toHaveProperty('message'); |
63 | | - |
64 | | - if (!result.success) { |
65 | | - expect(result.error || result.requiresSudo).toBeDefined(); |
66 | | - } |
| 62 | + expect(result).toEqual({ |
| 63 | + success: false, |
| 64 | + message: 'DNS cache flush requires administrator privileges', |
| 65 | + error: 'Run with sudo: sudo mac-cleaner-cli maintenance --dns', |
| 66 | + requiresSudo: true, |
67 | 67 | }); |
| 68 | + expect(spawnCalls).toEqual([{ command: 'sudo', args: ['-n', 'true'] }]); |
| 69 | + }); |
68 | 70 |
|
69 | | - it('should have requiresSudo property when sudo is needed', async () => { |
70 | | - const result = await flushDnsCache(); |
71 | | - |
72 | | - // The result should have proper structure |
73 | | - expect(result).toHaveProperty('success'); |
74 | | - expect(result).toHaveProperty('message'); |
75 | | - |
76 | | - // If it needs sudo, requiresSudo should be set |
77 | | - if (!result.success && result.message.includes('privileges')) { |
78 | | - expect(result.requiresSudo).toBe(true); |
79 | | - } |
80 | | - }); |
| 71 | + it('should flush the cache directly when running as root', async () => { |
| 72 | + vi.spyOn(process, 'getuid').mockReturnValue(0); |
| 73 | + |
| 74 | + const result = await flushDnsCache(); |
| 75 | + |
| 76 | + expect(result).toEqual({ success: true, message: 'DNS cache flushed successfully' }); |
| 77 | + expect(spawnCalls).toEqual([ |
| 78 | + { command: '/usr/bin/dscacheutil', args: ['-flushcache'] }, |
| 79 | + { command: '/usr/bin/killall', args: ['-HUP', 'mDNSResponder'] }, |
| 80 | + ]); |
| 81 | + }); |
| 82 | + |
| 83 | + it('should flush the cache through sudo when passwordless sudo works', async () => { |
| 84 | + const result = await flushDnsCache(); |
| 85 | + |
| 86 | + expect(result.success).toBe(true); |
| 87 | + expect(spawnCalls).toEqual([ |
| 88 | + { command: 'sudo', args: ['-n', 'true'] }, |
| 89 | + { command: 'sudo', args: ['-n', '/usr/bin/dscacheutil', '-flushcache'] }, |
| 90 | + { command: 'sudo', args: ['-n', '/usr/bin/killall', '-HUP', 'mDNSResponder'] }, |
| 91 | + ]); |
| 92 | + }); |
| 93 | + |
| 94 | + it('should report that sudo is required when the flush is not permitted', async () => { |
| 95 | + vi.spyOn(process, 'getuid').mockReturnValue(0); |
| 96 | + respond = () => ({ code: 1, stderr: 'dscacheutil: Operation not permitted' }); |
| 97 | + |
| 98 | + const result = await flushDnsCache(); |
| 99 | + |
| 100 | + expect(result.success).toBe(false); |
| 101 | + expect(result.message).toBe('Failed to flush DNS cache'); |
| 102 | + expect(result.error).toBe('Run with sudo: sudo mac-cleaner-cli maintenance --dns'); |
| 103 | + expect(result.requiresSudo).toBe(true); |
| 104 | + }); |
| 105 | + |
| 106 | + it('should surface other failures verbatim', async () => { |
| 107 | + vi.spyOn(process, 'getuid').mockReturnValue(0); |
| 108 | + respond = () => ({ code: 1, stderr: 'mDNSResponder: no matching processes' }); |
| 109 | + |
| 110 | + const result = await flushDnsCache(); |
| 111 | + |
| 112 | + expect(result.success).toBe(false); |
| 113 | + expect(result.error).toBe('mDNSResponder: no matching processes'); |
| 114 | + expect(result.requiresSudo).toBe(false); |
| 115 | + }); |
| 116 | + |
| 117 | + it('should surface spawn errors', async () => { |
| 118 | + vi.spyOn(process, 'getuid').mockReturnValue(0); |
| 119 | + respond = () => ({ error: new Error('spawn ENOENT') }); |
| 120 | + |
| 121 | + const result = await flushDnsCache(); |
| 122 | + |
| 123 | + expect(result.success).toBe(false); |
| 124 | + expect(result.error).toBe('spawn ENOENT'); |
| 125 | + }); |
| 126 | + |
| 127 | + it('should fail when killall fails after a successful flush', async () => { |
| 128 | + vi.spyOn(process, 'getuid').mockReturnValue(0); |
| 129 | + respond = (command) => { |
| 130 | + if (command === '/usr/bin/killall') return { code: 1, stderr: 'killall failed' }; |
| 131 | + return { code: 0 }; |
| 132 | + }; |
| 133 | + |
| 134 | + const result = await flushDnsCache(); |
| 135 | + |
| 136 | + expect(result.success).toBe(false); |
| 137 | + expect(result.error).toBe('killall failed'); |
81 | 138 | }); |
82 | 139 | }); |
0 commit comments