Skip to content

Commit 8310b55

Browse files
github-actions[bot]CopilotCopilot
authored
[Test Coverage] Add branch-coverage tests for container-cleanup.ts (#3316)
* test: add branch-coverage tests for container-cleanup.ts Cover previously untested branches in container-cleanup.ts: - sanitizeDockerComposeYaml: null/non-object YAML, missing services, array services, service without environment, array-form env entries with no '=' separator - cleanup(): cli-proxy log preservation (with/without proxyLogsDir), api-proxy-logs inside proxyLogsDir, sessionStateDir specified but missing, auditDir specified but missing, empty default audit dir, unmountSslTmpfs invocation when ssl/ dir exists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: address container cleanup review feedback --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 9106b75 commit 8310b55

1 file changed

Lines changed: 363 additions & 0 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
/**
2+
* Targeted branch-coverage tests for container-cleanup.ts.
3+
*
4+
* These tests cover paths that were not exercised by the existing
5+
* docker-manager-cleanup.test.ts suite, focusing on:
6+
* - sanitizeDockerComposeYaml edge cases
7+
* - cleanup() branches for cli-proxy logs, audit dir, session state, and SSL
8+
*/
9+
10+
import { cleanup, collectDiagnosticLogs } from './container-cleanup';
11+
import * as fs from 'fs';
12+
import * as path from 'path';
13+
import * as os from 'os';
14+
import * as yaml from 'js-yaml';
15+
16+
// Mock execa
17+
import { mockExecaFn, mockExecaSync } from './test-helpers/mock-execa.test-utils';
18+
// eslint-disable-next-line @typescript-eslint/no-require-imports
19+
jest.mock('execa', () => require('./test-helpers/mock-execa.test-utils').execaMockFactory());
20+
21+
// Mock ssl-bump so cleanup() doesn't attempt real mount operations
22+
jest.mock('./ssl-bump', () => ({
23+
cleanupSslKeyMaterial: jest.fn(),
24+
unmountSslTmpfs: jest.fn().mockResolvedValue(undefined),
25+
}));
26+
27+
jest.mock('./host-env', () => {
28+
const actual = jest.requireActual('./host-env');
29+
return {
30+
...actual,
31+
getSafeHostUid: () => String(process.getuid?.() ?? 1000),
32+
getSafeHostGid: () => String(process.getgid?.() ?? 1000),
33+
};
34+
});
35+
36+
// ─── helpers ─────────────────────────────────────────────────────────────────
37+
38+
function makeTmpDir(): string {
39+
return fs.mkdtempSync(path.join(os.tmpdir(), 'awf-'));
40+
}
41+
42+
// ─── sanitizeDockerComposeYaml edge cases (via collectDiagnosticLogs) ────────
43+
44+
describe('sanitizeDockerComposeYaml edge cases', () => {
45+
let testDir: string;
46+
47+
beforeEach(() => {
48+
testDir = makeTmpDir();
49+
jest.clearAllMocks();
50+
mockExecaFn.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
51+
});
52+
53+
afterEach(() => {
54+
if (fs.existsSync(testDir)) {
55+
fs.rmSync(testDir, { recursive: true, force: true });
56+
}
57+
});
58+
59+
it('returns raw string when YAML parses to a non-object (null)', async () => {
60+
// "null" is valid YAML that parses to null — the sanitizer should return the raw content
61+
fs.writeFileSync(path.join(testDir, 'docker-compose.yml'), 'null');
62+
await collectDiagnosticLogs(testDir);
63+
const diagnosticsDir = path.join(testDir, 'diagnostics');
64+
const sanitized = fs.readFileSync(path.join(diagnosticsDir, 'docker-compose.yml'), 'utf8');
65+
expect(sanitized).toBe('null');
66+
});
67+
68+
it('sanitizes when compose has no services key', async () => {
69+
// Parsed object but without a "services" key — should dump the yaml without error
70+
fs.writeFileSync(path.join(testDir, 'docker-compose.yml'), 'version: "3"\n');
71+
await collectDiagnosticLogs(testDir);
72+
const diagnosticsDir = path.join(testDir, 'diagnostics');
73+
const sanitized = fs.readFileSync(path.join(diagnosticsDir, 'docker-compose.yml'), 'utf8');
74+
expect(yaml.load(sanitized)).toEqual({ version: '3' });
75+
});
76+
77+
it('sanitizes when services is an array instead of an object', async () => {
78+
// services is an array — should be treated as "no services to sanitize"
79+
fs.writeFileSync(
80+
path.join(testDir, 'docker-compose.yml'),
81+
['version: "3"', 'services:', ' - name: agent'].join('\n')
82+
);
83+
await collectDiagnosticLogs(testDir);
84+
const diagnosticsDir = path.join(testDir, 'diagnostics');
85+
const sanitized = fs.readFileSync(path.join(diagnosticsDir, 'docker-compose.yml'), 'utf8');
86+
expect(yaml.load(sanitized)).toEqual({ version: '3', services: [{ name: 'agent' }] });
87+
});
88+
89+
it('skips service entries that are not plain objects', async () => {
90+
// A service entry whose value is a primitive (null/string) — should not throw
91+
const raw = ['services:', ' broken_service: null', ' valid_service:', ' image: nginx'].join('\n');
92+
fs.writeFileSync(path.join(testDir, 'docker-compose.yml'), raw);
93+
await collectDiagnosticLogs(testDir);
94+
const diagnosticsDir = path.join(testDir, 'diagnostics');
95+
const sanitized = fs.readFileSync(path.join(diagnosticsDir, 'docker-compose.yml'), 'utf8');
96+
expect(yaml.load(sanitized)).toEqual({
97+
services: {
98+
broken_service: null,
99+
valid_service: {
100+
image: 'nginx',
101+
},
102+
},
103+
});
104+
});
105+
106+
it('preserves all env vars when service has no environment key', async () => {
107+
// Service without an "environment" field — no redaction needed
108+
const raw = ['services:', ' agent:', ' image: ubuntu:22.04'].join('\n');
109+
fs.writeFileSync(path.join(testDir, 'docker-compose.yml'), raw);
110+
await collectDiagnosticLogs(testDir);
111+
const sanitized = fs.readFileSync(
112+
path.join(testDir, 'diagnostics', 'docker-compose.yml'),
113+
'utf8'
114+
);
115+
expect(sanitized).not.toContain('[REDACTED]');
116+
expect(yaml.load(sanitized)).toEqual({
117+
services: {
118+
agent: {
119+
image: 'ubuntu:22.04',
120+
},
121+
},
122+
});
123+
});
124+
125+
it('redacts secrets in array-form environment entries', async () => {
126+
// Array-style environment (list of KEY=VALUE strings)
127+
const raw = [
128+
'services:',
129+
' agent:',
130+
' environment:',
131+
' - GITHUB_TOKEN=ghp_array',
132+
' - NORMAL_VAR=keep_me',
133+
' - NO_EQUALS_HERE',
134+
].join('\n');
135+
fs.writeFileSync(path.join(testDir, 'docker-compose.yml'), raw);
136+
await collectDiagnosticLogs(testDir);
137+
const sanitized = fs.readFileSync(
138+
path.join(testDir, 'diagnostics', 'docker-compose.yml'),
139+
'utf8'
140+
);
141+
expect(sanitized).not.toContain('ghp_array');
142+
expect(sanitized).toContain('keep_me');
143+
// Entry without "=" should be preserved unchanged
144+
expect(sanitized).toContain('NO_EQUALS_HERE');
145+
});
146+
});
147+
148+
// ─── cleanup() missing branch coverage ───────────────────────────────────────
149+
150+
describe('cleanup - cli-proxy logs', () => {
151+
let testDir: string;
152+
153+
beforeEach(() => {
154+
testDir = makeTmpDir();
155+
jest.clearAllMocks();
156+
mockExecaSync.mockReturnValue(undefined);
157+
});
158+
159+
afterEach(() => {
160+
if (fs.existsSync(testDir)) {
161+
fs.rmSync(testDir, { recursive: true, force: true });
162+
}
163+
});
164+
165+
it('chmods cli-proxy-logs inside proxyLogsDir when it exists', async () => {
166+
const proxyLogsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-proxy-'));
167+
try {
168+
const cliProxyLogsDir = path.join(proxyLogsDir, 'cli-proxy-logs');
169+
fs.mkdirSync(cliProxyLogsDir, { recursive: true });
170+
fs.writeFileSync(path.join(cliProxyLogsDir, 'difc-proxy.log'), 'audit entry\n');
171+
172+
await cleanup(testDir, false, proxyLogsDir);
173+
174+
expect(mockExecaSync).toHaveBeenCalledWith('chmod', ['-R', 'a+rX', cliProxyLogsDir]);
175+
} finally {
176+
if (fs.existsSync(proxyLogsDir)) {
177+
fs.rmSync(proxyLogsDir, { recursive: true, force: true });
178+
}
179+
}
180+
});
181+
182+
it('moves non-empty cli-proxy-logs to /tmp when proxyLogsDir is not specified', async () => {
183+
const cliProxyLogsDir = path.join(testDir, 'cli-proxy-logs');
184+
fs.mkdirSync(cliProxyLogsDir, { recursive: true });
185+
fs.writeFileSync(path.join(cliProxyLogsDir, 'difc-proxy.log'), 'audit entry\n');
186+
187+
await cleanup(testDir, false);
188+
189+
const timestamp = path.basename(testDir).replace('awf-', '');
190+
const destination = path.join(os.tmpdir(), `cli-proxy-logs-${timestamp}`);
191+
expect(fs.existsSync(destination)).toBe(true);
192+
const movedLogPath = path.join(destination, 'difc-proxy.log');
193+
expect(fs.existsSync(movedLogPath)).toBe(true);
194+
expect(fs.readFileSync(movedLogPath, 'utf8')).toBe('audit entry\n');
195+
// testDir is deleted by cleanup; clean up the destination
196+
if (fs.existsSync(destination)) {
197+
fs.rmSync(destination, { recursive: true, force: true });
198+
}
199+
});
200+
201+
it('does not move empty cli-proxy-logs directory', async () => {
202+
const cliProxyLogsDir = path.join(testDir, 'cli-proxy-logs');
203+
fs.mkdirSync(cliProxyLogsDir, { recursive: true });
204+
// leave it empty
205+
206+
await cleanup(testDir, false);
207+
208+
const timestamp = path.basename(testDir).replace('awf-', '');
209+
const destination = path.join(os.tmpdir(), `cli-proxy-logs-${timestamp}`);
210+
expect(fs.existsSync(destination)).toBe(false);
211+
});
212+
});
213+
214+
describe('cleanup - api-proxy logs via proxyLogsDir', () => {
215+
let testDir: string;
216+
217+
beforeEach(() => {
218+
testDir = makeTmpDir();
219+
jest.clearAllMocks();
220+
mockExecaSync.mockReturnValue(undefined);
221+
});
222+
223+
afterEach(() => {
224+
if (fs.existsSync(testDir)) {
225+
fs.rmSync(testDir, { recursive: true, force: true });
226+
}
227+
});
228+
229+
it('chmods api-proxy-logs inside proxyLogsDir when it exists and is non-empty', async () => {
230+
const proxyLogsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-proxy-'));
231+
try {
232+
const apiProxyLogsDir = path.join(proxyLogsDir, 'api-proxy-logs');
233+
fs.mkdirSync(apiProxyLogsDir, { recursive: true });
234+
fs.writeFileSync(path.join(apiProxyLogsDir, 'proxy.log'), 'request\n');
235+
236+
await cleanup(testDir, false, proxyLogsDir);
237+
238+
expect(mockExecaSync).toHaveBeenCalledWith('chmod', ['-R', 'a+rX', apiProxyLogsDir]);
239+
} finally {
240+
if (fs.existsSync(proxyLogsDir)) {
241+
fs.rmSync(proxyLogsDir, { recursive: true, force: true });
242+
}
243+
}
244+
});
245+
});
246+
247+
describe('cleanup - audit dir', () => {
248+
let testDir: string;
249+
250+
beforeEach(() => {
251+
testDir = makeTmpDir();
252+
jest.clearAllMocks();
253+
mockExecaSync.mockReturnValue(undefined);
254+
});
255+
256+
afterEach(() => {
257+
if (fs.existsSync(testDir)) {
258+
fs.rmSync(testDir, { recursive: true, force: true });
259+
}
260+
});
261+
262+
it('skips chmod when auditDir is specified but does not exist', async () => {
263+
const nonExistentAuditDir = path.join(os.tmpdir(), `awf-nonexistent-audit-${Date.now()}`);
264+
265+
await cleanup(testDir, false, undefined, nonExistentAuditDir);
266+
267+
expect(mockExecaSync).not.toHaveBeenCalledWith('chmod', ['-R', 'a+rX', nonExistentAuditDir]);
268+
});
269+
270+
it('does not move empty default audit directory', async () => {
271+
const defaultAuditDir = path.join(testDir, 'audit');
272+
fs.mkdirSync(defaultAuditDir, { recursive: true });
273+
// leave it empty
274+
275+
await cleanup(testDir, false);
276+
277+
const timestamp = path.basename(testDir).replace('awf-', '');
278+
const destination = path.join(os.tmpdir(), `awf-audit-${timestamp}`);
279+
expect(fs.existsSync(destination)).toBe(false);
280+
});
281+
});
282+
283+
describe('cleanup - sessionStateDir', () => {
284+
let testDir: string;
285+
286+
beforeEach(() => {
287+
testDir = makeTmpDir();
288+
jest.clearAllMocks();
289+
mockExecaSync.mockReturnValue(undefined);
290+
});
291+
292+
afterEach(() => {
293+
if (fs.existsSync(testDir)) {
294+
fs.rmSync(testDir, { recursive: true, force: true });
295+
}
296+
});
297+
298+
it('skips chmod when sessionStateDir is specified but does not exist', async () => {
299+
const nonExistentStateDir = path.join(os.tmpdir(), `awf-nonexistent-state-${Date.now()}`);
300+
301+
await cleanup(testDir, false, undefined, undefined, nonExistentStateDir);
302+
303+
expect(mockExecaSync).not.toHaveBeenCalledWith('chmod', ['-R', 'a+rX', nonExistentStateDir]);
304+
});
305+
306+
it('chmods sessionStateDir in-place when it exists', async () => {
307+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-state-'));
308+
try {
309+
fs.writeFileSync(path.join(stateDir, 'events.jsonl'), '{"type":"start"}\n');
310+
311+
await cleanup(testDir, false, undefined, undefined, stateDir);
312+
313+
expect(mockExecaSync).toHaveBeenCalledWith('chmod', ['-R', 'a+rX', stateDir]);
314+
} finally {
315+
if (fs.existsSync(stateDir)) {
316+
fs.rmSync(stateDir, { recursive: true, force: true });
317+
}
318+
}
319+
});
320+
});
321+
322+
describe('cleanup - SSL directory', () => {
323+
let testDir: string;
324+
325+
beforeEach(() => {
326+
testDir = makeTmpDir();
327+
jest.clearAllMocks();
328+
mockExecaSync.mockReturnValue(undefined);
329+
});
330+
331+
afterEach(() => {
332+
if (fs.existsSync(testDir)) {
333+
fs.rmSync(testDir, { recursive: true, force: true });
334+
}
335+
});
336+
337+
it('calls unmountSslTmpfs when ssl directory exists in workDir', async () => {
338+
const { cleanupSslKeyMaterial, unmountSslTmpfs } = jest.requireMock('./ssl-bump') as {
339+
cleanupSslKeyMaterial: jest.Mock;
340+
unmountSslTmpfs: jest.Mock;
341+
};
342+
unmountSslTmpfs.mockResolvedValue(undefined);
343+
344+
const sslDir = path.join(testDir, 'ssl');
345+
fs.mkdirSync(sslDir, { recursive: true });
346+
fs.writeFileSync(path.join(sslDir, 'ca.pem'), 'fake-cert');
347+
348+
await cleanup(testDir, false);
349+
350+
expect(cleanupSslKeyMaterial).toHaveBeenCalledWith(testDir);
351+
expect(unmountSslTmpfs).toHaveBeenCalledWith(sslDir);
352+
});
353+
354+
it('does not call unmountSslTmpfs when ssl directory does not exist', async () => {
355+
const { unmountSslTmpfs } = jest.requireMock('./ssl-bump') as {
356+
unmountSslTmpfs: jest.Mock;
357+
};
358+
359+
await cleanup(testDir, false);
360+
361+
expect(unmountSslTmpfs).not.toHaveBeenCalled();
362+
});
363+
});

0 commit comments

Comments
 (0)