diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index ddf7c1d01..ad79a0b99 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -219,6 +219,114 @@ describe('createMainAction', () => { }); }); + describe('performCleanup with keepContainers=true', () => { + it('logs preserved paths and skips cleanup when keepContainers is true', async () => { + const configWithKeep = { ...STUB_CONFIG, keepContainers: true }; + mockedValidateOptions.validateOptions.mockReturnValue( + configWithKeep as unknown as import('../types').WrapperConfig + ); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, _deps, callbacks) => { + await callbacks.performCleanup(); + return 0; + }); + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + // cleanup should NOT be called (keepContainers=true) + expect(mockedDockerManager.cleanup).not.toHaveBeenCalled(); + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Configuration files preserved') + ); + }); + }); + + describe('performCleanup with containers started', () => { + it('stops containers and cleans host iptables when both flags are set', async () => { + const configWithFlags = { ...STUB_CONFIG, keepContainers: false }; + mockedValidateOptions.validateOptions.mockReturnValue( + configWithFlags as unknown as import('../types').WrapperConfig + ); + mockedDockerManager.stopContainers.mockResolvedValue(undefined); + mockedHostIptables.cleanupHostIptables.mockResolvedValue(undefined); + mockedDockerManager.cleanup.mockResolvedValue(undefined); + + // Make runMainWorkflow call both onContainersStarted and onHostIptablesSetup + mockedCliWorkflow.runMainWorkflow.mockImplementation( + async (_config, _deps, callbacks) => { + callbacks.onHostIptablesSetup?.(); + callbacks.onContainersStarted?.(); + await callbacks.performCleanup(); + return 0; + } + ); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedDockerManager.preserveIptablesAudit).toHaveBeenCalled(); + expect(mockedDockerManager.stopContainers).toHaveBeenCalled(); + expect(mockedHostIptables.cleanupHostIptables).toHaveBeenCalled(); + expect(mockedDockerManager.cleanup).toHaveBeenCalled(); + }); + }); + + describe('performCleanup signal parameter', () => { + it('logs signal name when cleanup is triggered with a signal', async () => { + let capturedSignalHandlers: Parameters[0] | undefined; + mockedSignalHandler.registerSignalHandlers.mockImplementation((opts) => { + capturedSignalHandlers = opts; + }); + mockedCliWorkflow.runMainWorkflow.mockResolvedValue(0); + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(capturedSignalHandlers).toBeDefined(); + mockedLogger.info.mockClear(); + await capturedSignalHandlers!.performCleanup('SIGINT'); + expect(mockedLogger.info).toHaveBeenCalledWith('Received SIGINT, cleaning up...'); + }); + }); + + describe('onContainersStarted and onHostIptablesSetup callbacks', () => { + it('getContainersStarted returns true after onContainersStarted is called', async () => { + let capturedOpts: Parameters[0] | undefined; + mockedSignalHandler.registerSignalHandlers.mockImplementation((opts) => { + capturedOpts = opts; + }); + mockedCliWorkflow.runMainWorkflow.mockImplementation( + async (_config, _deps, callbacks) => { + callbacks.onContainersStarted?.(); + return 0; + } + ); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + // After onContainersStarted is called, the flag should be true + expect(capturedOpts!.getContainersStarted()).toBe(true); + }); + }); + + describe('fatal error cleanup after containers started', () => { + it('stops containers during cleanup when workflow fails after startup callbacks', async () => { + mockedCliWorkflow.runMainWorkflow.mockImplementation( + async (_config, _deps, callbacks) => { + callbacks.onHostIptablesSetup?.(); + callbacks.onContainersStarted?.(); + throw new Error('signal test'); + } + ); + mockedDockerManager.stopContainers.mockResolvedValue(undefined); + mockedDockerManager.cleanup.mockResolvedValue(undefined); + + const action = createMainAction(getOptionValueSource); + await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1'); + + // Verify containers were stopped as part of cleanup + expect(mockedDockerManager.stopContainers).toHaveBeenCalled(); + }); + }); + describe('redaction of sensitive config fields', () => { it('does not log API keys in debug output', async () => { const configWithKeys = { diff --git a/src/commands/preflight.test.ts b/src/commands/preflight.test.ts index 7de9e6612..2e2ed0d97 100644 --- a/src/commands/preflight.test.ts +++ b/src/commands/preflight.test.ts @@ -243,6 +243,47 @@ describe('resolveAllowedDomains', () => { expect(result.resolvedCopilotApiTarget).toBe('custom.copilot.com'); expect(result.resolvedCopilotApiBasePath).toBe('/v1'); }); + + it('handles localhost detected but shouldEnableHostAccess=false', () => { + mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ + allowedDomains: ['localhost'], + localhostDetected: true, + shouldEnableHostAccess: false, + }); + mockedDomainUtils.parseDomains.mockReturnValue(['localhost']); + + const options: Record = { allowDomains: 'localhost' }; + const result = resolveAllowedDomains(options); + + expect(result.localhostResult.localhostDetected).toBe(true); + // enableHostAccess should NOT be set when shouldEnableHostAccess is false + expect(options.enableHostAccess).toBeUndefined(); + expect(mockedLogger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('localhost keyword enables host access') + ); + }); + + it('handles localhost detected without defaultPorts', () => { + mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ + allowedDomains: ['localhost'], + localhostDetected: true, + shouldEnableHostAccess: false, + defaultPorts: undefined, + }); + mockedDomainUtils.parseDomains.mockReturnValue(['localhost']); + + const options: Record = { allowDomains: 'localhost' }; + resolveAllowedDomains(options); + + // allowHostPorts should NOT be set when defaultPorts is undefined + expect(options.allowHostPorts).toBeUndefined(); + }); + + it('skips ruleset merge when rulesetFile array is empty', () => { + const result = resolveAllowedDomains({ rulesetFile: [] }); + expect(mockedRules.loadAndMergeDomains).not.toHaveBeenCalled(); + expect(result.allowedDomains).toEqual([]); + }); }); describe('resolveBlockedDomains', () => {