From d6970f00edf460549276304dc943a13da7b942b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:34:36 +0000 Subject: [PATCH] test: add branch coverage for config-writer, agent-options, host-env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover previously-uncovered code paths: src/config-writer.ts: - validateAndPrepareWorkDir: throws when workDir is not a directory (line 58) - copySeccompProfile: copies from alt path when primary is missing (lines 95-97) - copySeccompProfile: throws when neither path exists - writeAuditArtifacts: throws when auditDir is not a directory (line 162) src/commands/validators/agent-options.ts: - env success path (additionalEnv assignment, line 45) - invalid env var format (branch 39 — process.exit) - missing envFile (branch 50 — process.exit) - valid mount path (lines 67-68) - invalid mount format (branch 62 — process.exit) - enableDlp log path (line 141, branch 140) src/host-env.ts: - stripScheme: catch branch when URL constructor throws (line 81) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/config-writer-new-branches.test.ts | 183 +++++++++++++++++++++++++ src/coverage-new-branches.test.ts | 132 ++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 src/config-writer-new-branches.test.ts create mode 100644 src/coverage-new-branches.test.ts diff --git a/src/config-writer-new-branches.test.ts b/src/config-writer-new-branches.test.ts new file mode 100644 index 00000000..31cb3f75 --- /dev/null +++ b/src/config-writer-new-branches.test.ts @@ -0,0 +1,183 @@ +/** + * Branch and line coverage for previously-uncovered paths in src/config-writer.ts: + * - validateAndPrepareWorkDir: workDir is not a directory (line 58) + * - copySeccompProfile: alt-path seccomp fallback (lines 95-97), neither path found + * - writeAuditArtifacts: auditDir is not a directory (line 162) + * + * This file sets up its own complete fs mock to gain control over statSync and + * copyFileSync (which are not provided by the shared fsMockFactory). + */ + +// Full fs mock — must be hoisted before any imports that use fs +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + chownSync: jest.fn(), + existsSync: jest.fn(), + lstatSync: jest.fn(), + statSync: jest.fn(), + writeFileSync: jest.fn(), + copyFileSync: jest.fn(), + readFileSync: jest.fn(), + readdirSync: jest.fn(), + mkdtempSync: jest.fn(), + rmSync: jest.fn(), + }; +}); + +jest.mock('./ssl-bump', () => ({ + isOpenSslAvailable: jest.fn(), + generateSessionCa: jest.fn(), + initSslDb: jest.fn(), + cleanupSslKeyMaterial: jest.fn(), + unmountSslTmpfs: jest.fn(), +})); + +jest.mock('./host-env', () => ({ + SQUID_PORT: 3128, + stripScheme: jest.fn((v: string) => v), + getSafeHostUid: jest.fn().mockReturnValue('1000'), + getSafeHostGid: jest.fn().mockReturnValue('1000'), + getRealUserHome: jest.fn().mockReturnValue('/home/test'), +})); + +jest.mock('./host-identity', () => ({ + getSafeHostUid: jest.fn().mockReturnValue('1000'), + getSafeHostGid: jest.fn().mockReturnValue('1000'), + getRealUserHome: jest.fn().mockReturnValue('/home/test'), +})); + +jest.mock('./squid-config', () => ({ + generateSquidConfig: jest.fn().mockReturnValue('# mock squid config'), + generatePolicyManifest: jest.fn().mockReturnValue({}), +})); + +jest.mock('./compose-generator', () => ({ + generateDockerCompose: jest.fn().mockReturnValue({ services: {}, version: '3' }), + redactDockerComposeSecrets: jest.fn().mockReturnValue({ services: {}, version: '3' }), +})); + +jest.mock('./domain-matchers', () => ({ + parseUrlPatterns: jest.fn().mockReturnValue([]), +})); + +import * as fs from 'fs'; +import * as path from 'path'; +import { configWriterTestHelpers } from './config-writer'; + +const { validateAndPrepareWorkDir, copySeccompProfile, writeAuditArtifacts } = + configWriterTestHelpers; + +const fsMock = fs as jest.Mocked; + +function makeConfig(workDir = '/tmp/test-workdir', overrides = {}) { + return { + workDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info' as const, + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + // Default: mkdirSync returns undefined (dir already existed → workDirCreated=false) + fsMock.mkdirSync.mockReturnValue(undefined); + // Default: not a symlink, is a directory + fsMock.lstatSync.mockReturnValue({ isSymbolicLink: () => false } as fs.Stats); + fsMock.statSync.mockReturnValue({ isDirectory: () => true } as fs.Stats); +}); + +// ─── validateAndPrepareWorkDir — non-directory guard ───────────────────────── + +describe('config-writer: validateAndPrepareWorkDir — non-directory workDir (line 58)', () => { + it('throws when workDir exists but is not a directory', () => { + fsMock.lstatSync.mockReturnValueOnce({ isSymbolicLink: () => false } as fs.Stats); + fsMock.statSync.mockReturnValueOnce({ isDirectory: () => false } as fs.Stats); + + const workDir = '/tmp/test-workdir'; + expect(() => + validateAndPrepareWorkDir(makeConfig(workDir)) + ).toThrow(`Expected directory but found non-directory path: ${workDir}`); + }); + + it('does not throw when workDir is a valid directory', () => { + fsMock.lstatSync.mockReturnValueOnce({ isSymbolicLink: () => false } as fs.Stats); + fsMock.statSync.mockReturnValueOnce({ isDirectory: () => true } as fs.Stats); + + expect(() => validateAndPrepareWorkDir(makeConfig())).not.toThrow(); + expect(fsMock.chmodSync).toHaveBeenCalled(); + }); +}); + +// ─── copySeccompProfile — alt-path and missing branches ────────────────────── + +describe('config-writer: copySeccompProfile — alt-path fallback (lines 95-97)', () => { + it('copies from the alt path when primary path does not exist', () => { + fsMock.existsSync + .mockReturnValueOnce(false) // primary containers/ path + .mockReturnValueOnce(true); // dist/../containers/ alt path + + const config = makeConfig(); + copySeccompProfile(config); + + expect(fsMock.copyFileSync).toHaveBeenCalledTimes(1); + expect(fsMock.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining('seccomp-profile.json'), + expect.stringContaining('seccomp-profile.json'), + ); + }); + + it('throws when neither primary nor alt seccomp path exists', () => { + fsMock.existsSync + .mockReturnValueOnce(false) // primary path + .mockReturnValueOnce(false); // alt path + + expect(() => copySeccompProfile(makeConfig())).toThrow('Seccomp profile not found'); + expect(fsMock.copyFileSync).not.toHaveBeenCalled(); + }); + + it('copies from the primary path when it exists', () => { + fsMock.existsSync.mockReturnValueOnce(true); // primary path exists + + copySeccompProfile(makeConfig()); + + expect(fsMock.copyFileSync).toHaveBeenCalledTimes(1); + }); +}); + +// ─── writeAuditArtifacts — non-directory auditDir guard ────────────────────── + +describe('config-writer: writeAuditArtifacts — non-directory auditDir (line 162)', () => { + it('throws when auditDir exists but is not a directory', () => { + fsMock.lstatSync.mockReturnValue({ isSymbolicLink: () => false } as fs.Stats); + fsMock.statSync.mockReturnValue({ isDirectory: () => false } as fs.Stats); + + const workDir = '/tmp/test-workdir'; + const auditPath = path.join(workDir, 'audit'); + + expect(() => + writeAuditArtifacts(makeConfig(workDir), {} as any, {} as any, '# squid') + ).toThrow(`Expected directory but found non-directory path: ${auditPath}`); + }); + + it('writes audit artifacts when auditDir is valid', () => { + fsMock.lstatSync.mockReturnValue({ isSymbolicLink: () => false } as fs.Stats); + fsMock.statSync.mockReturnValue({ isDirectory: () => true } as fs.Stats); + + expect(() => + writeAuditArtifacts(makeConfig(), {} as any, { services: {}, version: '3' } as any, '# squid') + ).not.toThrow(); + + expect(fsMock.writeFileSync).toHaveBeenCalled(); + }); +}); diff --git a/src/coverage-new-branches.test.ts b/src/coverage-new-branches.test.ts new file mode 100644 index 00000000..53092e90 --- /dev/null +++ b/src/coverage-new-branches.test.ts @@ -0,0 +1,132 @@ +/** + * Branch and line coverage for previously-uncovered paths in: + * - src/host-env.ts (stripScheme error branch) + * - src/commands/validators/agent-options.ts + * (env success path, invalid env-file, invalid mount, + * valid mount success path, enableDlp log path) + */ + +// ─── host-env.ts ───────────────────────────────────────────────────────────── + +import { stripScheme } from './host-env'; + +describe('host-env: stripScheme — error branch (line 81)', () => { + it('returns trimmed value unchanged when the URL constructor throws on the candidate', () => { + // A bare string with spaces produces "https://not a valid url" which is invalid. + const value = 'not a valid url with spaces'; + expect(stripScheme(value)).toBe(value.trim()); + }); + + it('returns empty string for empty input', () => { + expect(stripScheme('')).toBe(''); + expect(stripScheme(' ')).toBe(''); + }); + + it('strips scheme from a valid https URL', () => { + expect(stripScheme('https://api.example.com/path')).toBe('api.example.com'); + }); + + it('handles bare hostname without scheme', () => { + expect(stripScheme('api.example.com')).toBe('api.example.com'); + }); +}); + +// ─── agent-options.ts ──────────────────────────────────────────────────────── + +jest.mock('./option-parsers', () => ({ + parseEnvironmentVariables: jest.fn(), + parseVolumeMounts: jest.fn(), +})); + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: jest.fn(), +})); + +import { validateAgentOptions } from './commands/validators/agent-options'; +import { parseEnvironmentVariables, parseVolumeMounts } from './option-parsers'; + +const fsMock = jest.requireMock('fs') as { existsSync: jest.Mock }; +const parseEnvMock = parseEnvironmentVariables as jest.MockedFunction; +const parseMountMock = parseVolumeMounts as jest.MockedFunction; + +describe('validateAgentOptions', () => { + const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as any); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + mockExit.mockRestore(); + }); + + it('sets additionalEnv when parseEnvironmentVariables succeeds (line 45)', () => { + parseEnvMock.mockReturnValue({ success: true, env: { FOO: 'bar' } }); + + const result = validateAgentOptions({ env: ['FOO=bar'] }); + expect(result.additionalEnv).toEqual({ FOO: 'bar' }); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('exits when parseEnvironmentVariables fails (branch 39)', () => { + parseEnvMock.mockReturnValue({ success: false, invalidVar: 'BADVAR' }); + + expect(() => validateAgentOptions({ env: ['BADVAR'] })).toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('exits when envFile does not exist (branch 50)', () => { + fsMock.existsSync.mockReturnValue(false); + + expect(() => + validateAgentOptions({ envFile: '/no/such/file.env' }) + ).toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('does not exit when envFile exists (branch 50 — true path)', () => { + fsMock.existsSync.mockReturnValue(true); + + const result = validateAgentOptions({ envFile: '/valid/file.env' }); + expect(mockExit).not.toHaveBeenCalled(); + expect(result.additionalEnv).toEqual({}); + }); + + it('sets volumeMounts when parseVolumeMounts succeeds (lines 67-68)', () => { + parseMountMock.mockReturnValue({ success: true, mounts: ['/host:/container:ro'] }); + + const result = validateAgentOptions({ mount: ['/host:/container:ro'] }); + expect(result.volumeMounts).toEqual(['/host:/container:ro']); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('exits when parseVolumeMounts fails (branch 62)', () => { + parseMountMock.mockReturnValue({ + success: false, + invalidMount: 'bad-mount', + reason: 'missing colon separator', + }); + + expect(() => + validateAgentOptions({ mount: ['bad-mount'] }) + ).toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('does not exit when enableDlp is true (line 141, branch 140)', () => { + const result = validateAgentOptions({ enableDlp: true }); + expect(mockExit).not.toHaveBeenCalled(); + expect(result.additionalEnv).toEqual({}); + }); + + it('returns empty result when no options are provided', () => { + const result = validateAgentOptions({}); + expect(result.additionalEnv).toEqual({}); + expect(result.volumeMounts).toBeUndefined(); + expect(result.allowedUrls).toBeUndefined(); + expect(mockExit).not.toHaveBeenCalled(); + }); +});