Skip to content

Commit 231ce27

Browse files
test: add branch coverage for container-lifecycle.ts uncovered paths (#4197)
- Cover reportBlockedDomains 'else' branch (allowed domain, standard port) - Cover checkSquidLogs IPv6 target with non-numeric trailing segment - Cover checkSquidLogs with TCP_TUNNEL-only log (no denied entries) - Cover didApiProxyFailStartup via inspect returning 'running|unhealthy' and 'exited|unhealthy' - Cover runAgentCommand zero-exit suppression of blocked-domain warnings Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent edcada6 commit 231ce27

1 file changed

Lines changed: 248 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* Targeted branch-coverage tests for container-lifecycle.ts.
3+
*
4+
* These tests cover paths not exercised by docker-manager-lifecycle.test.ts:
5+
* - reportBlockedDomains "else" branch (domain allowed, standard port, other reason)
6+
* - checkSquidLogs IPv6 target with non-numeric trailing segment
7+
* - checkSquidLogs with no TCP_DENIED entries (empty log coverage)
8+
* - didApiProxyFailStartup with healthStatus === 'unhealthy' from inspect output
9+
*/
10+
11+
import { startContainers, runAgentCommand } from './container-lifecycle';
12+
import { containerLifecycleTestHelpers } from './container-lifecycle.test-utils';
13+
import { logger } from './logger';
14+
import * as fs from 'fs';
15+
import * as path from 'path';
16+
import * as os from 'os';
17+
18+
import { mockExecaFn } from './test-helpers/mock-execa.test-utils';
19+
// eslint-disable-next-line @typescript-eslint/no-require-imports
20+
jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory());
21+
22+
function makeExecaResult(stdout = '', stderr = '', exitCode = 0): any {
23+
return { stdout, stderr, exitCode };
24+
}
25+
26+
describe('container-lifecycle uncovered branches', () => {
27+
let testDir: string;
28+
29+
beforeEach(() => {
30+
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-'));
31+
jest.clearAllMocks();
32+
containerLifecycleTestHelpers.resetAgentExternallyKilled();
33+
});
34+
35+
afterEach(() => {
36+
if (fs.existsSync(testDir)) {
37+
fs.rmSync(testDir, { recursive: true, force: true });
38+
}
39+
});
40+
41+
// ─── reportBlockedDomains "else" branch ──────────────────────────────────────
42+
// This branch is hit when the domain IS in the allowlist but blocked on a
43+
// standard port (80 or 443). Squid shouldn't normally produce this combination,
44+
// but the code handles it as "Other reason (shouldn't happen often)".
45+
46+
describe('reportBlockedDomains - allowed domain on standard port', () => {
47+
it('should log generic blocked message when allowed domain is blocked on port 443', async () => {
48+
const squidLogsDir = path.join(testDir, 'squid-logs');
49+
fs.mkdirSync(squidLogsDir, { recursive: true });
50+
// github.com:443 — domain IS in the allowlist, port IS standard
51+
fs.writeFileSync(
52+
path.join(squidLogsDir, 'access.log'),
53+
'1760994429.358 172.30.0.20:36274 github.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE github.com:443 "curl/7.81.0"\n'
54+
);
55+
56+
mockExecaFn.mockResolvedValueOnce(makeExecaResult()); // docker logs -f
57+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('1')); // docker wait
58+
59+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
60+
try {
61+
const result = await runAgentCommand(testDir, ['github.com']);
62+
expect(result.exitCode).toBe(1);
63+
// The "else" branch emits "Blocked: github.com:443" without extra context
64+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(' - Blocked: github.com:443'));
65+
// Should NOT say "domain not in allowlist" or "port ... not allowed"
66+
const allWarnings = warnSpy.mock.calls.map(([m]) => m).join('\n');
67+
expect(allWarnings).not.toContain('domain not in allowlist');
68+
expect(allWarnings).not.toContain('not allowed');
69+
} finally {
70+
warnSpy.mockRestore();
71+
}
72+
});
73+
74+
it('should log generic blocked message when allowed domain is blocked on port 80', async () => {
75+
const squidLogsDir = path.join(testDir, 'squid-logs');
76+
fs.mkdirSync(squidLogsDir, { recursive: true });
77+
fs.writeFileSync(
78+
path.join(squidLogsDir, 'access.log'),
79+
'1760994429.358 172.30.0.20:36274 github.com:80 -:- 1.1 GET 403 TCP_DENIED:HIER_NONE github.com:80 "curl/7.81.0"\n'
80+
);
81+
82+
mockExecaFn.mockResolvedValueOnce(makeExecaResult()); // docker logs -f
83+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('1')); // docker wait
84+
85+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
86+
try {
87+
await runAgentCommand(testDir, ['github.com']);
88+
// "else" branch — generic message, no port complaint
89+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(' - Blocked: github.com:80'));
90+
const allWarnings = warnSpy.mock.calls.map(([m]) => m).join('\n');
91+
expect(allWarnings).not.toContain('port 80 not allowed');
92+
expect(allWarnings).not.toContain('domain not in allowlist');
93+
} finally {
94+
warnSpy.mockRestore();
95+
}
96+
});
97+
});
98+
99+
// ─── checkSquidLogs IPv6 target parsing ─────────────────────────────────────
100+
// When a Squid log entry contains an IPv6 address, the simple lastIndexOf(':')
101+
// trick extracts a non-numeric "port", triggering the fallback that treats the
102+
// entire target string as the domain with no port.
103+
104+
describe('checkSquidLogs - IPv6 targets', () => {
105+
it('should handle IPv6 target where extracted "port" is non-numeric', async () => {
106+
const squidLogsDir = path.join(testDir, 'squid-logs');
107+
fs.mkdirSync(squidLogsDir, { recursive: true });
108+
// Simulate an IPv6 address without a numeric port suffix; the last segment
109+
// after ':' would be something like "abc" (non-numeric) so the code falls
110+
// back to domain = target, port = undefined.
111+
// E.g. target = "2001:db8::abc" → lastIndexOf(':') → segment "abc" (non-numeric)
112+
fs.writeFileSync(
113+
path.join(squidLogsDir, 'access.log'),
114+
'1760994429.358 172.30.0.20:36274 2001:db8::abc -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE 2001:db8::abc "curl/7.81.0"\n'
115+
);
116+
117+
mockExecaFn.mockResolvedValueOnce(makeExecaResult()); // docker logs -f
118+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('1')); // docker wait
119+
120+
const result = await runAgentCommand(testDir, []);
121+
expect(result.exitCode).toBe(1);
122+
// The full IPv6 target is treated as the domain (no port extracted)
123+
expect(result.blockedDomains).toContain('2001:db8::abc');
124+
});
125+
126+
it('should correctly parse bracketed IPv6 target with port', async () => {
127+
const squidLogsDir = path.join(testDir, 'squid-logs');
128+
fs.mkdirSync(squidLogsDir, { recursive: true });
129+
// [::1]:443 — port after last ':' is "443" (numeric), so domain = "[::1]", port = "443"
130+
fs.writeFileSync(
131+
path.join(squidLogsDir, 'access.log'),
132+
'1760994429.358 172.30.0.20:36274 [::1]:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE [::1]:443 "curl/7.81.0"\n'
133+
);
134+
135+
mockExecaFn.mockResolvedValueOnce(makeExecaResult()); // docker logs -f
136+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('1')); // docker wait
137+
138+
const result = await runAgentCommand(testDir, []);
139+
expect(result.exitCode).toBe(1);
140+
// Domain should be "[::1]" without the port
141+
expect(result.blockedDomains).toContain('[::1]');
142+
});
143+
});
144+
145+
// ─── checkSquidLogs - no denied entries ──────────────────────────────────────
146+
147+
describe('checkSquidLogs - log with only allowed entries', () => {
148+
it('should return empty blockedDomains when log has TCP_TUNNEL entries only', async () => {
149+
const squidLogsDir = path.join(testDir, 'squid-logs');
150+
fs.mkdirSync(squidLogsDir, { recursive: true });
151+
// TCP_TUNNEL (allowed) — no TCP_DENIED lines
152+
fs.writeFileSync(
153+
path.join(squidLogsDir, 'access.log'),
154+
'1760994429.358 172.30.0.20:36274 github.com:443 -:- 1.1 CONNECT 200 TCP_TUNNEL:HIER_DIRECT github.com:443 "curl/7.81.0"\n'
155+
);
156+
157+
mockExecaFn.mockResolvedValueOnce(makeExecaResult()); // docker logs -f
158+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('0')); // docker wait — success
159+
160+
const result = await runAgentCommand(testDir, ['github.com']);
161+
expect(result.exitCode).toBe(0);
162+
expect(result.blockedDomains).toEqual([]);
163+
});
164+
});
165+
166+
// ─── didApiProxyFailStartup - healthStatus === 'unhealthy' from inspect ──────
167+
// This covers the branch where docker inspect returns "running|unhealthy" (health
168+
// check is failing while container is still alive), which also triggers the retry.
169+
170+
describe('startContainers - api-proxy unhealthy via inspect health status', () => {
171+
it('should retry when docker inspect reports running but unhealthy health status', async () => {
172+
// 1. docker rm (initial cleanup)
173+
mockExecaFn.mockResolvedValueOnce(makeExecaResult());
174+
// 2. docker compose up (first attempt — generic error, not api-proxy in message)
175+
mockExecaFn.mockRejectedValueOnce(new Error('Command failed: docker compose up -d'));
176+
// 3. docker inspect awf-api-proxy → "running|unhealthy"
177+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('running|unhealthy'));
178+
// 4. docker logs (diagnosis before retry)
179+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('api-proxy logs'));
180+
// 5. docker compose down (cleanup before retry)
181+
mockExecaFn.mockResolvedValueOnce(makeExecaResult());
182+
// 6. docker compose up (retry — succeeds)
183+
mockExecaFn.mockResolvedValueOnce(makeExecaResult());
184+
185+
await expect(startContainers(testDir, ['github.com'])).resolves.toBeUndefined();
186+
187+
// Confirm two compose-up calls were made (initial + retry)
188+
const upCalls = mockExecaFn.mock.calls.filter((call: any[]) =>
189+
call[0] === 'docker' && Array.isArray(call[1]) && call[1].includes('up')
190+
);
191+
expect(upCalls).toHaveLength(2);
192+
});
193+
194+
it('should retry when docker inspect reports exited|unhealthy', async () => {
195+
// 1. docker rm (initial cleanup)
196+
mockExecaFn.mockResolvedValueOnce(makeExecaResult());
197+
// 2. docker compose up (first attempt — generic error)
198+
mockExecaFn.mockRejectedValueOnce(new Error('Command failed: docker compose up -d'));
199+
// 3. docker inspect awf-api-proxy → "exited|unhealthy"
200+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('exited|unhealthy'));
201+
// 4. docker logs
202+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('api-proxy logs'));
203+
// 5. docker compose down
204+
mockExecaFn.mockResolvedValueOnce(makeExecaResult());
205+
// 6. docker compose up (retry — succeeds)
206+
mockExecaFn.mockResolvedValueOnce(makeExecaResult());
207+
208+
await expect(startContainers(testDir, ['github.com'])).resolves.toBeUndefined();
209+
210+
const upCalls = mockExecaFn.mock.calls.filter((call: any[]) =>
211+
call[0] === 'docker' && Array.isArray(call[1]) && call[1].includes('up')
212+
);
213+
expect(upCalls).toHaveLength(2);
214+
});
215+
});
216+
217+
// ─── runAgentCommand - exit code 0 with blocked domains (no warning) ─────────
218+
// When exit code is 0 but there are denied entries, the blocked-domains warning
219+
// should NOT be emitted (only non-zero exits trigger it).
220+
221+
describe('runAgentCommand - zero exit code suppresses blocked-domain warning', () => {
222+
it('should not warn about blocked domains when exit code is 0', async () => {
223+
const squidLogsDir = path.join(testDir, 'squid-logs');
224+
fs.mkdirSync(squidLogsDir, { recursive: true });
225+
fs.writeFileSync(
226+
path.join(squidLogsDir, 'access.log'),
227+
'1760994429.358 172.30.0.20:36274 blocked.com:443 -:- 1.1 CONNECT 403 TCP_DENIED:HIER_NONE blocked.com:443 "curl/7.81.0"\n'
228+
);
229+
230+
mockExecaFn.mockResolvedValueOnce(makeExecaResult()); // docker logs -f
231+
mockExecaFn.mockResolvedValueOnce(makeExecaResult('0')); // docker wait — exit 0
232+
233+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
234+
try {
235+
const result = await runAgentCommand(testDir, ['github.com']);
236+
expect(result.exitCode).toBe(0);
237+
// Blocked domains are still returned but no user-facing warning is emitted
238+
expect(result.blockedDomains).toContain('blocked.com');
239+
const warningsAboutBlocked = warnSpy.mock.calls.filter(([m]) =>
240+
typeof m === 'string' && m.toLowerCase().includes('blocked')
241+
);
242+
expect(warningsAboutBlocked).toHaveLength(0);
243+
} finally {
244+
warnSpy.mockRestore();
245+
}
246+
});
247+
});
248+
});

0 commit comments

Comments
 (0)