diff --git a/src/commands/fix/cmd-fix-e2e.test.mts b/src/commands/fix/cmd-fix.e2e.test.mts similarity index 100% rename from src/commands/fix/cmd-fix-e2e.test.mts rename to src/commands/fix/cmd-fix.e2e.test.mts diff --git a/src/commands/fix/cmd-fix.test.mts b/src/commands/fix/cmd-fix.integration.test.mts similarity index 79% rename from src/commands/fix/cmd-fix.test.mts rename to src/commands/fix/cmd-fix.integration.test.mts index 8e6883be6..6369d2313 100644 --- a/src/commands/fix/cmd-fix.test.mts +++ b/src/commands/fix/cmd-fix.integration.test.mts @@ -925,4 +925,290 @@ describe('socket fix', async () => { }, ) }) + + describe('--limit flag behavior', () => { + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '0', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit with value 0', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '1', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit with value 1', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '100', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit with large value', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + ['fix', FLAG_DRY_RUN, FLAG_CONFIG, '{"apiToken":"fakeToken"}'], + 'should use default limit of 10 when --limit is not specified', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + ['fix', '--limit', '0', FLAG_CONFIG, '{"apiToken":"fake-token"}'], + 'should handle --limit 0 in non-dry-run mode', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'Unable to resolve a Socket account organization', + ) + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + }) + + describe('--id flag behavior', () => { + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + FLAG_ID, + 'GHSA-1234-5678-9abc', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept single GHSA ID with --id flag', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + FLAG_ID, + 'CVE-2021-12345', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept single CVE ID with --id flag', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + FLAG_ID, + 'pkg:npm/lodash@4.17.20', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept single PURL with --id flag', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + FLAG_ID, + 'GHSA-1234-5678-9abc,GHSA-abcd-efgh-ijkl', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept comma-separated GHSA IDs', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + FLAG_ID, + 'GHSA-1234-5678-9abc', + FLAG_ID, + 'CVE-2021-12345', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept multiple --id flags with different ID types', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + }) + + describe('--limit and --id combination', () => { + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '1', + FLAG_ID, + 'GHSA-1234-5678-9abc', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept both --limit and --id flags together', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '5', + FLAG_ID, + 'GHSA-1234-5678-9abc,CVE-2021-12345,pkg:npm/lodash@4.17.20', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit with multiple vulnerability IDs', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '1', + FLAG_ID, + 'GHSA-1234-5678-9abc', + '--autopilot', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit, --id, and --autopilot together', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + '--limit', + '2', + FLAG_ID, + 'GHSA-1234-5678-9abc,GHSA-abcd-efgh-ijkl', + FLAG_CONFIG, + '{"apiToken":"fake-token"}', + ], + 'should handle --limit and --id in non-dry-run mode', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + 'Unable to resolve a Socket account organization', + ) + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '3', + FLAG_ID, + 'GHSA-1234-5678-9abc', + FLAG_JSON, + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit, --id, and --json output format together', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'fix', + FLAG_DRY_RUN, + '--limit', + '10', + FLAG_ID, + 'CVE-2021-12345', + FLAG_MARKDOWN, + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --limit, --id, and --markdown output format together', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Not saving"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + }) }) diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index 975869898..9e2de27cc 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -47,6 +47,63 @@ import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-n import type { FixConfig } from './types.mts' import type { CResult } from '../../types.mts' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +type DiscoverGhsaIdsOptions = { + cwd?: string | undefined + limit?: number | undefined + spinner?: Spinner | undefined +} + +/** + * Discovers GHSA IDs by running coana without applying fixes. + * Returns a list of GHSA IDs, optionally limited. + */ +async function discoverGhsaIds( + orgSlug: string, + tarHash: string, + fixConfig: FixConfig, + options?: DiscoverGhsaIdsOptions | undefined, +): Promise { + const { + cwd = process.cwd(), + limit, + spinner, + } = { + __proto__: null, + ...options, + } as DiscoverGhsaIdsOptions + + const foundCResult = await spawnCoanaDlx( + [ + 'compute-fixes-and-upgrade-purls', + cwd, + '--manifests-tar-hash', + tarHash, + ...(fixConfig.rangeStyle ? ['--range-style', fixConfig.rangeStyle] : []), + ...(fixConfig.minimumReleaseAge + ? ['--minimum-release-age', fixConfig.minimumReleaseAge] + : []), + ...(fixConfig.include.length ? ['--include', ...fixConfig.include] : []), + ...(fixConfig.exclude.length ? ['--exclude', ...fixConfig.exclude] : []), + ...(fixConfig.disableMajorUpdates ? ['--disable-major-updates'] : []), + ...(fixConfig.showAffectedDirectDependencies + ? ['--show-affected-direct-dependencies'] + : []), + ...fixConfig.unknownFlags, + ], + orgSlug, + { cwd, spinner }, + ) + + if (foundCResult.ok) { + const foundIds = cmdFlagValueToArray( + /(?<=Vulnerabilities found:).*/.exec(foundCResult.data), + ) + return limit !== undefined ? foundIds.slice(0, limit) : foundIds + } + return [] +} export async function coanaFix( fixConfig: FixConfig, @@ -138,8 +195,20 @@ export async function coanaFix( } } - const ids = isAll ? ['all'] : ghsas.slice(0, limit) - if (!ids.length) { + let ids: string[] + if (isAll && limit > 0) { + ids = await discoverGhsaIds(orgSlug, tarHash, fixConfig, { + cwd, + limit, + spinner, + }) + } else if (limit > 0) { + ids = ghsas.slice(0, limit) + } else { + ids = [] + } + + if (limit < 1 || ids.length === 0) { spinner?.stop() return { ok: true, data: { fixed: false } } } @@ -156,7 +225,7 @@ export async function coanaFix( '--manifests-tar-hash', tarHash, '--apply-fixes-to', - ...(isAll ? ['all'] : ghsas), + ...ids, ...(fixConfig.rangeStyle ? ['--range-style', fixConfig.rangeStyle] : []), @@ -199,7 +268,7 @@ export async function coanaFix( // Clean up the temporary file. try { await fs.unlink(tmpFile) - } catch (e) { + } catch { // Ignore cleanup errors. } } @@ -234,35 +303,11 @@ export async function coanaFix( let ids: string[] | undefined if (shouldSpawnCoana && isAll) { - const foundCResult = await spawnCoanaDlx( - [ - 'compute-fixes-and-upgrade-purls', - cwd, - '--manifests-tar-hash', - tarHash, - ...(fixConfig.rangeStyle - ? ['--range-style', fixConfig.rangeStyle] - : []), - ...(minimumReleaseAge - ? ['--minimum-release-age', minimumReleaseAge] - : []), - ...(include.length ? ['--include', ...include] : []), - ...(exclude.length ? ['--exclude', ...exclude] : []), - ...(disableMajorUpdates ? ['--disable-major-updates'] : []), - ...(showAffectedDirectDependencies - ? ['--show-affected-direct-dependencies'] - : []), - ...fixConfig.unknownFlags, - ], - fixConfig.orgSlug, - { cwd, spinner }, - ) - if (foundCResult.ok) { - const foundIds = cmdFlagValueToArray( - /(?<=Vulnerabilities found:).*/.exec(foundCResult.data), - ) - ids = foundIds.slice(0, adjustedLimit) - } + ids = await discoverGhsaIds(orgSlug, tarHash, fixConfig, { + cwd, + limit: adjustedLimit, + spinner, + }) } else if (shouldSpawnCoana) { ids = ghsas.slice(0, adjustedLimit) } diff --git a/src/commands/fix/handle-fix-limit.test.mts b/src/commands/fix/handle-fix-limit.test.mts new file mode 100644 index 000000000..25a703cbd --- /dev/null +++ b/src/commands/fix/handle-fix-limit.test.mts @@ -0,0 +1,441 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { coanaFix } from './coana-fix.mts' + +import type { FixConfig } from './types.mts' + +// Mock all external dependencies. +const mockSpawnCoanaDlx = vi.hoisted(() => vi.fn()) +const mockSetupSdk = vi.hoisted(() => vi.fn()) +const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) +const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) +const mockHandleApiCall = vi.hoisted(() => vi.fn()) +const mockGetFixEnv = vi.hoisted(() => vi.fn()) +const mockGetSocketFixPrs = vi.hoisted(() => vi.fn()) +const mockFetchGhsaDetails = vi.hoisted(() => vi.fn()) +const mockGitUnstagedModifiedFiles = vi.hoisted(() => vi.fn()) + +vi.mock('../../utils/dlx.mts', () => ({ + spawnCoanaDlx: mockSpawnCoanaDlx, +})) + +vi.mock('../../utils/sdk.mts', () => ({ + setupSdk: mockSetupSdk, +})) + +vi.mock('../scan/fetch-supported-scan-file-names.mts', () => ({ + fetchSupportedScanFileNames: mockFetchSupportedScanFileNames, +})) + +vi.mock('../../utils/path-resolve.mts', () => ({ + getPackageFilesForScan: mockGetPackageFilesForScan, +})) + +vi.mock('../../utils/api.mts', () => ({ + handleApiCall: mockHandleApiCall, +})) + +vi.mock('./env-helpers.mts', () => ({ + checkCiEnvVars: vi.fn(() => ({ missing: [], present: [] })), + getCiEnvInstructions: vi.fn(() => 'Set CI env vars'), + getFixEnv: mockGetFixEnv, +})) + +vi.mock('./pull-request.mts', () => ({ + getSocketFixPrs: mockGetSocketFixPrs, + openSocketFixPr: vi.fn(), +})) + +vi.mock('../../utils/github.mts', () => ({ + enablePrAutoMerge: vi.fn(), + fetchGhsaDetails: mockFetchGhsaDetails, + setGitRemoteGithubRepoUrl: vi.fn(), +})) + +vi.mock('../../utils/git.mts', () => ({ + gitCheckoutBranch: vi.fn(() => Promise.resolve(true)), + gitCommit: vi.fn(() => Promise.resolve(true)), + gitCreateBranch: vi.fn(() => Promise.resolve(true)), + gitDeleteBranch: vi.fn(() => Promise.resolve(true)), + gitPushBranch: vi.fn(() => Promise.resolve(true)), + gitRemoteBranchExists: vi.fn(() => Promise.resolve(false)), + gitResetAndClean: vi.fn(() => Promise.resolve(true)), + gitUnstagedModifiedFiles: mockGitUnstagedModifiedFiles, +})) + +vi.mock('./branch-cleanup.mts', () => ({ + cleanupErrorBranches: vi.fn(), + cleanupFailedPrBranches: vi.fn(), + cleanupStaleBranch: vi.fn(() => Promise.resolve(true)), + cleanupSuccessfulPrLocalBranch: vi.fn(), +})) + +describe('socket fix --limit behavior verification', () => { + const baseConfig: FixConfig = { + applyFixes: true, + autopilot: false, + cwd: '/test/cwd', + disableMajorUpdates: false, + exclude: [], + ghsas: [], + include: [], + limit: 10, + minSatisfying: false, + minimumReleaseAge: '', + orgSlug: 'test-org', + outputFile: '', + prCheck: true, + rangeStyle: 'preserve', + showAffectedDirectDependencies: false, + spinner: undefined, + unknownFlags: [], + } + + beforeEach(() => { + vi.clearAllMocks() + + // Default mock implementations. + mockSetupSdk.mockResolvedValue({ + ok: true, + data: { + uploadManifestFiles: vi.fn(), + }, + }) + + mockFetchSupportedScanFileNames.mockResolvedValue({ + ok: true, + data: ['package.json', 'package-lock.json'], + }) + + mockGetPackageFilesForScan.mockResolvedValue([ + '/test/cwd/package.json', + '/test/cwd/package-lock.json', + ]) + + mockHandleApiCall.mockResolvedValue({ + ok: true, + data: { tarHash: 'test-hash-123' }, + }) + + mockGetFixEnv.mockResolvedValue({ + githubToken: '', + gitUserEmail: '', + gitUserName: '', + isCi: false, + repoInfo: null, + }) + + mockGitUnstagedModifiedFiles.mockResolvedValue({ + ok: true, + data: [], + }) + }) + + describe('local mode (no PRs)', () => { + it('should process only N GHSAs when --limit N is specified', async () => { + const ghsas = [ + 'GHSA-1111-1111-1111', + 'GHSA-2222-2222-2222', + 'GHSA-3333-3333-3333', + 'GHSA-4444-4444-4444', + 'GHSA-5555-5555-5555', + ] + + // Mock successful fix result. + mockSpawnCoanaDlx.mockResolvedValue({ + ok: true, + data: 'fix applied', + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas, + limit: 3, + }) + + expect(result.ok).toBe(true) + + // Verify spawnCoanaDlx was called once with only the first 3 GHSAs. + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + expect(callArgs).toContain('--apply-fixes-to') + + // Find the index of --apply-fixes-to and check the next arguments. + const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') + const ghsaArgs = callArgs + .slice(applyFixesIndex + 1) + .filter(arg => arg.startsWith('GHSA-')) + + expect(ghsaArgs).toEqual([ + 'GHSA-1111-1111-1111', + 'GHSA-2222-2222-2222', + 'GHSA-3333-3333-3333', + ]) + }) + + it('should process all GHSAs when limit exceeds GHSA count', async () => { + const ghsas = ['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222'] + + mockSpawnCoanaDlx.mockResolvedValue({ + ok: true, + data: 'fix applied', + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas, + limit: 10, + }) + + expect(result.ok).toBe(true) + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + + const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') + const ghsaArgs = callArgs + .slice(applyFixesIndex + 1) + .filter(arg => arg.startsWith('GHSA-')) + + expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222']) + }) + + it('should process no GHSAs when --limit 0 is specified', async () => { + const ghsas = [ + 'GHSA-1111-1111-1111', + 'GHSA-2222-2222-2222', + 'GHSA-3333-3333-3333', + ] + + const result = await coanaFix({ + ...baseConfig, + ghsas, + limit: 0, + }) + + expect(result.ok).toBe(true) + expect(result.data?.fixed).toBe(false) + + // spawnCoanaDlx should not be called at all with limit 0. + expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() + }) + + it('should discover vulnerabilities when no GHSAs are provided', async () => { + // First call is for discovery (returns vulnerability IDs). + mockSpawnCoanaDlx.mockResolvedValueOnce({ + ok: true, + data: 'Vulnerabilities found: GHSA-aaaa-aaaa-aaaa,GHSA-bbbb-bbbb-bbbb', + }) + + // Second call is to apply fixes to the discovered IDs. + mockSpawnCoanaDlx.mockResolvedValueOnce({ + ok: true, + data: 'fix applied', + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas: [], + limit: 10, + }) + + expect(result.ok).toBe(true) + + // When ghsas is empty, it first discovers vulnerabilities, then applies fixes. + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(2) + + // First call is discovery (no --apply-fixes-to). + const discoveryArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + expect(discoveryArgs).toContain('compute-fixes-and-upgrade-purls') + expect(discoveryArgs).not.toContain('--apply-fixes-to') + + // Second call applies fixes to discovered IDs. + const applyArgs = mockSpawnCoanaDlx.mock.calls[1]?.[0] as string[] + expect(applyArgs).toContain('--apply-fixes-to') + }) + }) + + describe('PR mode', () => { + beforeEach(() => { + // Enable PR mode. + mockGetFixEnv.mockResolvedValue({ + githubToken: 'test-token', + gitUserEmail: 'test@example.com', + gitUserName: 'test-user', + isCi: true, + repoInfo: { + defaultBranch: 'main', + owner: 'test-owner', + repo: 'test-repo', + }, + }) + + mockGetSocketFixPrs.mockResolvedValue([]) + mockFetchGhsaDetails.mockResolvedValue(new Map()) + }) + + it('should process only N GHSAs when --limit N is specified in PR mode', async () => { + const ghsas = [ + 'GHSA-aaaa-aaaa-aaaa', + 'GHSA-bbbb-bbbb-bbbb', + 'GHSA-cccc-cccc-cccc', + 'GHSA-dddd-dddd-dddd', + ] + + // First call returns the IDs to process. + mockSpawnCoanaDlx.mockResolvedValueOnce({ + ok: true, + data: `Vulnerabilities found: ${ghsas.join(',')}`, + }) + + // Subsequent calls are for individual GHSA fixes. + mockSpawnCoanaDlx.mockResolvedValue({ + ok: true, + data: 'fix applied', + }) + + mockGitUnstagedModifiedFiles.mockResolvedValue({ + ok: true, + data: ['package.json'], + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas: ['all'], + limit: 2, + }) + + expect(result.ok).toBe(true) + + // First call to discover vulnerabilities, then 2 calls for the fixes. + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) + }) + + it('should adjust limit based on existing open PRs', async () => { + const ghsas = [ + 'GHSA-aaaa-aaaa-aaaa', + 'GHSA-bbbb-bbbb-bbbb', + 'GHSA-cccc-cccc-cccc', + ] + + // Mock 1 existing open PR. + mockGetSocketFixPrs.mockResolvedValueOnce([ + { number: 123, state: 'OPEN' }, + ]) + + // Second call returns no open PRs for specific GHSAs. + mockGetSocketFixPrs.mockResolvedValue([]) + + mockSpawnCoanaDlx.mockResolvedValueOnce({ + ok: true, + data: `Vulnerabilities found: ${ghsas.join(',')}`, + }) + + mockSpawnCoanaDlx.mockResolvedValue({ + ok: true, + data: 'fix applied', + }) + + mockGitUnstagedModifiedFiles.mockResolvedValue({ + ok: true, + data: ['package.json'], + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas: ['all'], + limit: 3, + }) + + expect(result.ok).toBe(true) + + // With limit 3 and 1 existing PR, adjusted limit is 2. + // So: 1 discovery call + 2 fix calls = 3 total. + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) + }) + + it('should process no GHSAs when existing open PRs exceed limit', async () => { + // Mock 5 existing open PRs. + mockGetSocketFixPrs.mockResolvedValue([ + { number: 1, state: 'OPEN' }, + { number: 2, state: 'OPEN' }, + { number: 3, state: 'OPEN' }, + { number: 4, state: 'OPEN' }, + { number: 5, state: 'OPEN' }, + ]) + + const result = await coanaFix({ + ...baseConfig, + ghsas: ['all'], + limit: 3, + }) + + expect(result.ok).toBe(true) + expect(result.data?.fixed).toBe(false) + + // With 5 open PRs and limit 3, adjusted limit is 0, so no processing. + expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() + }) + }) + + describe('--id filtering with --limit', () => { + it('should apply limit to filtered GHSA IDs', async () => { + const ghsas = [ + 'GHSA-1111-1111-1111', + 'GHSA-2222-2222-2222', + 'GHSA-3333-3333-3333', + 'GHSA-4444-4444-4444', + 'GHSA-5555-5555-5555', + ] + + mockSpawnCoanaDlx.mockResolvedValue({ + ok: true, + data: 'fix applied', + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas, + limit: 2, + }) + + expect(result.ok).toBe(true) + + // Should only process first 2 GHSAs. + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') + const ghsaArgs = callArgs + .slice(applyFixesIndex + 1) + .filter(arg => arg.startsWith('GHSA-')) + + expect(ghsaArgs).toHaveLength(2) + expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222']) + }) + + it('should handle limit 1 with single GHSA ID', async () => { + const ghsas = ['GHSA-1111-1111-1111'] + + mockSpawnCoanaDlx.mockResolvedValue({ + ok: true, + data: 'fix applied', + }) + + const result = await coanaFix({ + ...baseConfig, + ghsas, + limit: 1, + }) + + expect(result.ok).toBe(true) + expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + + const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') + const ghsaArgs = callArgs + .slice(applyFixesIndex + 1) + .filter(arg => arg.startsWith('GHSA-')) + + expect(ghsaArgs).toEqual(['GHSA-1111-1111-1111']) + }) + }) +}) diff --git a/vitest.config.mts b/vitest.config.mts index 4f3fe3963..d658a138f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -11,7 +11,7 @@ export default defineConfig({ '**/.{idea,git,cache,output,temp}/**', '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*', // Exclude E2E tests from regular test runs. - '**/*-e2e.test.mts', + '**/*.e2e.test.mts', ], coverage: { exclude: [ diff --git a/vitest.e2e.config.mts b/vitest.e2e.config.mts index 371301845..119018c79 100644 --- a/vitest.e2e.config.mts +++ b/vitest.e2e.config.mts @@ -5,7 +5,7 @@ export default defineConfig({ preserveSymlinks: false, }, test: { - include: ['**/*-e2e.test.mts'], + include: ['**/*.e2e.test.mts'], coverage: { exclude: [ '**/{eslint,vitest}.config.*',