diff --git a/packages/snyk-fix/src/plugins/python/handlers/ensure-has-updates.ts b/packages/snyk-fix/src/plugins/python/handlers/fail-if-no-updates-applied.ts similarity index 91% rename from packages/snyk-fix/src/plugins/python/handlers/ensure-has-updates.ts rename to packages/snyk-fix/src/plugins/python/handlers/fail-if-no-updates-applied.ts index 71ab595592..65298f1810 100644 --- a/packages/snyk-fix/src/plugins/python/handlers/ensure-has-updates.ts +++ b/packages/snyk-fix/src/plugins/python/handlers/fail-if-no-updates-applied.ts @@ -6,7 +6,7 @@ import { isSuccessfulChange } from './attempted-changes-summary'; const debug = debugLib('snyk-fix:python:ensure-changes-applied'); -export function ensureHasUpdates(changes: FixChangesSummary[]) { +export function failIfNoUpdatesApplied(changes: FixChangesSummary[]) { if (!changes.length) { throw new NoFixesCouldBeAppliedError(); } diff --git a/packages/snyk-fix/src/plugins/python/handlers/pipenv-pipfile/update-dependencies/generate-upgrades.ts b/packages/snyk-fix/src/plugins/python/handlers/pipenv-pipfile/update-dependencies/generate-upgrades.ts new file mode 100644 index 0000000000..8edb390bb4 --- /dev/null +++ b/packages/snyk-fix/src/plugins/python/handlers/pipenv-pipfile/update-dependencies/generate-upgrades.ts @@ -0,0 +1,17 @@ +import { EntityToFix } from '../../../../../types'; +import { standardizePackageName } from '../../../standardize-package-name'; +import { validateRequiredData } from '../../validate-required-data'; + +export function generateUpgrades(entity: EntityToFix): { upgrades: string[] } { + const { remediation } = validateRequiredData(entity); + const { pin: pins } = remediation; + + const upgrades: string[] = []; + for (const pkgAtVersion of Object.keys(pins)) { + const pin = pins[pkgAtVersion]; + const newVersion = pin.upgradeTo.split('@')[1]; + const [pkgName] = pkgAtVersion.split('@'); + upgrades.push(`${standardizePackageName(pkgName)}==${newVersion}`); + } + return { upgrades }; +} diff --git a/packages/snyk-fix/src/plugins/python/handlers/pipenv-pipfile/update-dependencies/index.ts b/packages/snyk-fix/src/plugins/python/handlers/pipenv-pipfile/update-dependencies/index.ts index c28d93ba37..9425f0992e 100644 --- a/packages/snyk-fix/src/plugins/python/handlers/pipenv-pipfile/update-dependencies/index.ts +++ b/packages/snyk-fix/src/plugins/python/handlers/pipenv-pipfile/update-dependencies/index.ts @@ -6,37 +6,26 @@ import { FixChangesSummary, FixOptions, } from '../../../../../types'; -import { NoFixesCouldBeAppliedError } from '../../../../../lib/errors/no-fixes-applied'; -import { standardizePackageName } from '../../../standardize-package-name'; -import { validateRequiredData } from '../../validate-required-data'; -import { ensureHasUpdates } from '../../ensure-has-updates'; +import { failIfNoUpdatesApplied } from '../../fail-if-no-updates-applied'; +import { NoFixesCouldBeAppliedError } from '../../../../../lib/errors/no-fixes-applied'; +import { generateUpgrades } from './generate-upgrades'; import { pipenvAdd } from './pipenv-add'; const debug = debugLib('snyk-fix:python:Pipfile'); +function chooseFixStrategy(options: FixOptions) { + return options.sequentialFix ? fixSequentially : fixAll; +} + export async function updateDependencies( entity: EntityToFix, options: FixOptions, ): Promise { - const handlerResult = await fixAll(entity, options); + const handlerResult = await chooseFixStrategy(options)(entity, options); return handlerResult; } -export function generateUpgrades(entity: EntityToFix): { upgrades: string[] } { - const { remediation } = validateRequiredData(entity); - const { pin: pins } = remediation; - - const upgrades: string[] = []; - for (const pkgAtVersion of Object.keys(pins)) { - const pin = pins[pkgAtVersion]; - const newVersion = pin.upgradeTo.split('@')[1]; - const [pkgName] = pkgAtVersion.split('@'); - upgrades.push(`${standardizePackageName(pkgName)}==${newVersion}`); - } - return { upgrades }; -} - async function fixAll( entity: EntityToFix, options: FixOptions, @@ -46,14 +35,14 @@ async function fixAll( failed: [], skipped: [], }; - const { upgrades } = await generateUpgrades(entity); - if (!upgrades.length) { - throw new NoFixesCouldBeAppliedError( - 'Failed to calculate package updates to apply', - ); - } const changes: FixChangesSummary[] = []; try { + const { upgrades } = await generateUpgrades(entity); + if (!upgrades.length) { + throw new NoFixesCouldBeAppliedError( + 'Failed to calculate package updates to apply', + ); + } // TODO: for better support we need to: // 1. parse the manifest and extract original requirements, version spec etc // 2. swap out only the version and retain original spec @@ -64,7 +53,7 @@ async function fixAll( changes.push(...(await pipenvAdd(entity, options, upgrades))); } - ensureHasUpdates(changes); + failIfNoUpdatesApplied(changes); handlerResult.succeeded.push({ original: entity, changes, @@ -81,3 +70,53 @@ async function fixAll( } return handlerResult; } + +async function fixSequentially( + entity: EntityToFix, + options: FixOptions, +): Promise { + const handlerResult: PluginFixResponse = { + succeeded: [], + failed: [], + skipped: [], + }; + const { upgrades } = await generateUpgrades(entity); + // TODO: for better support we need to: + // 1. parse the manifest and extract original requirements, version spec etc + // 2. swap out only the version and retain original spec + // 3. re-lock the lockfile + // at the moment we do not parse Pipfile and therefore can't tell the difference + // between prod & dev updates + const changes: FixChangesSummary[] = []; + + try { + if (!upgrades.length) { + throw new NoFixesCouldBeAppliedError( + 'Failed to calculate package updates to apply', + ); + } + // update prod dependencies first + if (upgrades.length) { + for (const upgrade of upgrades) { + changes.push(...(await pipenvAdd(entity, options, [upgrade]))); + } + } + + failIfNoUpdatesApplied(changes); + + handlerResult.succeeded.push({ + original: entity, + changes, + }); + } catch (error) { + debug( + `Failed to fix ${entity.scanResult.identity.targetFile}.\nERROR: ${error}`, + ); + handlerResult.failed.push({ + original: entity, + tip: error.tip, + error, + }); + } + return handlerResult; +} diff --git a/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/generate-upgrades.ts b/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/generate-upgrades.ts new file mode 100644 index 0000000000..57fa15cc40 --- /dev/null +++ b/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/generate-upgrades.ts @@ -0,0 +1,70 @@ +import * as pathLib from 'path'; +import * as toml from 'toml'; + +import * as debugLib from 'debug'; + +import { EntityToFix } from '../../../../../types'; + +import { validateRequiredData } from '../../validate-required-data'; +import { standardizePackageName } from '../../../standardize-package-name'; + +const debug = debugLib('snyk-fix:python:Poetry'); + +interface PyProjectToml { + tool: { + poetry: { + name: string; + version: string; + description: string; + authors: string[]; + dependencies?: object; + 'dev-dependencies'?: object; + }; + }; +} + +export async function generateUpgrades( + entity: EntityToFix, +): Promise<{ upgrades: string[]; devUpgrades: string[] }> { + const { remediation, targetFile } = validateRequiredData(entity); + const pins = remediation.pin; + + const targetFilePath = pathLib.resolve(entity.workspace.path, targetFile); + const { dir } = pathLib.parse(targetFilePath); + const pyProjectTomlRaw = await entity.workspace.readFile( + pathLib.resolve(dir, 'pyproject.toml'), + ); + const pyProjectToml: PyProjectToml = toml.parse(pyProjectTomlRaw); + + const prodTopLevelDeps = Object.keys( + pyProjectToml.tool.poetry.dependencies ?? {}, + ).map((dep) => standardizePackageName(dep)); + const devTopLevelDeps = Object.keys( + pyProjectToml.tool.poetry['dev-dependencies'] ?? {}, + ).map((dep) => standardizePackageName(dep)); + + const upgrades: string[] = []; + const devUpgrades: string[] = []; + for (const pkgAtVersion of Object.keys(pins)) { + const pin = pins[pkgAtVersion]; + const newVersion = pin.upgradeTo.split('@')[1]; + const [pkgName] = pkgAtVersion.split('@'); + + const upgrade = `${standardizePackageName(pkgName)}==${newVersion}`; + + if (pin.isTransitive || prodTopLevelDeps.includes(pkgName)) { + // transitive and it could have come from a dev or prod dep + // since we can't tell right now let be pinned into production deps + upgrades.push(upgrade); + } else if (prodTopLevelDeps.includes(pkgName)) { + upgrades.push(upgrade); + } else if (entity.options.dev && devTopLevelDeps.includes(pkgName)) { + devUpgrades.push(upgrade); + } else { + debug( + `Could not determine what type of upgrade ${upgrade} is. When choosing between: transitive upgrade, production or dev direct upgrade. `, + ); + } + } + return { upgrades, devUpgrades }; +} diff --git a/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/index.ts b/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/index.ts index 38d9afb203..570d8ca066 100644 --- a/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/index.ts +++ b/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/index.ts @@ -1,8 +1,4 @@ -import * as pathLib from 'path'; -import * as toml from 'toml'; - import * as debugLib from 'debug'; -import * as poetryFix from '@snyk/fix-poetry'; import { PluginFixResponse } from '../../../../types'; import { @@ -10,114 +6,25 @@ import { FixChangesSummary, FixOptions, } from '../../../../../types'; +import { generateUpgrades } from './generate-upgrades'; +import { poetryAdd } from './poetry-add'; import { NoFixesCouldBeAppliedError } from '../../../../../lib/errors/no-fixes-applied'; -import { CommandFailedError } from '../../../../../lib/errors/command-failed-to-run-error'; -import { validateRequiredData } from '../../validate-required-data'; -import { standardizePackageName } from '../../../standardize-package-name'; -import { - generateFailedChanges, - generateSuccessfulChanges, -} from '../../attempted-changes-summary'; -import { ensureHasUpdates } from '../../ensure-has-updates'; +import { failIfNoUpdatesApplied } from '../../fail-if-no-updates-applied'; const debug = debugLib('snyk-fix:python:Poetry'); -interface PyProjectToml { - tool: { - poetry: { - name: string; - version: string; - description: string; - authors: string[]; - dependencies?: object; - 'dev-dependencies'?: object; - }; - }; +function chooseFixStrategy(options: FixOptions) { + return options.sequentialFix ? fixSequentially : fixAll; } export async function updateDependencies( entity: EntityToFix, options: FixOptions, ): Promise { - const handlerResult = await fixAll(entity, options); + const handlerResult = await chooseFixStrategy(options)(entity, options); return handlerResult; } -export async function generateUpgrades( - entity: EntityToFix, -): Promise<{ upgrades: string[]; devUpgrades: string[] }> { - const { remediation, targetFile } = validateRequiredData(entity); - const pins = remediation.pin; - - const targetFilePath = pathLib.resolve(entity.workspace.path, targetFile); - const { dir } = pathLib.parse(targetFilePath); - const pyProjectTomlRaw = await entity.workspace.readFile( - pathLib.resolve(dir, 'pyproject.toml'), - ); - const pyProjectToml: PyProjectToml = toml.parse(pyProjectTomlRaw); - - const prodTopLevelDeps = Object.keys( - pyProjectToml.tool.poetry.dependencies ?? {}, - ).map((dep) => standardizePackageName(dep)); - const devTopLevelDeps = Object.keys( - pyProjectToml.tool.poetry['dev-dependencies'] ?? {}, - ).map((dep) => standardizePackageName(dep)); - - const upgrades: string[] = []; - const devUpgrades: string[] = []; - for (const pkgAtVersion of Object.keys(pins)) { - const pin = pins[pkgAtVersion]; - const newVersion = pin.upgradeTo.split('@')[1]; - const [pkgName] = pkgAtVersion.split('@'); - - const upgrade = `${standardizePackageName(pkgName)}==${newVersion}`; - - if (pin.isTransitive || prodTopLevelDeps.includes(pkgName)) { - // transitive and it could have come from a dev or prod dep - // since we can't tell right now let be pinned into production deps - upgrades.push(upgrade); - } else if (prodTopLevelDeps.includes(pkgName)) { - upgrades.push(upgrade); - } else if (entity.options.dev && devTopLevelDeps.includes(pkgName)) { - devUpgrades.push(upgrade); - } else { - debug( - `Could not determine what type of upgrade ${upgrade} is. When choosing between: transitive upgrade, production or dev direct upgrade. `, - ); - } - } - return { upgrades, devUpgrades }; -} - -function throwPoetryError(stderr: string, command?: string) { - const ALREADY_UP_TO_DATE = 'No dependencies to install or update'; - const INCOMPATIBLE_PYTHON = new RegExp( - /Python requirement (.*) is not compatible/g, - 'gm', - ); - const SOLVER_PROBLEM = /SolverProblemError(.* version solving failed)/gms; - - const incompatiblePythonError = INCOMPATIBLE_PYTHON.exec(stderr); - if (incompatiblePythonError) { - throw new CommandFailedError( - `The current project's Python requirement ${incompatiblePythonError[1]} is not compatible with some of the required packages`, - command, - ); - } - const solverProblemError = SOLVER_PROBLEM.exec(stderr); - if (solverProblemError) { - throw new CommandFailedError(solverProblemError[0].trim(), command); - } - - if (stderr.includes(ALREADY_UP_TO_DATE)) { - throw new CommandFailedError( - 'No dependencies could be updated as they seem to be at the correct versions. Make sure installed dependencies in the environment match those in the lockfile by running `poetry update`', - command, - ); - } - throw new NoFixesCouldBeAppliedError(); -} - async function fixAll( entity: EntityToFix, options: FixOptions, @@ -129,17 +36,17 @@ async function fixAll( }; const { upgrades, devUpgrades } = await generateUpgrades(entity); - if (![...upgrades, ...devUpgrades].length) { - throw new NoFixesCouldBeAppliedError( - 'Failed to calculate package updates to apply', - ); - } // TODO: for better support we need to: // 1. parse the manifest and extract original requirements, version spec etc // 2. swap out only the version and retain original spec // 3. re-lock the lockfile const changes: FixChangesSummary[] = []; try { + if (![...upgrades, ...devUpgrades].length) { + throw new NoFixesCouldBeAppliedError( + 'Failed to calculate package updates to apply', + ); + } // update prod dependencies first if (upgrades.length) { changes.push(...(await poetryAdd(entity, options, upgrades))); @@ -153,7 +60,7 @@ async function fixAll( ); } - ensureHasUpdates(changes); + failIfNoUpdatesApplied(changes); handlerResult.succeeded.push({ original: entity, changes, @@ -171,33 +78,57 @@ async function fixAll( return handlerResult; } -async function poetryAdd( +async function fixSequentially( entity: EntityToFix, options: FixOptions, - upgrades: string[], - dev?: boolean, -): Promise { +): Promise { + const handlerResult: PluginFixResponse = { + succeeded: [], + failed: [], + skipped: [], + }; + const { upgrades, devUpgrades } = await generateUpgrades(entity); + // TODO: for better support we need to: + // 1. parse the manifest and extract original requirements, version spec etc + // 2. swap out only the version and retain original spec + // 3. re-lock the lockfile const changes: FixChangesSummary[] = []; - let poetryCommand; - const { remediation, targetFile } = validateRequiredData(entity); try { - const targetFilePath = pathLib.resolve(entity.workspace.path, targetFile); - const { dir } = pathLib.parse(targetFilePath); - if (!options.dryRun && upgrades.length) { - const res = await poetryFix.poetryAdd(dir, upgrades, { - dev, - python: entity.options.command ?? undefined, - }); - if (res.exitCode !== 0) { - poetryCommand = res.command; - throwPoetryError(res.stderr ? res.stderr : res.stdout, res.command); + if (![...upgrades, ...devUpgrades].length) { + throw new NoFixesCouldBeAppliedError( + 'Failed to calculate package updates to apply', + ); + } + // update prod dependencies first + if (upgrades.length) { + for (const upgrade of upgrades) { + changes.push(...(await poetryAdd(entity, options, [upgrade]))); } } - changes.push(...generateSuccessfulChanges(upgrades, remediation.pin)); + + // update dev dependencies second + if (devUpgrades.length) { + for (const upgrade of devUpgrades) { + const installDev = true; + changes.push( + ...(await poetryAdd(entity, options, [upgrade], installDev)), + ); + } + } + failIfNoUpdatesApplied(changes); + handlerResult.succeeded.push({ + original: entity, + changes, + }); } catch (error) { - changes.push( - ...generateFailedChanges(upgrades, remediation.pin, error, poetryCommand), + debug( + `Failed to fix ${entity.scanResult.identity.targetFile}.\nERROR: ${error}`, ); + handlerResult.failed.push({ + original: entity, + tip: error.tip, + error, + }); } - return changes; + return handlerResult; } diff --git a/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/poetry-add.ts b/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/poetry-add.ts new file mode 100644 index 0000000000..4365881d54 --- /dev/null +++ b/packages/snyk-fix/src/plugins/python/handlers/poetry/update-dependencies/poetry-add.ts @@ -0,0 +1,76 @@ +import * as pathLib from 'path'; + +import * as poetryFix from '@snyk/fix-poetry'; + +import { + EntityToFix, + FixChangesSummary, + FixOptions, +} from '../../../../../types'; +import { validateRequiredData } from '../../validate-required-data'; +import { + generateFailedChanges, + generateSuccessfulChanges, +} from '../../attempted-changes-summary'; +import { CommandFailedError } from '../../../../../lib/errors/command-failed-to-run-error'; +import { NoFixesCouldBeAppliedError } from '../../../../../lib/errors/no-fixes-applied'; + +export async function poetryAdd( + entity: EntityToFix, + options: FixOptions, + upgrades: string[], + dev?: boolean, +): Promise { + const changes: FixChangesSummary[] = []; + let poetryCommand; + const { remediation, targetFile } = validateRequiredData(entity); + try { + const targetFilePath = pathLib.resolve(entity.workspace.path, targetFile); + const { dir } = pathLib.parse(targetFilePath); + if (!options.dryRun && upgrades.length) { + const res = await poetryFix.poetryAdd(dir, upgrades, { + dev, + python: entity.options.command ?? undefined, + }); + if (res.exitCode !== 0) { + poetryCommand = res.command; + throwPoetryError(res.stderr ? res.stderr : res.stdout, res.command); + } + } + changes.push(...generateSuccessfulChanges(upgrades, remediation.pin)); + } catch (error) { + changes.push( + ...generateFailedChanges(upgrades, remediation.pin, error, poetryCommand), + ); + } + return changes; +} + +function throwPoetryError(stderr: string, command?: string) { + const ALREADY_UP_TO_DATE = 'No dependencies to install or update'; + const INCOMPATIBLE_PYTHON = new RegExp( + /Python requirement (.*) is not compatible/g, + 'gm', + ); + const SOLVER_PROBLEM = /SolverProblemError(.* version solving failed)/gms; + + const incompatiblePythonError = INCOMPATIBLE_PYTHON.exec(stderr); + if (incompatiblePythonError) { + throw new CommandFailedError( + `The current project's Python requirement ${incompatiblePythonError[1]} is not compatible with some of the required packages`, + command, + ); + } + const solverProblemError = SOLVER_PROBLEM.exec(stderr); + if (solverProblemError) { + throw new CommandFailedError(solverProblemError[0].trim(), command); + } + + if (stderr.includes(ALREADY_UP_TO_DATE)) { + throw new CommandFailedError( + 'No dependencies could be updated as they seem to be at the correct versions. Make sure installed dependencies in the environment match those in the lockfile by running `poetry update`', + command, + ); + } + throw new NoFixesCouldBeAppliedError(); +} diff --git a/packages/snyk-fix/src/types.ts b/packages/snyk-fix/src/types.ts index 8ad4916605..14ef906b33 100644 --- a/packages/snyk-fix/src/types.ts +++ b/packages/snyk-fix/src/types.ts @@ -239,6 +239,7 @@ export interface FixOptions { dryRun?: boolean; quiet?: boolean; stripAnsi?: boolean; + sequentialFix?: boolean; } export interface FixedMeta { diff --git a/packages/snyk-fix/test/acceptance/plugins/python/handlers/pipenv-pipfile/update-dependencies.spec.ts b/packages/snyk-fix/test/acceptance/plugins/python/handlers/pipenv-pipfile/update-dependencies.spec.ts index 4355c65ea1..f3efc29f7f 100644 --- a/packages/snyk-fix/test/acceptance/plugins/python/handlers/pipenv-pipfile/update-dependencies.spec.ts +++ b/packages/snyk-fix/test/acceptance/plugins/python/handlers/pipenv-pipfile/update-dependencies.spec.ts @@ -176,13 +176,13 @@ describe('fix Pipfile Python projects', () => { ); expect(result.fixSummary).toContain('✖ No successful fixes'); expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(1); - expect(pipenvPipfileFixStub.mock.calls[0]).toEqual([ + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'with-dev-deps'), ['django==2.0.1', 'transitive==1.1.1'], { python: 'python3', }, - ]); + ); }); it('applies expected changes to Pipfile when install fails', async () => { @@ -259,13 +259,455 @@ describe('fix Pipfile Python projects', () => { ); expect(result.fixSummary).toContain('✖ No successful fixes'); expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(1); - expect(pipenvPipfileFixStub.mock.calls[0]).toEqual([ + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'with-dev-deps'), ['django==2.0.1', 'transitive==1.1.1'], { python: 'python3', }, - ]); + ); + }); + + it('applies expected changes to Pipfile (100% success)', async () => { + jest.spyOn(pipenvPipfileFix, 'pipenvInstall').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'pipenv install django==2.0.1', + duration: 123, + }); + // Arrange + const targetFile = 'with-django-upgrade/Pipfile'; + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'django@1.6.1': { + upgradeTo: 'django@2.0.1', + vulns: ['vuln-id'], + isTransitive: false, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + success: true, + userMessage: 'Upgraded django from 1.6.1 to 2.0.1', + }, + ], + }, + ], + }, + }, + }); + expect(result.fixSummary).toContain( + '✔ Upgraded django from 1.6.1 to 2.0.1', + ); + expect(result.fixSummary).toContain('1 items were successfully fixed'); + expect(result.fixSummary).toContain('1 issues were successfully fixed'); + expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(1); + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'with-django-upgrade'), + ['django==2.0.1'], + { + python: 'python3', + }, + ); + }); + + it('passes down custom --python if the project was tested with this (--command) from CLI', async () => { + // Arrange + const targetFile = 'with-django-upgrade/Pipfile'; + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'django@1.6.1': { + upgradeTo: 'django@2.0.1', + vulns: ['vuln-id'], + isTransitive: false, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + entityToFix.options.command = 'python2'; + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + }); + + // Assert + expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(1); + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'with-django-upgrade'), + ['django==2.0.1'], + { + python: 'python2', + }, + ); + + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + success: true, + userMessage: 'Upgraded django from 1.6.1 to 2.0.1', + }, + ], + }, + ], + }, + }, + }); + expect(result.fixSummary).toContain( + '✔ Upgraded django from 1.6.1 to 2.0.1', + ); + expect(result.fixSummary).toContain('1 items were successfully fixed'); + expect(result.fixSummary).toContain('1 issues were successfully fixed'); + }); +}); + +describe('fix Pipfile Python projects (fix sequentially)', () => { + let pipenvPipfileFixStub: jest.SpyInstance; + beforeAll(() => { + jest.spyOn(pipenvPipfileFix, 'isPipenvSupportedVersion').mockReturnValue({ + supported: true, + versions: ['123.123.123'], + }); + jest.spyOn(pipenvPipfileFix, 'isPipenvInstalled').mockResolvedValue({ + version: '123.123.123', + }); + }); + + beforeEach(() => { + pipenvPipfileFixStub = jest.spyOn(pipenvPipfileFix, 'pipenvInstall'); + }); + + afterEach(() => { + pipenvPipfileFixStub.mockClear(); + }); + + const workspacesPath = pathLib.resolve(__dirname, 'workspaces'); + + it('shows expected changes with lockfile in --dry-run mode', async () => { + jest.spyOn(pipenvPipfileFix, 'pipenvInstall').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'pipenv install', + duration: 123, + }); + // Arrange + const targetFile = 'with-dev-deps/Pipfile'; + + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'django@1.6.1': { + upgradeTo: 'django@2.0.1', + vulns: [], + isTransitive: false, + }, + 'transitive@1.0.0': { + upgradeTo: 'transitive@1.1.1', + vulns: [], + isTransitive: true, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + dryRun: true, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + success: true, + userMessage: 'Upgraded django from 1.6.1 to 2.0.1', + }, + { + success: true, + userMessage: 'Pinned transitive from 1.0.0 to 1.1.1', + }, + ], + }, + ], + }, + }, + }); + expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(0); + }); + + // FYI: on later pipenv versions the Pipfile changes are also not present of locking failed + it('applies expected changes to Pipfile when locking fails', async () => { + jest.spyOn(pipenvPipfileFix, 'pipenvInstall').mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: 'Locking failed', + command: 'pipenv install django==2.0.1', + duration: 123, + }); + + jest.spyOn(pipenvPipfileFix, 'pipenvInstall').mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'poetry install transitive==1.1.1', + duration: 123, + }); + // Arrange + const targetFile = 'with-dev-deps/Pipfile'; + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'django@1.6.1': { + upgradeTo: 'django@2.0.1', + vulns: ['vuln-id'], + isTransitive: false, + }, + 'transitive@1.0.0': { + upgradeTo: 'transitive@1.1.1', + vulns: [], + isTransitive: true, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + from: 'django@1.6.1', + issueIds: ['vuln-id'], + reason: 'Locking failed', + success: false, + tip: 'Try running `pipenv install django==2.0.1`', + to: 'django@2.0.1', + userMessage: 'Failed to upgrade django from 1.6.1 to 2.0.1', + }, + { + from: 'transitive@1.0.0', + issueIds: [], + success: true, + to: 'transitive@1.1.1', + userMessage: 'Pinned transitive from 1.0.0 to 1.1.1', + }, + ], + }, + ], + }, + }, + }); + expect(result.fixSummary).toContain('Locking failed'); + expect(result.fixSummary).toContain( + 'Tip: Try running `pipenv install django==2.0.1`', + ); + expect(result.fixSummary).toContain( + '✔ Pinned transitive from 1.0.0 to 1.1.1', + ); + expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(2); + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'with-dev-deps'), + ['django==2.0.1'], + { + python: 'python3', + }, + ); + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'with-dev-deps'), + ['transitive==1.1.1'], + { + python: 'python3', + }, + ); + }); + + it('applies expected changes to Pipfile when install fails', async () => { + jest.spyOn(pipenvPipfileFix, 'pipenvInstall').mockResolvedValue({ + exitCode: 1, + stdout: '', + stderr: `Updating dependenciesResolving dependencies... (1.1s) + + SolverProblemError + + Because django (2.6) depends on numpy (>=1.19)and tensorflow (2.2.1) depends on numpy (>=1.16.0,<1.19.0), django (2.6) is incompatible with tensorflow (2.2.1).So, because pillow depends on both tensorflow (2.2.1) and django (2.6), version solving failed.`, + command: 'pipenv install django==2.0.1 transitive==1.1.1', + duration: 123, + }); + + // Arrange + const targetFile = 'with-dev-deps/Pipfile'; + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'django@1.6.1': { + upgradeTo: 'django@2.0.1', + vulns: ['vuln-id'], + isTransitive: false, + }, + 'transitive@1.0.0': { + upgradeTo: 'transitive@1.1.1', + vulns: [], + isTransitive: true, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [ + { + original: entityToFix, + error: expect.objectContaining({ + name: 'NoFixesCouldBeAppliedError', + }), + tip: + 'Try running `pipenv install django==2.0.1 transitive==1.1.1`', + }, + ], + skipped: [], + succeeded: [], + }, + }, + }); + expect(result.fixSummary).toContain('version solving failed'); + expect(result.fixSummary).toContain( + 'Tip: Try running `pipenv install django==2.0.1 transitive==1.1.1`', + ); + expect(result.fixSummary).toContain('✖ No successful fixes'); + expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(2); + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'with-dev-deps'), + ['django==2.0.1'], + { + python: 'python3', + }, + ); + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'with-dev-deps'), + ['transitive==1.1.1'], + { + python: 'python3', + }, + ); }); it('applies expected changes to Pipfile (100% success)', async () => { @@ -305,6 +747,7 @@ describe('fix Pipfile Python projects', () => { const result = await snykFix.fix([entityToFix], { quiet: true, stripAnsi: true, + sequentialFix: true, }); // Assert expect(result).toMatchObject({ @@ -333,13 +776,13 @@ describe('fix Pipfile Python projects', () => { expect(result.fixSummary).toContain('1 items were successfully fixed'); expect(result.fixSummary).toContain('1 issues were successfully fixed'); expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(1); - expect(pipenvPipfileFixStub.mock.calls[0]).toEqual([ + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'with-django-upgrade'), ['django==2.0.1'], { python: 'python3', }, - ]); + ); }); it('passes down custom --python if the project was tested with this (--command) from CLI', async () => { @@ -373,17 +816,18 @@ describe('fix Pipfile Python projects', () => { const result = await snykFix.fix([entityToFix], { quiet: true, stripAnsi: true, + sequentialFix: true, }); // Assert expect(pipenvPipfileFixStub).toHaveBeenCalledTimes(1); - expect(pipenvPipfileFixStub.mock.calls[0]).toEqual([ + expect(pipenvPipfileFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'with-django-upgrade'), ['django==2.0.1'], { python: 'python2', }, - ]); + ); expect(result).toMatchObject({ exceptions: {}, diff --git a/packages/snyk-fix/test/acceptance/plugins/python/handlers/poetry/update-dependencies.spec.ts b/packages/snyk-fix/test/acceptance/plugins/python/handlers/poetry/update-dependencies.spec.ts index c06a3729fb..c359fc17fe 100644 --- a/packages/snyk-fix/test/acceptance/plugins/python/handlers/poetry/update-dependencies.spec.ts +++ b/packages/snyk-fix/test/acceptance/plugins/python/handlers/poetry/update-dependencies.spec.ts @@ -181,13 +181,13 @@ describe('fix Poetry Python projects', () => { ); expect(result.fixSummary).toContain('✖ No successful fixes'); expect(poetryFixStub).toHaveBeenCalledTimes(1); - expect(poetryFixStub.mock.calls[0]).toEqual([ + expect(poetryFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'simple'), ['six==2.0.1', 'transitive==1.1.1'], { python: 'python3', }, - ]); + ); }); it('Calls the plugin with expected parameters (upgrade & pin)', async () => { @@ -260,13 +260,13 @@ describe('fix Poetry Python projects', () => { }, }); expect(poetryFixStub.mock.calls).toHaveLength(1); - expect(poetryFixStub.mock.calls[0]).toEqual([ + expect(poetryFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'simple'), ['six==2.0.1', 'transitive==1.1.1'], { python: 'python3', }, - ]); + ); }); it('Calls the plugin with expected parameters with --dev (upgrade & pin)', async () => { @@ -361,18 +361,18 @@ describe('fix Poetry Python projects', () => { }, }); expect(poetryFixStub.mock.calls).toHaveLength(2); - expect(poetryFixStub.mock.calls[0]).toEqual([ + expect(poetryFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'simple'), ['six==2.0.1', 'transitive==1.1.1'], {}, - ]); - expect(poetryFixStub.mock.calls[1]).toEqual([ + ); + expect(poetryFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'simple'), ['json-api==0.1.22'], { dev: true, }, - ]); + ); }); it('pins a transitive dep with custom python interpreter via --command', async () => { jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValue({ @@ -438,13 +438,13 @@ describe('fix Poetry Python projects', () => { }, }); expect(poetryFixStub.mock.calls).toHaveLength(1); - expect(poetryFixStub.mock.calls[0]).toEqual([ + expect(poetryFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'simple'), ['markupsafe==2.1.0'], { python: 'python2', }, - ]); + ); }); it('shows expected changes when updating a dev dep', async () => { jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValue({ @@ -510,13 +510,582 @@ describe('fix Poetry Python projects', () => { }, }); expect(poetryFixStub.mock.calls).toHaveLength(1); - expect(poetryFixStub.mock.calls[0]).toEqual([ + expect(poetryFixStub).toHaveBeenCalledWith( pathLib.resolve(workspacesPath, 'with-dev-deps'), ['json-api==0.1.22'], { dev: true, }, - ]); + ); + }); + + it.todo( + 'upgrade fails since the env already has the right versions (full failure)', + ); + + it.todo('upgrade of dev deps fails (partial failure)'); +}); + +describe('fix Poetry Python projects fix sequentially', () => { + let poetryFixStub: jest.SpyInstance; + beforeAll(() => { + jest.spyOn(poetryFix, 'isPoetrySupportedVersion').mockReturnValue({ + supported: true, + versions: ['1.1.1'], + }); + jest.spyOn(poetryFix, 'isPoetryInstalled').mockResolvedValue({ + version: '1.1.1', + }); + }); + + beforeEach(() => { + poetryFixStub = jest.spyOn(poetryFix, 'poetryAdd'); + }); + + afterEach(() => { + poetryFixStub.mockClear(); + }); + + const workspacesPath = pathLib.resolve(__dirname, 'workspaces'); + + it('shows expected changes with lockfile in --dry-run mode', async () => { + jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'poetry install', + duration: 123, + }); + // Arrange + const targetFile = 'simple/pyproject.toml'; + + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'six@1.1.6': { + upgradeTo: 'six@2.0.1', + vulns: ['VULN-six'], + isTransitive: false, + }, + 'transitive@1.0.0': { + upgradeTo: 'transitive@1.1.1', + vulns: [], + isTransitive: true, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + dryRun: true, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + success: true, + userMessage: 'Upgraded six from 1.1.6 to 2.0.1', + }, + { + success: true, + userMessage: 'Pinned transitive from 1.0.0 to 1.1.1', + }, + ], + }, + ], + }, + }, + }); + expect(poetryFixStub.mock.calls).toHaveLength(0); + }); + + it('error is bubbled up', async () => { + jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValueOnce({ + exitCode: 1, + stdout: '', + stderr: `Resolving dependencies... (1.7s) + + SolverProblemError + + Because package-A (2.6) depends on package-B (>=1.19) + and package-C (2.2.1) depends on package-B (>=1.16.0,<1.19.0), package-D (2.6) is incompatible with package-C (2.2.1). + So, because package-Z depends on both package-C (2.2.1) and package-D (2.6), version solving failed.`, + command: 'poetry install six==2.0.1', + duration: 123, + }); + jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'poetry install transitive==1.1.1', + duration: 123, + }); + + // Arrange + const targetFile = 'simple/pyproject.toml'; + + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'six@1.1.6': { + upgradeTo: 'six@2.0.1', + vulns: ['VULN-six'], + isTransitive: false, + }, + 'transitive@1.0.0': { + upgradeTo: 'transitive@1.1.1', + vulns: [], + isTransitive: true, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + dryRun: false, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + from: 'six@1.1.6', + issueIds: ['VULN-six'], + reason: `SolverProblemError + + Because package-A (2.6) depends on package-B (>=1.19) + and package-C (2.2.1) depends on package-B (>=1.16.0,<1.19.0), package-D (2.6) is incompatible with package-C (2.2.1). + So, because package-Z depends on both package-C (2.2.1) and package-D (2.6), version solving failed`, + success: false, + tip: 'Try running `poetry install six==2.0.1`', + to: 'six@2.0.1', + userMessage: 'Failed to upgrade six from 1.1.6 to 2.0.1', + }, + { + from: 'transitive@1.0.0', + issueIds: [], + success: true, + to: 'transitive@1.1.1', + userMessage: 'Pinned transitive from 1.0.0 to 1.1.1', + }, + ], + }, + ], + }, + }, + }); + expect(result.fixSummary).toContain('SolverProblemError'); + expect(result.fixSummary).toContain( + 'Tip: Try running `poetry install six==2.0.1`', + ); + expect(result.fixSummary).toContain( + 'Pinned transitive from 1.0.0 to 1.1.1', + ); + expect(poetryFixStub).toHaveBeenCalledTimes(2); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['six==2.0.1'], + { + python: 'python3', + }, + ); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['transitive==1.1.1'], + { + python: 'python3', + }, + ); + }); + + it('Calls the plugin with expected parameters (upgrade & pin)', async () => { + jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'poetry install', + duration: 123, + }); + // Arrange + const targetFile = 'simple/pyproject.toml'; + + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'six@1.1.6': { + upgradeTo: 'six@2.0.1', + vulns: ['VULN-six'], + isTransitive: false, + }, + 'transitive@1.0.0': { + upgradeTo: 'transitive@1.1.1', + vulns: [], + isTransitive: true, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + success: true, + userMessage: 'Upgraded six from 1.1.6 to 2.0.1', + }, + { + success: true, + userMessage: 'Pinned transitive from 1.0.0 to 1.1.1', + }, + ], + }, + ], + }, + }, + }); + expect(poetryFixStub.mock.calls).toHaveLength(2); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['six==2.0.1'], + { + python: 'python3', + }, + ); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['transitive==1.1.1'], + { + python: 'python3', + }, + ); + }); + + it('Calls the plugin with expected parameters with --dev (upgrade & pin)', async () => { + jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'poetry install', + duration: 123, + }); + // Arrange + const targetFile = 'simple/pyproject.toml'; + + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'six@1.1.6': { + upgradeTo: 'six@2.0.1', + vulns: ['VULN-six'], + isTransitive: false, + }, + 'transitive@1.0.0': { + upgradeTo: 'transitive@1.1.1', + vulns: ['vuln-transitive'], + isTransitive: true, + }, + 'json-api@0.1.21': { + upgradeTo: 'json-api@0.1.22', + vulns: ['SNYK-1'], + isTransitive: false, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + { + dev: true, + }, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + sequentialFix: true, + }); + + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + from: 'six@1.1.6', + to: 'six@2.0.1', + issueIds: ['VULN-six'], + success: true, + userMessage: 'Upgraded six from 1.1.6 to 2.0.1', + }, + { + from: 'transitive@1.0.0', + to: 'transitive@1.1.1', + issueIds: ['vuln-transitive'], + success: true, + userMessage: 'Pinned transitive from 1.0.0 to 1.1.1', + }, + { + from: 'json-api@0.1.21', + to: 'json-api@0.1.22', + issueIds: ['SNYK-1'], + success: true, + userMessage: 'Upgraded json-api from 0.1.21 to 0.1.22', + }, + ], + }, + ], + }, + }, + }); + expect(poetryFixStub.mock.calls).toHaveLength(3); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['transitive==1.1.1'], + {}, + ); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['six==2.0.1'], + {}, + ); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['json-api==0.1.22'], + { + dev: true, + }, + ); + }); + it('pins a transitive dep with custom python interpreter via --command', async () => { + jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'poetry install', + duration: 123, + }); + // Arrange + const targetFile = 'simple/poetry.lock'; + + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'markupsafe@2.0.1': { + upgradeTo: 'markupsafe@2.1.0', + vulns: ['SNYK-1'], + isTransitive: true, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + { + command: 'python2', + }, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + success: true, + userMessage: 'Pinned markupsafe from 2.0.1 to 2.1.0', + }, + ], + }, + ], + }, + }, + }); + expect(poetryFixStub.mock.calls).toHaveLength(1); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'simple'), + ['markupsafe==2.1.0'], + { + python: 'python2', + }, + ); + }); + it('shows expected changes when updating a dev dep', async () => { + jest.spyOn(poetryFix, 'poetryAdd').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '', + command: 'poetry install', + duration: 123, + }); + // Arrange + const targetFile = 'with-dev-deps/pyproject.toml'; + + const testResult = { + ...generateTestResult(), + remediation: { + unresolved: [], + upgrade: {}, + patch: {}, + ignore: {}, + pin: { + 'json-api@0.1.21': { + upgradeTo: 'json-api@0.1.22', + vulns: ['SNYK-1'], + isTransitive: false, + }, + }, + }, + }; + + const entityToFix = generateEntityToFixWithFileReadWrite( + workspacesPath, + targetFile, + testResult, + { + dev: true, + }, + ); + + // Act + const result = await snykFix.fix([entityToFix], { + quiet: true, + stripAnsi: true, + sequentialFix: true, + }); + // Assert + expect(result).toMatchObject({ + exceptions: {}, + results: { + python: { + failed: [], + skipped: [], + succeeded: [ + { + original: entityToFix, + changes: [ + { + success: true, + userMessage: 'Upgraded json-api from 0.1.21 to 0.1.22', + }, + ], + }, + ], + }, + }, + }); + expect(poetryFixStub.mock.calls).toHaveLength(1); + expect(poetryFixStub).toHaveBeenCalledWith( + pathLib.resolve(workspacesPath, 'with-dev-deps'), + ['json-api==0.1.22'], + { + dev: true, + }, + ); }); it.todo( diff --git a/packages/snyk-fix/test/unit/plugins/python/handlers/pipenv-pipfile/update-dependencies/update-dependencies.ts b/packages/snyk-fix/test/unit/plugins/python/handlers/pipenv-pipfile/update-dependencies/update-dependencies.ts index 29293b4b4c..d38925db28 100644 --- a/packages/snyk-fix/test/unit/plugins/python/handlers/pipenv-pipfile/update-dependencies/update-dependencies.ts +++ b/packages/snyk-fix/test/unit/plugins/python/handlers/pipenv-pipfile/update-dependencies/update-dependencies.ts @@ -1,10 +1,10 @@ -import { - generateSuccessfulChanges, - generateUpgrades, -} from '../../../../../../../src/plugins/python/handlers/pipenv-pipfile/update-dependencies'; +import { generateSuccessfulChanges } from '../../../../../../../src/plugins/python/handlers/attempted-changes-summary'; +import { generateUpgrades } from '../../../../../../../src/plugins/python/handlers/pipenv-pipfile/update-dependencies/generate-upgrades'; +import { generateEntityToFix } from '../../../../../../helpers/generate-entity-to-fix'; describe('generateUpgrades', () => { it('generates upgrades as expected', async () => { + const entityToFix = generateEntityToFix('pip', 'Pipfile', ''); // Arrange const pinRemediation = { 'django@1.6.1': { @@ -18,18 +18,50 @@ describe('generateUpgrades', () => { isTransitive: true, }, }; + (entityToFix.testResult as any).remediation = { + ignore: {}, + patch: {}, + pin: pinRemediation, + unresolved: [], + // only pins are supported for Python + upgrade: { + 'json-api@0.1.21': { + upgradeTo: 'json-api@0.1.22', + upgrades: ['json-api@0.1.22'], + vulns: ['pip:json-api:20170213'], + isTransitive: false, + }, + }, + }; // Act - const res = await generateUpgrades(pinRemediation); + const { upgrades } = await generateUpgrades(entityToFix); // Assert - expect(res).toEqual(['django>=2.0.1', 'transitive>=1.1.1']); + expect(upgrades).toEqual(['django>=2.0.1', 'transitive>=1.1.1']); }); it('returns [] when no pins available', async () => { // Arrange + const entityToFix = generateEntityToFix('pip', 'Pipfile', ''); + // Arrange + (entityToFix.testResult as any).remediation = { + ignore: {}, + patch: {}, + pin: {}, + unresolved: [], + // only pins are supported for Python + upgrade: { + 'json-api@0.1.21': { + upgradeTo: 'json-api@0.1.22', + upgrades: ['json-api@0.1.22'], + vulns: ['pip:json-api:20170213'], + isTransitive: false, + }, + }, + }; // Act - const res = await generateUpgrades({}); + const { upgrades } = await generateUpgrades(entityToFix); // Assert - expect(res).toEqual([]); + expect(upgrades).toEqual([]); }); }); @@ -50,7 +82,10 @@ describe('generateSuccessfulChanges', () => { }; // Act - const res = await generateSuccessfulChanges(pinRemediation); + const res = await generateSuccessfulChanges( + ['django===2.0.1', 'transitive==1.1.1'], + pinRemediation, + ); // Assert expect(res).toEqual([ { @@ -72,7 +107,7 @@ describe('generateSuccessfulChanges', () => { it('returns [] when no pins available', async () => { // Arrange // Act - const res = await generateSuccessfulChanges({}); + const res = await generateSuccessfulChanges([], {}); // Assert expect(res).toEqual([]); }); diff --git a/packages/snyk-fix/test/unit/plugins/python/handlers/poetry/generate-upgrades.spec.ts b/packages/snyk-fix/test/unit/plugins/python/handlers/poetry/generate-upgrades.spec.ts index 5f994b4396..0a5ae2cc7e 100644 --- a/packages/snyk-fix/test/unit/plugins/python/handlers/poetry/generate-upgrades.spec.ts +++ b/packages/snyk-fix/test/unit/plugins/python/handlers/poetry/generate-upgrades.spec.ts @@ -1,4 +1,4 @@ -import { generateUpgrades } from '../../../../../../src/plugins/python/handlers/poetry/update-dependencies'; +import { generateUpgrades } from '../../../../../../src/plugins/python/handlers/poetry/update-dependencies/generate-upgrades'; import { generateEntityToFix } from '../../../../../helpers/generate-entity-to-fix'; describe('generateUpgrades', () => { it('returns empty if no upgrades could be generated', async () => { @@ -25,8 +25,7 @@ describe('generateUpgrades', () => { manifestContents, ); - // @ts-ignore: for test purpose need specific remediation - entityToFix.testResult.remediation = { + (entityToFix.testResult as any).remediation = { ignore: {}, patch: {}, pin: {}, @@ -70,12 +69,10 @@ describe('generateUpgrades', () => { 'pyproject.toml', manifestContents, ); - // @ts-ignore: for test purpose need specific remediation - entityToFix.options = { + (entityToFix as any).options = { dev: true, }; - // @ts-ignore: for test purpose need specific remediation - entityToFix.testResult.remediation = { + (entityToFix.testResult as any).remediation = { ignore: {}, patch: {}, pin: { @@ -120,8 +117,7 @@ describe('generateUpgrades', () => { 'pyproject.toml', manifestContents, ); - // @ts-ignore: for test purpose need specific remediation - entityToFix.testResult.remediation = { + (entityToFix.testResult as any).remediation = { ignore: {}, patch: {}, pin: { @@ -165,8 +161,7 @@ describe('generateUpgrades', () => { 'pyproject.toml', manifestContents, ); - // @ts-ignore: for test purpose need specific remediation - entityToFix.testResult.remediation = { + (entityToFix.testResult as any).remediation = { ignore: {}, patch: {}, pin: { @@ -212,12 +207,10 @@ describe('generateUpgrades', () => { 'pyproject.toml', manifestContents, ); - // @ts-ignore: for test purpose need specific remediation - entityToFix.options = { + (entityToFix as any).options = { dev: true, }; - // @ts-ignore: for test purpose need specific remediation - entityToFix.testResult.remediation = { + (entityToFix.testResult as any).remediation = { ignore: {}, patch: {}, pin: { diff --git a/src/cli/args.ts b/src/cli/args.ts index bf942a9c51..00baaf0471 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,7 +1,7 @@ import * as abbrev from 'abbrev'; import { MethodResult } from './commands/types'; -import debugModule = require('debug'); +import * as debugModule from 'debug'; import { parseMode, displayModeHelp } from './modes'; import { SupportedCliCommands, @@ -218,6 +218,7 @@ export function args(rawArgv: string[]): Args { 'integration-version', 'prune-repeated-subdependencies', 'dry-run', + 'sequential', ]; for (const dashedArg of argumentsToTransform) { if (argv[dashedArg]) { diff --git a/src/cli/commands/fix/index.ts b/src/cli/commands/fix/index.ts index 613e6ecfac..ce3c07792a 100644 --- a/src/cli/commands/fix/index.ts +++ b/src/cli/commands/fix/index.ts @@ -25,6 +25,7 @@ const snykFixFeatureFlag = 'cliSnykFix'; interface FixOptions { dryRun?: boolean; quiet?: boolean; + sequential?: boolean; } export default async function fix(...args: MethodArgs): Promise { const { options: rawOptions, paths } = await processCommandArgs( @@ -44,12 +45,13 @@ export default async function fix(...args: MethodArgs): Promise { const vulnerableResults = results.filter( (res) => Object.keys(res.testResult.issues).length, ); - const { dryRun, quiet } = options; + const { dryRun, quiet, sequential: sequentialFix } = options; const { fixSummary, meta, results: resultsByPlugin } = await snykFix.fix( results, { dryRun, quiet, + sequentialFix, }, ); diff --git a/src/lib/types.ts b/src/lib/types.ts index 976abf711d..741635d8e9 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -163,6 +163,7 @@ export type SupportedUserReachableFacingCliArgs = | 'detection-depth' | 'docker' | 'dry-run' + | 'sequential' | 'fail-on' | 'file' | 'gradle-sub-project'