diff --git a/src/codeActionHandler.ts b/src/codeActionHandler.ts index 43e8859c..a91c9478 100644 --- a/src/codeActionHandler.ts +++ b/src/codeActionHandler.ts @@ -12,6 +12,13 @@ import { RHDA_DIAGNOSTIC_SOURCE } from './constants'; let codeActionsMap: Map = new Map(); +/** + * Gets the code actions map. + */ +function getCodeActionsMap(): Map { + return codeActionsMap; +} + /** * Clears the code actions map. */ @@ -35,26 +42,15 @@ function registerCodeAction(key: string, codeAction: CodeAction) { * @param uri - The URI of the file being analyzed. * @returns An array of CodeAction objects to be made available to the user. */ -function getDiagnosticsCodeActions(diagnostics: Diagnostic[], uri: string): CodeAction[] { +function getDiagnosticsCodeActions(diagnostics: Diagnostic[]): CodeAction[] { let hasRhdaDiagonostic: boolean = false; const codeActions: CodeAction[] = []; for (const diagnostic of diagnostics) { - const diagnosticCodeActions = codeActionsMap[diagnostic.range.start.line + '|' + diagnostic.range.start.character]; - if (diagnosticCodeActions && diagnosticCodeActions.length !== 0) { - - if (path.basename(uri) === 'pom.xml') { - diagnosticCodeActions.forEach(codeAction => { - codeAction.command = { - title: 'RedHat repository recommendation', - command: globalConfig.triggerRHRepositoryRecommendationNotification, - }; - }); - } - codeActions.push(...diagnosticCodeActions); - - } + const key = `${diagnostic.range.start.line}|${diagnostic.range.start.character}`; + const diagnosticCodeActions = codeActionsMap[key] || []; + codeActions.push(...diagnosticCodeActions); hasRhdaDiagonostic ||= diagnostic.source === RHDA_DIAGNOSTIC_SOURCE; } @@ -104,7 +100,15 @@ function generateSwitchToRecommendedVersionAction(title: string, versionReplacem range: diagnostic.range, newText: versionReplacementString }]; + + if (diagnostic.severity !== 1 && path.basename(uri) === 'pom.xml') { + codeAction.command = { + title: 'RedHat repository recommendation', + command: globalConfig.triggerRHRepositoryRecommendationNotification, + }; + } + return codeAction; } -export { clearCodeActionsMap, registerCodeAction , generateSwitchToRecommendedVersionAction, getDiagnosticsCodeActions }; \ No newline at end of file +export { getCodeActionsMap, clearCodeActionsMap, registerCodeAction , generateSwitchToRecommendedVersionAction, getDiagnosticsCodeActions }; \ No newline at end of file diff --git a/src/componentAnalysis.ts b/src/componentAnalysis.ts index 5f2465c0..95920aa7 100644 --- a/src/componentAnalysis.ts +++ b/src/componentAnalysis.ts @@ -138,7 +138,7 @@ class AnalysisResponse implements IAnalysisResponse { * @private */ private getRemediation(issue: any): string { - return isDefined(issue, 'remediation', 'trustedContent', 'package') ? issue.remediation.trustedContent.package : ''; + return isDefined(issue, 'remediation', 'trustedContent', 'ref') ? issue.remediation.trustedContent.ref.split('?')[0] : ''; } /** diff --git a/src/diagnosticsHandler.ts b/src/diagnosticsHandler.ts index 72035a71..515bbb87 100644 --- a/src/diagnosticsHandler.ts +++ b/src/diagnosticsHandler.ts @@ -12,7 +12,7 @@ import { Vulnerability } from './vulnerability'; import { connection } from './server'; import { VERSION_PLACEHOLDER } from './constants'; import { clearCodeActionsMap, registerCodeAction, generateSwitchToRecommendedVersionAction } from './codeActionHandler'; -import { IPositionedContext } from './collector' +import { IPositionedContext } from './collector'; /** * Diagnostics Pipeline specification. @@ -108,7 +108,7 @@ class DiagnosticsPipeline implements IDiagnosticsPipeline { * @private */ private createCodeAction(loc: string, ref: string, context: IPositionedContext, sourceId: string, vulnerabilityDiagnostic: Diagnostic) { - const switchToVersion = this.provider.resolveDependencyFromReference(ref).split('@')[1] + const switchToVersion = this.provider.resolveDependencyFromReference(ref).split('@')[1]; const versionReplacementString = context ? context.value.replace(VERSION_PLACEHOLDER, switchToVersion) : switchToVersion; const title = `Switch to version ${switchToVersion} for ${sourceId}`; const codeAction = generateSwitchToRecommendedVersionAction(title, versionReplacementString, vulnerabilityDiagnostic, this.diagnosticFilePath); diff --git a/src/server.ts b/src/server.ts index e5d36ac7..dd8fb142 100644 --- a/src/server.ts +++ b/src/server.ts @@ -108,7 +108,7 @@ connection.onDidChangeConfiguration(() => { * Handles code action requests from client. */ connection.onCodeAction((params): CodeAction[] => { - return getDiagnosticsCodeActions(params.context.diagnostics, params.textDocument.uri); + return getDiagnosticsCodeActions(params.context.diagnostics); }); connection.listen(); diff --git a/test/codeActionHandler.test.ts b/test/codeActionHandler.test.ts index cd3dbe49..1b6fa9f0 100644 --- a/test/codeActionHandler.test.ts +++ b/test/codeActionHandler.test.ts @@ -7,11 +7,11 @@ import { CodeAction, CodeActionKind, Diagnostic } from 'vscode-languageserver/no import * as config from '../src/config'; import { RHDA_DIAGNOSTIC_SOURCE } from '../src/constants'; -import { getDiagnosticsCodeActions } from '../src/codeActionHandler'; +import * as codeActionHandler from '../src/codeActionHandler'; describe('Code Action Handler tests', () => { - const mockRange: Range = { + const mockRange0: Range = { start: { line: 123, character: 123 @@ -22,58 +22,278 @@ describe('Code Action Handler tests', () => { } }; - const mockDiagnostics: Diagnostic[] = [ + const mockRange1: Range = { + start: { + line: 321, + character: 321 + }, + end: { + line: 654, + character: 654 + } + }; + + const mockDiagnostic0: Diagnostic[] = [ { severity: 1, - range: mockRange, + range: mockRange0, message: 'mock message', source: RHDA_DIAGNOSTIC_SOURCE, - }, + } + ]; + + const mockDiagnostic1: Diagnostic[] = [ { - severity: 2, - range: mockRange, + severity: 3, + range: mockRange1, message: 'another mock message', - source: RHDA_DIAGNOSTIC_SOURCE, + source: 'mockSource', } ]; - it('should return an empty array if no RHDA diagnostics are present', () => { + it('should register code actions in codeActionsMap for the same key', () => { + const key0 = 'mockKey0'; + const codeAction1 = { title: 'Mock Action1' }; + const codeAction2 = { title: 'Mock Action2' }; + codeActionHandler.registerCodeAction(key0, codeAction1); + codeActionHandler.registerCodeAction(key0, codeAction2); + + expect(codeActionHandler.getCodeActionsMap()[key0]).to.deep.equal([codeAction1, codeAction2]); + }); + + it('should register code actions in codeActionsMap for different keys', () => { + const key1 = 'mockKey1'; + const key2 = 'mockKey2'; + const codeAction1 = { title: 'Mock Action1' }; + const codeAction2 = { title: 'Mock Action2' }; + codeActionHandler.registerCodeAction(key1, codeAction1); + codeActionHandler.registerCodeAction(key2, codeAction2); + + const codeActionsMap = codeActionHandler.getCodeActionsMap(); + expect(codeActionsMap[key1]).to.deep.equal([codeAction1]); + expect(codeActionsMap[key2]).to.deep.equal([codeAction2]); + }); + + it('should clear codeActionsMap', () => { + expect(Object.keys(codeActionHandler.getCodeActionsMap()).length).greaterThan(0); + + codeActionHandler.clearCodeActionsMap(); + + expect(Object.keys(codeActionHandler.getCodeActionsMap()).length).to.equal(0); + }); + + it('should return an empty array if no RHDA diagnostics are present and full stack analysis action is provided', () => { + const diagnostics: Diagnostic[] = []; + let globalConfig = { + triggerFullStackAnalysis: 'mockTriggerFullStackAnalysis' + }; + sinon.stub(config, 'globalConfig').value(globalConfig); + + const codeActions = codeActionHandler.getDiagnosticsCodeActions(diagnostics); + + expect(codeActions).to.be.an('array').that.is.empty; + }); + + it('should return an empty array if no RHDA diagnostics are present and full stack analysis action is not provided', () => { const diagnostics: Diagnostic[] = []; + let globalConfig = { + triggerFullStackAnalysis: '' + }; + sinon.stub(config, 'globalConfig').value(globalConfig); + + const codeActions = codeActionHandler.getDiagnosticsCodeActions(diagnostics); + + expect(codeActions).to.be.an('array').that.is.empty; + }); + + it('should return an empty array if RHDA diagnostics are present but no matching code actions are found', () => { + const key = 'mockKey'; + const codeAction = { title: 'Mock Action' }; + codeActionHandler.registerCodeAction(key, codeAction); + + let globalConfig = { + triggerFullStackAnalysis: 'mockTriggerFullStackAnalysis' + }; + sinon.stub(config, 'globalConfig').value(globalConfig); - const codeActions = getDiagnosticsCodeActions(diagnostics); + const codeActions = codeActionHandler.getDiagnosticsCodeActions(mockDiagnostic1); expect(codeActions).to.be.an('array').that.is.empty; }); - it('should generate code actions for RHDA diagnostics when full stack analysis action is provided', async () => { + it('should generate code actions for RHDA diagnostics without full stack analysis action setting in globalConfig', async () => { + const key = `${mockDiagnostic0[0].range.start.line}|${mockDiagnostic0[0].range.start.character}`; + const codeAction = { title: 'Mock Action' }; + codeActionHandler.clearCodeActionsMap(); + codeActionHandler.registerCodeAction(key, codeAction); + + let globalConfig = { + triggerFullStackAnalysis: '' + }; + sinon.stub(config, 'globalConfig').value(globalConfig); + + const codeActions: CodeAction[] = codeActionHandler.getDiagnosticsCodeActions(mockDiagnostic0); + + expect(codeActions).to.deep.equal( + [ + { + title: 'Mock Action', + } + ] + ); + }); + + it('should generate code actions for RHDA diagnostics without RHDA Diagonostic source', async () => { + const key = `${mockDiagnostic1[0].range.start.line}|${mockDiagnostic1[0].range.start.character}`; + const codeAction = { title: 'Mock Action' }; + codeActionHandler.clearCodeActionsMap(); + codeActionHandler.registerCodeAction(key, codeAction); + + let globalConfig = { + triggerFullStackAnalysis: 'mockTriggerFullStackAnalysis' + }; + sinon.stub(config, 'globalConfig').value(globalConfig); + + const codeActions: CodeAction[] = codeActionHandler.getDiagnosticsCodeActions(mockDiagnostic1); + + expect(codeActions).to.deep.equal( + [ + { + title: 'Mock Action', + } + ] + ); + }); + + it('should generate code actions for RHDA diagnostics with full stack analysis action', async () => { + const key = `${mockDiagnostic0[0].range.start.line}|${mockDiagnostic0[0].range.start.character}`; + const codeAction = { title: 'Mock Action' }; + codeActionHandler.registerCodeAction(key, codeAction); + let globalConfig = { - triggerFullStackAnalysis: 'mockAction' + triggerFullStackAnalysis: 'mockTriggerFullStackAnalysis' }; sinon.stub(config, 'globalConfig').value(globalConfig); - const codeActions: CodeAction[] = getDiagnosticsCodeActions(mockDiagnostics); + const codeActions: CodeAction[] = codeActionHandler.getDiagnosticsCodeActions(mockDiagnostic0); expect(codeActions).to.deep.equal( [ + { + title: 'Mock Action', + }, { title: 'Detailed Vulnerability Report', kind: CodeActionKind.QuickFix, command: { title: 'Analytics Report', - command: 'mockAction' } + command: 'mockTriggerFullStackAnalysis' + } } ] ); }); - it('should return an empty array when no full stack analysis action is provided', async () => { + it('should return a switch to recommended version code action without RedHat repository recommendation', async () => { + + const codeAction: CodeAction = codeActionHandler.generateSwitchToRecommendedVersionAction( 'mockTitle', 'mockVersionReplacementString', mockDiagnostic0[0], 'mock/path/noPom.xml'); + expect(codeAction).to.deep.equal( + { + "diagnostics": [ + { + "message": "mock message", + "range": { + "end": { + "character": 456, + "line": 456 + }, + "start": { + "character": 123, + "line": 123 + } + }, + "severity": 1, + "source": RHDA_DIAGNOSTIC_SOURCE + } + ], + "edit": { + "changes": { + "mock/path/noPom.xml": [ + { + "newText": "mockVersionReplacementString", + "range": { + "end": { + "character": 456, + "line": 456 + }, + "start": { + "character": 123, + "line": 123 + } + } + } + ] + } + }, + "kind": "quickfix", + "title": "mockTitle" + } + ); + }); + + it('should return a switch to recommended version code action with RedHat repository recommendation', async () => { + let globalConfig = { - triggerFullStackAnalysis: '' + triggerRHRepositoryRecommendationNotification: 'mockTriggerRHRepositoryRecommendationNotification' }; sinon.stub(config, 'globalConfig').value(globalConfig); - const codeActions: CodeAction[] = getDiagnosticsCodeActions(mockDiagnostics); - - expect(codeActions).to.be.an('array').that.is.empty; + const codeAction: CodeAction = codeActionHandler.generateSwitchToRecommendedVersionAction( 'mockTitle', 'mockVersionReplacementString', mockDiagnostic1[0], 'mock/path/pom.xml'); + expect(codeAction).to.deep.equal( + { + "command": { + "command": 'mockTriggerRHRepositoryRecommendationNotification', + "title": "RedHat repository recommendation" + }, + "diagnostics": [ + { + "message": "another mock message", + "range": { + "end": { + "character": 654, + "line": 654 + }, + "start": { + "character": 321, + "line": 321 + } + }, + "severity": 3, + "source": 'mockSource' + } + ], + "edit": { + "changes": { + "mock/path/pom.xml": [ + { + "newText": "mockVersionReplacementString", + "range": { + "end": { + "character": 654, + "line": 654 + }, + "start": { + "character": 321, + "line": 321 + } + } + } + ] + } + }, + "kind": "quickfix", + "title": "mockTitle" + } + ); }); }); \ No newline at end of file diff --git a/test/config.test.ts b/test/config.test.ts index 12b3feb0..5f252936 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -38,8 +38,39 @@ describe('Config tests', () => { }, }; + const partialData = { + redHatDependencyAnalytics: { + exhortSnykToken: 'mockToken', + exhortOSSIndexUser: 'mockUser', + exhortOSSIndexToken: 'mockToken', + matchManifestVersions: true + }, + mvn: { + executable: { path: '' } + }, + npm: { + executable: { path: '' } + }, + go: { + executable: { path: '' } + }, + python3: { + executable: { path: '' } + }, + pip3: { + executable: { path: '' } + }, + python: { + executable: { path: '' } + }, + pip: { + executable: { path: '' } + }, + }; + it('should initialize with default values when environment variables are not set', () => { expect(mockConfig.triggerFullStackAnalysis).to.eq(''); + expect(mockConfig.triggerRHRepositoryRecommendationNotification).to.eq(''); expect(mockConfig.telemetryId).to.eq(''); expect(mockConfig.utmSource).to.eq(''); expect(mockConfig.exhortSnykToken).to.eq(''); @@ -71,4 +102,21 @@ describe('Config tests', () => { expect(mockConfig.exhortPythonPath).to.eq('mockPath'); expect(mockConfig.exhortPipPath).to.eq('mockPath'); }); + + it('should update configuration based on provided partial data', () => { + + mockConfig.updateConfig(partialData); + + expect(mockConfig.exhortSnykToken).to.eq('mockToken'); + expect(mockConfig.exhortOSSIndexUser).to.eq('mockUser'); + expect(mockConfig.exhortOSSIndexToken).to.eq('mockToken'); + expect(mockConfig.matchManifestVersions).to.eq('true'); + expect(mockConfig.exhortMvnPath).to.eq('mvn'); + expect(mockConfig.exhortNpmPath).to.eq('npm'); + expect(mockConfig.exhortGoPath).to.eq('go'); + expect(mockConfig.exhortPython3Path).to.eq('python3'); + expect(mockConfig.exhortPip3Path).to.eq('pip3'); + expect(mockConfig.exhortPythonPath).to.eq('python'); + expect(mockConfig.exhortPipPath).to.eq('pip'); + }); }); \ No newline at end of file diff --git a/test/vulnerability.test.ts b/test/vulnerability.test.ts index 5b01e159..a7ef0614 100644 --- a/test/vulnerability.test.ts +++ b/test/vulnerability.test.ts @@ -9,6 +9,7 @@ import { Vulnerability } from '../src/vulnerability'; describe('Vulnerability tests', () => { const mavenDependencyProvider: DependencyProvider = new DependencyProvider(); const mockMavenRef: string = 'pkg:maven/mockGroupId1/mockArtifact1@mockVersion'; + const mockRecommendationRef: string = 'pkg:maven/mockGroupId1/mockArtifact1@mockRecommendationVersion'; const mockRange: Range = { start: { line: 123, @@ -24,13 +25,15 @@ describe('Vulnerability tests', () => { constructor( public sourceId: string, public issuesCount: number, + public recommendationRef: string, + public remediationRef: string, public highestVulnerabilitySeverity: string ) {} } - it('should return diagnostic without vulnerabilities from single source', async () => { + it('should return diagnostic with vulnerabilities for single source', async () => { const mockDependencyData: MockDependencyData[] = [ - new MockDependencyData('snyk-snyk', 0, 'NONE') + new MockDependencyData('snyk(snyk)', 2, '', 'mockRemediationRef', 'HIGH') ]; let vulnerability = new Vulnerability( mavenDependencyProvider, @@ -43,15 +46,16 @@ describe('Vulnerability tests', () => { expect(diagnostic.message.replace(/\s/g, "")).to.eql(` mockGroupId1/mockArtifact1@mockVersion - snyk-snyk vulnerability info: - Known security vulnerabilities: 0 - Highest severity: NONE + snyk(snyk) vulnerability info: + Known security vulnerabilities: 2 + Highest severity: HIGH `.replace(/\s/g, "")); }); - it('should return diagnostic with vulnerabilities from single source', async () => { + it('should return diagnostic with vulnerabilities for multi source', async () => { const mockDependencyData: MockDependencyData[] = [ - new MockDependencyData('snyk-snyk', 2, 'HIGH') + new MockDependencyData('snyk(snyk)', 2, '', 'mockRemediationRef', 'HIGH'), + new MockDependencyData('oss(oss)', 3, '', 'mockRemediationRef', 'LOW') ]; let vulnerability = new Vulnerability( mavenDependencyProvider, @@ -64,18 +68,19 @@ describe('Vulnerability tests', () => { expect(diagnostic.message.replace(/\s/g, "")).to.eql(` mockGroupId1/mockArtifact1@mockVersion - snyk-snyk vulnerability info: + snyk(snyk) vulnerability info: Known security vulnerabilities: 2 Highest severity: HIGH + + oss(oss) vulnerability info: + Known security vulnerabilities: 3 + Highest severity: LOW `.replace(/\s/g, "")); }); - it('should return diagnostic from multiple source', async () => { + it('should return diagnostic without vulnerabilities and with recommendation for single source', async () => { const mockDependencyData: MockDependencyData[] = [ - new MockDependencyData('snyk-snyk', 1, 'HIGH'), - new MockDependencyData('snyk-oss', 2, 'LOW'), - new MockDependencyData('oss-oss', 0, 'NONE'), - new MockDependencyData('oss-snyk', 5, 'HIGH') + new MockDependencyData('snyk(snyk)', 0, mockRecommendationRef, '', 'NONE') ]; let vulnerability = new Vulnerability( mavenDependencyProvider, @@ -88,20 +93,105 @@ describe('Vulnerability tests', () => { expect(diagnostic.message.replace(/\s/g, "")).to.eql(` mockGroupId1/mockArtifact1@mockVersion - snyk-snyk vulnerability info: - Known security vulnerabilities: 1 - Highest severity: HIGH + snyk(snyk) vulnerability info: + Known security vulnerabilities: 0 + Recommendation: mockGroupId1/mockArtifact1@mockRecommendationVersion + `.replace(/\s/g, "")); + }); - snyk-oss vulnerability info: - Known security vulnerabilities: 2 - Highest severity: LOW + it('should return diagnostic without vulnerabilities and with recommendation for multi source', async () => { + const mockDependencyData: MockDependencyData[] = [ + new MockDependencyData('snyk(snyk)', 0, mockRecommendationRef, '', 'NONE'), + new MockDependencyData('oss(oss)', 0, mockRecommendationRef, '', 'NONE') + ]; + let vulnerability = new Vulnerability( + mavenDependencyProvider, + mockRange, + mockMavenRef, + mockDependencyData + ); + + const diagnostic = vulnerability.getDiagnostic(); + expect(diagnostic.message.replace(/\s/g, "")).to.eql(` + mockGroupId1/mockArtifact1@mockVersion + + snyk(snyk) vulnerability info: + Known security vulnerabilities: 0 + Recommendation: mockGroupId1/mockArtifact1@mockRecommendationVersion - oss-oss vulnerability info: + oss(oss) vulnerability info: Known security vulnerabilities: 0 - Highest severity: NONE + Recommendation: mockGroupId1/mockArtifact1@mockRecommendationVersion + `.replace(/\s/g, "")); + }); + + it('should return diagnostic without vulnerabilities and without recommendation for single source', async () => { + const mockDependencyData: MockDependencyData[] = [ + new MockDependencyData('snyk(snyk)', 0, '', '', 'NONE') + ]; + let vulnerability = new Vulnerability( + mavenDependencyProvider, + mockRange, + mockMavenRef, + mockDependencyData + ); + + const diagnostic = vulnerability.getDiagnostic(); + expect(diagnostic.message.replace(/\s/g, "")).to.eql(` + mockGroupId1/mockArtifact1@mockVersion - oss-snyk vulnerability info: - Known security vulnerabilities: 5 + snyk(snyk) vulnerability info: + Known security vulnerabilities: 0 + Recommendation: No RedHat packages to recommend + `.replace(/\s/g, "")); + }); + + it('should return diagnostic without vulnerabilities for multi source where one where some sources do not have recommendations and others do', async () => { + const mockDependencyData: MockDependencyData[] = [ + new MockDependencyData('snyk(snyk)', 0, '', '', 'NONE'), + new MockDependencyData('oss(oss)', 0, mockRecommendationRef, '', 'NONE') + + ]; + let vulnerability = new Vulnerability( + mavenDependencyProvider, + mockRange, + mockMavenRef, + mockDependencyData + ); + + const diagnostic = vulnerability.getDiagnostic(); + expect(diagnostic.message.replace(/\s/g, "")).to.eql(` + mockGroupId1/mockArtifact1@mockVersion + + snyk(snyk) vulnerability info: + Known security vulnerabilities: 0 + Recommendation: No RedHat packages to recommend + + oss(oss) vulnerability info: + Known security vulnerabilities: 0 + Recommendation: mockGroupId1/mockArtifact1@mockRecommendationVersion + `.replace(/\s/g, "")); + }); + + it('should return diagnostic where some sources have vulnerabilities and others dont', async () => { + const mockDependencyData: MockDependencyData[] = [ + new MockDependencyData('snyk(snyk)', 2, '', 'mockRemediationRef', 'HIGH'), + new MockDependencyData('oss(oss)', 0, mockRecommendationRef, '', 'NONE') + + ]; + let vulnerability = new Vulnerability( + mavenDependencyProvider, + mockRange, + mockMavenRef, + mockDependencyData + ); + + const diagnostic = vulnerability.getDiagnostic(); + expect(diagnostic.message.replace(/\s/g, "")).to.eql(` + mockGroupId1/mockArtifact1@mockVersion + + snyk(snyk) vulnerability info: + Known security vulnerabilities: 2 Highest severity: HIGH `.replace(/\s/g, "")); });