From 76b2a70f41ea179da1ed891df7ddec398147570e Mon Sep 17 00:00:00 2001 From: Ilona Shishov Date: Sun, 17 Dec 2023 11:23:02 +0200 Subject: [PATCH 1/2] feat: added remediation and recommendation quickfix feature Signed-off-by: Ilona Shishov --- package-lock.json | 8 ++--- package.json | 2 +- src/codeActionHandler.ts | 74 +++++++++++++++++++++++++++++++++++++-- src/componentAnalysis.ts | 68 ++++++++++++++++++++++++++++++----- src/config.ts | 30 ++++++++-------- src/diagnosticsHandler.ts | 49 ++++++++++++++++++++------ src/server.ts | 2 +- src/vulnerability.ts | 48 +++++++++++++++++++------ 8 files changed, 228 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index af8d39d6..b640f8a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.8.1-ea.2", "license": "Apache-2.0", "dependencies": { - "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.3", + "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.4", "@xml-tools/ast": "^5.0.5", "@xml-tools/parser": "^1.0.11", "json-to-ast": "^2.1.0", @@ -837,9 +837,9 @@ } }, "node_modules/@RHEcosystemAppEng/exhort-javascript-api": { - "version": "0.1.1-ea.3", - "resolved": "https://npm.pkg.github.com/download/@RHEcosystemAppEng/exhort-javascript-api/0.1.1-ea.3/64de57546f9958f378c8ad22ec18cedaec95b4ed", - "integrity": "sha512-ydfi0HL3uFlejGeyJbCalJQAz0nPO4NKjBEaj/awb5qN/HIONzB6KnjGQI2rvws4Iwhi+LvRptfRgH3aZtwejw==", + "version": "0.1.1-ea.4", + "resolved": "https://npm.pkg.github.com/download/@RHEcosystemAppEng/exhort-javascript-api/0.1.1-ea.4/cad7237062cf4467fcf76ee9151dbfb50037e233", + "integrity": "sha512-qKmcb2rfP26iaSwdi5LKAj2l2kiyBRfvf/gD1gYwXoQBSNfrz3rMCQJb2HZoRmEQfn0mE/mqMe0ZEfgdDQBHCw==", "license": "Apache-2.0", "dependencies": { "@babel/core": "^7.23.2", diff --git a/package.json b/package.json index f1ff9a4e..44aeba98 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dist" ], "dependencies": { - "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.3", + "@RHEcosystemAppEng/exhort-javascript-api": "^0.1.1-ea.4", "@xml-tools/ast": "^5.0.5", "@xml-tools/parser": "^1.0.11", "json-to-ast": "^2.1.0", diff --git a/src/codeActionHandler.ts b/src/codeActionHandler.ts index 4c7c91b5..43e8859c 100644 --- a/src/codeActionHandler.ts +++ b/src/codeActionHandler.ts @@ -4,19 +4,61 @@ * ------------------------------------------------------------------------------------------ */ 'use strict'; +import * as path from 'path'; import { CodeAction, CodeActionKind, Diagnostic } from 'vscode-languageserver/node'; + import { globalConfig } from './config'; import { RHDA_DIAGNOSTIC_SOURCE } from './constants'; +let codeActionsMap: Map = new Map(); + +/** + * Clears the code actions map. + */ +function clearCodeActionsMap() { + codeActionsMap = new Map(); +} + +/** + * Registers a code action. + * @param key - The key to register the code action against. + * @param codeAction - The code action to be registered. + */ +function registerCodeAction(key: string, codeAction: CodeAction) { + codeActionsMap[key] = codeActionsMap[key] || []; + codeActionsMap[key].push(codeAction); +} + /** * Retrieves code actions based on diagnostics and file type. * @param diagnostics - An array of available diagnostics. + * @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[]): CodeAction[] { - const hasRhdaDiagonostic = diagnostics.some(diagnostic => diagnostic.source === RHDA_DIAGNOSTIC_SOURCE); +function getDiagnosticsCodeActions(diagnostics: Diagnostic[], uri: string): 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); + + } + + hasRhdaDiagonostic ||= diagnostic.source === RHDA_DIAGNOSTIC_SOURCE; + } + if (globalConfig.triggerFullStackAnalysis && hasRhdaDiagonostic) { codeActions.push(generateFullStackAnalysisAction()); } @@ -39,4 +81,30 @@ function generateFullStackAnalysisAction(): CodeAction { }; } -export { getDiagnosticsCodeActions }; \ No newline at end of file +/** + * Generates a code action to switch to the recommended version. + * @param title - The title of the code action. + * @param versionReplacementString - The version replacement string. + * @param diagnostic - The diagnostic information. + * @param uri - The URI of the file. + * @returns A CodeAction object for switching to the recommended version. + */ +function generateSwitchToRecommendedVersionAction(title: string, versionReplacementString: string, diagnostic: Diagnostic, uri: string): CodeAction { + const codeAction: CodeAction = { + title: title, + diagnostics: [diagnostic], + kind: CodeActionKind.QuickFix, + edit: { + changes: { + } + } + }; + + codeAction.edit.changes[uri] = [{ + range: diagnostic.range, + newText: versionReplacementString + }]; + return codeAction; +} + +export { clearCodeActionsMap, registerCodeAction , generateSwitchToRecommendedVersionAction, getDiagnosticsCodeActions }; \ No newline at end of file diff --git a/src/componentAnalysis.ts b/src/componentAnalysis.ts index 4af609a3..5f2465c0 100644 --- a/src/componentAnalysis.ts +++ b/src/componentAnalysis.ts @@ -35,6 +35,8 @@ class Source implements ISource { interface IDependencyData { sourceId: string; issuesCount: number; + recommendationRef: string; + remediationRef: string highestVulnerabilitySeverity: string; } @@ -45,6 +47,8 @@ class DependencyData implements IDependencyData { constructor( public sourceId: string, public issuesCount: number, + public recommendationRef: string, + public remediationRef: string, public highestVulnerabilitySeverity: string ) {} } @@ -69,10 +73,12 @@ class AnalysisResponse implements IAnalysisResponse { if (isDefined(resData, 'providers')) { Object.entries(resData.providers).map(([providerName, providerData]) => { - if (isDefined(providerData, 'status', 'ok') && isDefined(providerData, 'sources') && providerData.status.ok) { - Object.entries(providerData.sources).map(([sourceName, sourceData]) => { - sources.push(new Source(`${providerName}(${sourceName})`, isDefined(sourceData, 'dependencies') ? sourceData.dependencies : [])); - }); + if (isDefined(providerData, 'status', 'ok') && providerData.status.ok) { + if (isDefined(providerData, 'sources')) { + Object.entries(providerData.sources).map(([sourceName, sourceData]) => { + sources.push(new Source(`${providerName}(${sourceName})`, this.getDependencies(sourceData))); + }); + } } else { failedProviders.push(providerName); } @@ -89,17 +95,61 @@ class AnalysisResponse implements IAnalysisResponse { sources.forEach(source => { source.dependencies.forEach(d => { - if (isDefined(d, 'ref') && isDefined(d, 'issues')) { - const dd = new DependencyData(source.id, d.issues.length, isDefined(d, 'highestVulnerability', 'severity') ? d.highestVulnerability.severity : 'NONE'); - if (this.dependencies[d.ref] === undefined) { - this.dependencies[d.ref] = []; - } + if (isDefined(d, 'ref')) { + + const issuesCount: number = isDefined(d, 'issues') ? d.issues.length : 0; + + const dd = issuesCount + ? new DependencyData(source.id, issuesCount, '', this.getRemediation(d.issues[0]), this.getHighestSeverity(d)) + : new DependencyData(source.id, issuesCount, this.getRecommendation(d), '', this.getHighestSeverity(d)); + + this.dependencies[d.ref] = this.dependencies[d.ref] || []; this.dependencies[d.ref].push(dd); } }); }); } } + + /** + * Retrieves dependencies from source. + * @param sourceData The source object. + * @returns An array of dependencies or empty array if none exists. + * @private + */ + private getDependencies(sourceData: any): any[] { + return isDefined(sourceData, 'dependencies') ? sourceData.dependencies : []; + } + + /** + * Retrieves the highest vulnerability severity value from a dependency. + * @param dependency The dependency object. + * @returns The highest severity level or NONE if none exists. + * @private + */ + private getHighestSeverity(dependency: any): string { + return isDefined(dependency, 'highestVulnerability', 'severity') ? dependency.highestVulnerability.severity : 'NONE'; + } + + /** + * Retrieves the remediation reference from an issue. + * @param issue The issue object. + * @returns The remediation reference or empty string if none exists. + * @private + */ + private getRemediation(issue: any): string { + return isDefined(issue, 'remediation', 'trustedContent', 'package') ? issue.remediation.trustedContent.package : ''; + } + + /** + * Retrieves the recommendation reference from a dependency. + * @param dependency The dependency object. + * @returns The recommendation reference or empty string if none exists. + * @private + */ + private getRecommendation(dependency: any): string { + return isDefined(dependency, 'recommendation') ? dependency.recommendation.split('?')[0] : ''; + } } /** diff --git a/src/config.ts b/src/config.ts index f6493e7b..be72f282 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,26 +10,28 @@ */ class Config { - triggerFullStackAnalysis: string; - telemetryId: string; - utmSource: string; - exhortSnykToken: string; - exhortOSSIndexUser: string; - exhortOSSIndexToken: string; - matchManifestVersions: string; - exhortMvnPath: string; - exhortNpmPath: string; - exhortGoPath: string; - exhortPython3Path: string; - exhortPip3Path: string; - exhortPythonPath: string; - exhortPipPath: string; + triggerFullStackAnalysis: string; + triggerRHRepositoryRecommendationNotification: string; + telemetryId: string; + utmSource: string; + exhortSnykToken: string; + exhortOSSIndexUser: string; + exhortOSSIndexToken: string; + matchManifestVersions: string; + exhortMvnPath: string; + exhortNpmPath: string; + exhortGoPath: string; + exhortPython3Path: string; + exhortPip3Path: string; + exhortPythonPath: string; + exhortPipPath: string; /** * Initializes a new instance of the Config class with default values from the parent process environment variable data. */ constructor() { this.triggerFullStackAnalysis = process.env.VSCEXT_TRIGGER_FULL_STACK_ANALYSIS || ''; + this.triggerRHRepositoryRecommendationNotification = process.env.VSCEXT_TRIGGER_REDHAT_REPOSITORY_RECOMMENDATION_NOTIFICATION || ''; this.telemetryId = process.env.VSCEXT_TELEMETRY_ID || ''; this.utmSource = process.env.VSCEXT_UTM_SOURCE || ''; this.exhortSnykToken = process.env.VSCEXT_EXHORT_SNYK_TOKEN || ''; diff --git a/src/diagnosticsHandler.ts b/src/diagnosticsHandler.ts index 5df74c3a..72035a71 100644 --- a/src/diagnosticsHandler.ts +++ b/src/diagnosticsHandler.ts @@ -5,10 +5,14 @@ 'use strict'; import { Diagnostic } from 'vscode-languageserver'; + import { DependencyMap, IDependencyProvider, getRange } from './collector'; import { executeComponentAnalysis, DependencyData } from './componentAnalysis'; import { Vulnerability } from './vulnerability'; import { connection } from './server'; +import { VERSION_PLACEHOLDER } from './constants'; +import { clearCodeActionsMap, registerCodeAction, generateSwitchToRecommendedVersionAction } from './codeActionHandler'; +import { IPositionedContext } from './collector' /** * Diagnostics Pipeline specification. @@ -61,8 +65,10 @@ class DiagnosticsPipeline implements IDiagnosticsPipeline { runDiagnostics(dependencies: Map) { Object.entries(dependencies).map(([ref, dependencyData]) => { - const dependency = this.dependencyMap.get(this.provider.resolveDependencyFromReference(ref).split('@')[0]); - if (dependency !== undefined) { + const dependencyRef = this.provider.resolveDependencyFromReference(ref).split('@')[0]; + const dependency = this.dependencyMap.get(dependencyRef); + + if (dependency) { const vulnerability = new Vulnerability( this.provider, getRange(dependency), @@ -73,20 +79,41 @@ class DiagnosticsPipeline implements IDiagnosticsPipeline { const vulnerabilityDiagnostic = vulnerability.getDiagnostic(); this.diagnostics.push(vulnerabilityDiagnostic); - dependencyData.forEach((dd) => { - const vulnProvider = dd.sourceId.split('(')[0]; - const issuesCount = dd.issuesCount; + const loc = vulnerabilityDiagnostic.range.start.line + '|' + vulnerabilityDiagnostic.range.start.character; - if (vulnProvider in this.vulnCount) { - this.vulnCount[vulnProvider] += issuesCount; - } else { - this.vulnCount[vulnProvider] = issuesCount; + dependencyData.forEach(dd => { + + const actionRef = vulnerabilityDiagnostic.severity === 1 ? dd.remediationRef : dd.recommendationRef; + + if (actionRef) { + this.createCodeAction(loc, actionRef, dependency.context, dd.sourceId, vulnerabilityDiagnostic); } - }); + + const vulnProvider = dd.sourceId.split('(')[0]; + const issuesCount = dd.issuesCount; + this.vulnCount[vulnProvider] = (this.vulnCount[vulnProvider] || 0) + issuesCount; + }); } connection.sendDiagnostics({ uri: this.diagnosticFilePath, diagnostics: this.diagnostics }); }); } + + /** + * Creates a code action. + * @param loc - Location of code action effect. + * @param ref - The reference name of the recommended package. + * @param context - Dependency context object. + * @param sourceId - Source ID. + * @param vulnerabilityDiagnostic - Vulnerability diagnostic object. + * @private + */ + private createCodeAction(loc: string, ref: string, context: IPositionedContext, sourceId: string, vulnerabilityDiagnostic: Diagnostic) { + 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); + registerCodeAction(loc, codeAction); + } } /** @@ -113,6 +140,8 @@ async function performDiagnostics(diagnosticFilePath: string, contents: string, const diagnosticsPipeline = new DiagnosticsPipeline(provider, dependencyMap, diagnosticFilePath); + clearCodeActionsMap(); + diagnosticsPipeline.clearDiagnostics(); const analysis = executeComponentAnalysis(diagnosticFilePath, contents) diff --git a/src/server.ts b/src/server.ts index dd8fb142..e5d36ac7 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); + return getDiagnosticsCodeActions(params.context.diagnostics, params.textDocument.uri); }); connection.listen(); diff --git a/src/vulnerability.ts b/src/vulnerability.ts index 77a84450..64127001 100644 --- a/src/vulnerability.ts +++ b/src/vulnerability.ts @@ -28,28 +28,54 @@ class Vulnerability { private dependencyData: DependencyData[], ) {} + /** + * Generate vulnerability information message + * @param dependencyData Properties of the dependency object + * @returns vulnerability information message string + */ + private generateVulnerabilityInfo(dependencyData: DependencyData): string { + return `${dependencyData.sourceId} vulnerability info: +Known security vulnerabilities: ${dependencyData.issuesCount} +Highest severity: ${dependencyData.highestVulnerabilitySeverity}`; + } + + /** + * Generate recommendation message from source + * @param dependencyData Properties of the dependency object + * @returns source recommendation message string + */ + private generateRecommendation(dependencyData: DependencyData): string { + return `${dependencyData.sourceId} vulnerability info: +Known security vulnerabilities: ${dependencyData.issuesCount} +Recommendation: ${this.provider.resolveDependencyFromReference(dependencyData.recommendationRef) || 'No RedHat packages to recommend'}`; + } + + /** * Creates a diagnostic object based on vulnerability data. * @returns A Diagnostic object representing the vulnerability. */ getDiagnostic(): Diagnostic { + + const hasIssues = this.dependencyData.some(data => data.issuesCount > 0); - const diagSeverity = DiagnosticSeverity.Error; - - let msg: string = `${this.provider.resolveDependencyFromReference(this.ref)}`; + const diagnosticSeverity = hasIssues ? DiagnosticSeverity.Error : DiagnosticSeverity.Information; - this.dependencyData.forEach(dd => { - msg += ` - -${dd.sourceId} vulnerability info: -Known security vulnerabilities: ${dd.issuesCount} -Highest severity: ${dd.highestVulnerabilitySeverity}`; + const messages = this.dependencyData.map(dd => { + if (hasIssues && dd.issuesCount > 0) { + return this.generateVulnerabilityInfo(dd); + } else if (!hasIssues) { + return this.generateRecommendation(dd); + } + return ''; }); + + const message = `${this.provider.resolveDependencyFromReference(this.ref)}\n\n${messages.join('\n\n')}`; return { - severity: diagSeverity, + severity: diagnosticSeverity, range: this.range, - message: msg, + message: message, source: RHDA_DIAGNOSTIC_SOURCE, }; } From ea8a8e197415a2b20e354725ad7571f7ef496c95 Mon Sep 17 00:00:00 2001 From: Ilona Shishov Date: Sun, 17 Dec 2023 17:48:29 +0200 Subject: [PATCH 2/2] test: update unit tests Signed-off-by: Ilona Shishov --- src/codeActionHandler.ts | 36 +++-- src/componentAnalysis.ts | 2 +- src/diagnosticsHandler.ts | 4 +- src/server.ts | 2 +- test/codeActionHandler.test.ts | 258 ++++++++++++++++++++++++++++++--- test/config.test.ts | 48 ++++++ test/vulnerability.test.ts | 136 ++++++++++++++--- 7 files changed, 424 insertions(+), 62 deletions(-) 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, "")); });