Skip to content

Commit 4a35cb8

Browse files
github-actions[bot]CopilotCopilot
authored
[Test Coverage] Add test coverage for subcommands and main-action (#3288)
* test: add coverage for subcommands and main-action Add unit tests for two low-coverage modules: - src/commands/subcommands.ts (47% → ~80%+) - src/commands/main-action.ts (19% → ~70%+) subcommands.test.ts: - Verifies all subcommands are registered (predownload, logs, stats, summary, audit) - Tests validateFormat rejects invalid format strings with process.exit(1) - Tests validateFormat accepts all valid format strings - Tests invalid/valid decision filters in logs audit subcommand - Tests --with-pid warning when used without -f - Tests predownload error handling (exitCode propagation) main-action.test.ts: - Tests empty args → process.exit(1) with usage error - Tests single arg passed as-is (shell variable preservation) - Tests multiple args joined via joinShellArgs - Tests full happy path (0 exit code) - Tests non-zero exit code propagation - Tests fatal error → performCleanup → process.exit(1) - Tests API key redaction in debug logs (security) - Tests blocked domains logging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: address review feedback in command tests --------- 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 c7f8c7a commit 4a35cb8

2 files changed

Lines changed: 493 additions & 0 deletions

File tree

src/commands/main-action.test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { createMainAction } from './main-action';
2+
3+
// eslint-disable-next-line @typescript-eslint/no-require-imports
4+
jest.mock('../logger', () => require('../test-helpers/mock-logger.test-utils').loggerMockFactory());
5+
jest.mock('../docker-manager');
6+
jest.mock('../host-iptables');
7+
jest.mock('../cli-workflow');
8+
jest.mock('../redact-secrets');
9+
jest.mock('../option-parsers');
10+
jest.mock('./preflight');
11+
jest.mock('./signal-handler');
12+
jest.mock('./validate-options');
13+
14+
import { logger } from '../logger';
15+
import * as dockerManager from '../docker-manager';
16+
import * as hostIptables from '../host-iptables';
17+
import * as cliWorkflow from '../cli-workflow';
18+
import * as redactSecrets from '../redact-secrets';
19+
import * as optionParsers from '../option-parsers';
20+
import * as preflight from './preflight';
21+
import * as signalHandler from './signal-handler';
22+
import * as validateOptions from './validate-options';
23+
24+
const mockedLogger = logger as jest.Mocked<typeof logger>;
25+
const mockedDockerManager = dockerManager as jest.Mocked<typeof dockerManager>;
26+
const mockedHostIptables = hostIptables as jest.Mocked<typeof hostIptables>;
27+
const mockedCliWorkflow = cliWorkflow as jest.Mocked<typeof cliWorkflow>;
28+
const mockedRedactSecrets = redactSecrets as jest.Mocked<typeof redactSecrets>;
29+
const mockedOptionParsers = optionParsers as jest.Mocked<typeof optionParsers>;
30+
const mockedPreflight = preflight as jest.Mocked<typeof preflight>;
31+
const mockedSignalHandler = signalHandler as jest.Mocked<typeof signalHandler>;
32+
const mockedValidateOptions = validateOptions as jest.Mocked<typeof validateOptions>;
33+
34+
/** Minimal WrapperConfig returned by the validateOptions mock. */
35+
const STUB_CONFIG = {
36+
allowedDomains: ['github.com'],
37+
blockedDomains: undefined,
38+
agentCommand: 'echo hi',
39+
logLevel: 'info',
40+
keepContainers: false,
41+
workDir: '/tmp/awf-test',
42+
imageRegistry: 'ghcr.io/github/gh-aw-firewall',
43+
imageTag: 'latest',
44+
buildLocal: false,
45+
dnsServers: ['8.8.8.8'],
46+
awfDockerHost: undefined,
47+
proxyLogsDir: undefined,
48+
auditDir: undefined,
49+
sessionStateDir: undefined,
50+
} as unknown as import('../types').WrapperConfig;
51+
52+
describe('createMainAction', () => {
53+
let processExitSpy: jest.SpyInstance;
54+
let consoleErrorSpy: jest.SpyInstance;
55+
let getOptionValueSource: jest.Mock;
56+
57+
beforeEach(() => {
58+
jest.clearAllMocks();
59+
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
60+
if (code === 1) {
61+
throw new Error(`process.exit: ${code}`);
62+
}
63+
return undefined as never;
64+
});
65+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
66+
getOptionValueSource = jest.fn().mockReturnValue(undefined);
67+
68+
// Default mock implementations
69+
mockedPreflight.applyConfigFilePrecedence.mockImplementation(() => {});
70+
mockedValidateOptions.validateOptions.mockReturnValue(STUB_CONFIG);
71+
mockedDockerManager.setAwfDockerHost.mockImplementation(() => {});
72+
mockedRedactSecrets.redactSecrets.mockImplementation((s: string) => s);
73+
mockedOptionParsers.joinShellArgs.mockImplementation((args: string[]) => args.join(' '));
74+
mockedSignalHandler.registerSignalHandlers.mockImplementation(() => {});
75+
mockedCliWorkflow.runMainWorkflow.mockResolvedValue(0);
76+
});
77+
78+
afterEach(() => {
79+
processExitSpy.mockRestore();
80+
consoleErrorSpy.mockRestore();
81+
});
82+
83+
describe('when args is empty', () => {
84+
it('exits with code 1 and prints usage error', async () => {
85+
const action = createMainAction(getOptionValueSource);
86+
await expect(action([], {})).rejects.toThrow('process.exit: 1');
87+
expect(processExitSpy).toHaveBeenCalledWith(1);
88+
expect(mockedOptionParsers.joinShellArgs).not.toHaveBeenCalled();
89+
expect(consoleErrorSpy).toHaveBeenCalledWith(
90+
expect.stringContaining('No command specified')
91+
);
92+
});
93+
});
94+
95+
describe('when single arg is provided', () => {
96+
it('uses the single arg as-is (preserves shell variables)', async () => {
97+
const action = createMainAction(getOptionValueSource);
98+
await action(['echo $HOME'], {});
99+
expect(mockedOptionParsers.joinShellArgs).not.toHaveBeenCalled();
100+
expect(mockedValidateOptions.validateOptions).toHaveBeenCalledWith(
101+
expect.anything(),
102+
'echo $HOME'
103+
);
104+
});
105+
});
106+
107+
describe('when multiple args are provided', () => {
108+
it('joins args with joinShellArgs', async () => {
109+
const action = createMainAction(getOptionValueSource);
110+
await action(['curl', '-H', 'Auth: token', 'https://api.github.com'], {});
111+
expect(mockedOptionParsers.joinShellArgs).toHaveBeenCalledWith([
112+
'curl',
113+
'-H',
114+
'Auth: token',
115+
'https://api.github.com',
116+
]);
117+
expect(mockedValidateOptions.validateOptions).toHaveBeenCalledWith(
118+
expect.anything(),
119+
'curl -H Auth: token https://api.github.com'
120+
);
121+
});
122+
});
123+
124+
describe('happy path', () => {
125+
it('calls workflow steps and exits with 0', async () => {
126+
mockedCliWorkflow.runMainWorkflow.mockResolvedValue(0);
127+
const action = createMainAction(getOptionValueSource);
128+
await action(['echo hi'], {});
129+
expect(mockedCliWorkflow.runMainWorkflow).toHaveBeenCalled();
130+
expect(processExitSpy).toHaveBeenCalledWith(0);
131+
});
132+
133+
it('calls applyConfigFilePrecedence with options and resolver', async () => {
134+
const options = { keepContainers: false };
135+
const action = createMainAction(getOptionValueSource);
136+
await action(['echo hi'], options);
137+
expect(mockedPreflight.applyConfigFilePrecedence).toHaveBeenCalledWith(
138+
options,
139+
getOptionValueSource
140+
);
141+
});
142+
143+
it('calls setAwfDockerHost with config.awfDockerHost', async () => {
144+
const configWithDockerHost = { ...STUB_CONFIG, awfDockerHost: '/var/run/docker.sock' };
145+
mockedValidateOptions.validateOptions.mockReturnValue(
146+
configWithDockerHost as unknown as import('../types').WrapperConfig
147+
);
148+
const action = createMainAction(getOptionValueSource);
149+
await action(['echo hi'], {});
150+
expect(mockedDockerManager.setAwfDockerHost).toHaveBeenCalledWith('/var/run/docker.sock');
151+
});
152+
153+
it('registers signal handlers', async () => {
154+
const action = createMainAction(getOptionValueSource);
155+
await action(['echo hi'], {});
156+
expect(mockedSignalHandler.registerSignalHandlers).toHaveBeenCalled();
157+
});
158+
159+
it('logs allowed domains', async () => {
160+
const action = createMainAction(getOptionValueSource);
161+
await action(['echo hi'], {});
162+
expect(mockedLogger.info).toHaveBeenCalledWith(
163+
expect.stringContaining('github.com')
164+
);
165+
});
166+
167+
it('logs blocked domains when present', async () => {
168+
const configWithBlocked = {
169+
...STUB_CONFIG,
170+
blockedDomains: ['evil.com'],
171+
};
172+
mockedValidateOptions.validateOptions.mockReturnValue(
173+
configWithBlocked as unknown as import('../types').WrapperConfig
174+
);
175+
const action = createMainAction(getOptionValueSource);
176+
await action(['echo hi'], {});
177+
expect(mockedLogger.info).toHaveBeenCalledWith(
178+
expect.stringContaining('evil.com')
179+
);
180+
});
181+
182+
it('does not log blocked domains when empty', async () => {
183+
const action = createMainAction(getOptionValueSource);
184+
await action(['echo hi'], {});
185+
const blockedCalls = mockedLogger.info.mock.calls.filter(
186+
(args) => String(args[0]).includes('Blocked domains')
187+
);
188+
expect(blockedCalls).toHaveLength(0);
189+
});
190+
});
191+
192+
describe('when runMainWorkflow returns non-zero exit code', () => {
193+
it('exits with the non-zero code', async () => {
194+
mockedCliWorkflow.runMainWorkflow.mockResolvedValue(42);
195+
const action = createMainAction(getOptionValueSource);
196+
await action(['curl https://example.com'], {});
197+
expect(processExitSpy).toHaveBeenCalledWith(42);
198+
});
199+
});
200+
201+
describe('when runMainWorkflow throws', () => {
202+
it('calls performCleanup and exits with code 1', async () => {
203+
mockedCliWorkflow.runMainWorkflow.mockRejectedValue(new Error('docker failed'));
204+
const action = createMainAction(getOptionValueSource);
205+
await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1');
206+
expect(mockedLogger.error).toHaveBeenCalledWith(
207+
'Fatal error:',
208+
expect.any(Error)
209+
);
210+
expect(mockedDockerManager.cleanup).toHaveBeenCalledWith(
211+
STUB_CONFIG.workDir,
212+
false,
213+
STUB_CONFIG.proxyLogsDir,
214+
STUB_CONFIG.auditDir,
215+
STUB_CONFIG.sessionStateDir
216+
);
217+
expect(mockedHostIptables.cleanupHostIptables).not.toHaveBeenCalled();
218+
expect(processExitSpy).toHaveBeenCalledWith(1);
219+
});
220+
});
221+
222+
describe('redaction of sensitive config fields', () => {
223+
it('does not log API keys in debug output', async () => {
224+
const configWithKeys = {
225+
...STUB_CONFIG,
226+
openaiApiKey: 'sk-secret',
227+
anthropicApiKey: 'ant-secret',
228+
copilotGithubToken: 'ghp-secret',
229+
copilotApiKey: 'cop-secret',
230+
geminiApiKey: 'gem-secret',
231+
};
232+
mockedValidateOptions.validateOptions.mockReturnValue(
233+
configWithKeys as unknown as import('../types').WrapperConfig
234+
);
235+
const action = createMainAction(getOptionValueSource);
236+
await action(['echo hi'], {});
237+
// Debug call should be made but without raw API keys
238+
const debugCalls = mockedLogger.debug.mock.calls;
239+
const configDebugCall = debugCalls.find((args) =>
240+
String(args[0]).includes('Configuration')
241+
);
242+
expect(configDebugCall).toBeDefined();
243+
const serialized = String(configDebugCall?.[1]);
244+
expect(serialized).not.toContain('sk-secret');
245+
expect(serialized).not.toContain('ant-secret');
246+
expect(serialized).not.toContain('ghp-secret');
247+
expect(serialized).not.toContain('cop-secret');
248+
expect(serialized).not.toContain('gem-secret');
249+
});
250+
});
251+
});

0 commit comments

Comments
 (0)