From 12fd4ef9e8ad67491c04b484098ade76b9756224 Mon Sep 17 00:00:00 2001 From: Teodora Sandu <81559517+teodora-sandu@users.noreply.github.com> Date: Wed, 3 Apr 2024 11:15:52 +0100 Subject: [PATCH] feat: render html from snyk-ls [IDE-214] (#496) * feat: render html from snyk-ls * test: add tests * refactor: move code to the right panel * refactor: remove unused code * test: try to fix tests --- CHANGELOG.md | 5 + src/main/kotlin/io/snyk/plugin/ui/UIUtils.kt | 14 ++ .../panels/SuggestionDescriptionPanel.kt | 5 +- .../SuggestionDescriptionPanelFromLS.kt | 32 ++++- .../panels/VulnerabilityDescriptionPanel.kt | 5 +- src/main/kotlin/snyk/common/lsp/Types.kt | 5 +- .../ui/BaseImageRemediationDetailPanel.kt | 2 +- .../container/ui/ContainerIssueDetailPanel.kt | 5 +- .../SuggestionDescriptionPanelFromLSTest.kt | 133 ++++++++++++++++++ src/test/kotlin/snyk/UIComponentFinder.kt | 17 +++ 10 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/io/snyk/plugin/ui/toolwindow/SuggestionDescriptionPanelFromLSTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5d76cff..992f0617b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Snyk Security Changelog +## [2.7.14] + +### Added +- Render Snyk Code vulnerabilities using HTML served by the Language Server behind a feature flag. + ## [2.7.13] ### Added - (LS Preview) added timeout to commands executed via code lenses diff --git a/src/main/kotlin/io/snyk/plugin/ui/UIUtils.kt b/src/main/kotlin/io/snyk/plugin/ui/UIUtils.kt index 655716b01..330d35ea9 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/UIUtils.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/UIUtils.kt @@ -6,6 +6,9 @@ import com.intellij.ui.BrowserHyperlinkListener import com.intellij.ui.ColorUtil import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.components.ActionLink +import com.intellij.ui.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBuilder import com.intellij.uiDesigner.core.GridConstraints import com.intellij.uiDesigner.core.GridLayoutManager import com.intellij.uiDesigner.core.Spacer @@ -373,3 +376,14 @@ fun expandTreeNodeRecursively(tree: JTree, node: DefaultMutableTreeNode) { expandTreeNodeRecursively(tree, it as DefaultMutableTreeNode) } } + +//JComponent, String +fun getJBCefBrowserIfSupported () : Pair { + if (!JBCefApp.isSupported()) { + return null to "" + } + val cefClient = JBCefApp.getInstance().createClient() + val jbCefBrowser = JBCefBrowserBuilder().setClient(cefClient).setEnableOpenDevToolsMenuItem(true).build() + + return jbCefBrowser to jbCefBrowser.cefBrowser.url +} diff --git a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanel.kt b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanel.kt index e6d83aa27..7c9297159 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanel.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanel.kt @@ -37,7 +37,10 @@ class SuggestionDescriptionPanel( private val snykCodeFile: SnykCodeFile, private val suggestion: SuggestionForFile, private val suggestionIndex: Int -) : IssueDescriptionPanelBase(title = suggestion.title, severity = suggestion.getSeverityAsEnum()) { +) : IssueDescriptionPanelBase( + title = suggestion.title, + severity = suggestion.getSeverityAsEnum() +) { val project = snykCodeFile.project private val suggestionRange: MyTextRange? = suggestion.ranges?.getOrNull(suggestionIndex) diff --git a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanelFromLS.kt b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanelFromLS.kt index 9e68b37e1..65061eede 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanelFromLS.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/SuggestionDescriptionPanelFromLS.kt @@ -10,14 +10,20 @@ import com.intellij.util.ui.JBInsets import com.intellij.util.ui.UIUtil import io.snyk.plugin.getDocument import io.snyk.plugin.navigateToSource +import io.snyk.plugin.pluginSettings import io.snyk.plugin.snykcode.core.SnykCodeFile import io.snyk.plugin.toVirtualFile import io.snyk.plugin.ui.DescriptionHeaderPanel +import io.snyk.plugin.ui.SnykBalloonNotificationHelper import io.snyk.plugin.ui.baseGridConstraintsAnchorWest import io.snyk.plugin.ui.descriptionHeaderPanel +import io.snyk.plugin.ui.getJBCefBrowserIfSupported import io.snyk.plugin.ui.panelGridConstraints +import io.snyk.plugin.ui.toolwindow.SnykToolWindowPanel +import io.snyk.plugin.ui.wrapWithScrollPane import snyk.common.lsp.DataFlow import snyk.common.lsp.ScanIssue +import java.awt.BorderLayout import java.awt.Color import java.awt.Dimension import java.awt.Font @@ -33,11 +39,33 @@ import kotlin.math.max class SuggestionDescriptionPanelFromLS( snykCodeFile: SnykCodeFile, private val issue: ScanIssue -) : IssueDescriptionPanelBase(title = getIssueTitle(issue), severity = issue.getSeverityAsEnum()) { +) : IssueDescriptionPanelBase( + title = getIssueTitle(issue), + severity = issue.getSeverityAsEnum() +) { val project = snykCodeFile.project + private val unexpectedErrorMessage = "Snyk encountered an issue while rendering the vulnerability description. Please try again, or contact support if the problem persists. We apologize for any inconvenience caused."; init { - createUI() + if (pluginSettings().isGlobalIgnoresFeatureEnabled && issue.additionalData.details != null) { + val (jbCefBrowser, jbCefBrowserUrl) = getJBCefBrowserIfSupported() + if (jbCefBrowser == null) { + val statePanel = StatePanel(SnykToolWindowPanel.SELECT_ISSUE_TEXT) + this.add(wrapWithScrollPane(statePanel), BorderLayout.CENTER) + SnykBalloonNotificationHelper.showError(unexpectedErrorMessage, null) + } else { + val panel = JPanel() + panel.add(jbCefBrowser.component, BorderLayout()) + this.add( + wrapWithScrollPane(panel), + BorderLayout.CENTER + ) + + jbCefBrowser.loadHTML(issue.additionalData.details, jbCefBrowserUrl) + } + } else { + createUI() + } } override fun secondRowTitlePanel(): DescriptionHeaderPanel = descriptionHeaderPanel( diff --git a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/VulnerabilityDescriptionPanel.kt b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/VulnerabilityDescriptionPanel.kt index 9ec35659b..26254bbb1 100644 --- a/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/VulnerabilityDescriptionPanel.kt +++ b/src/main/kotlin/io/snyk/plugin/ui/toolwindow/panels/VulnerabilityDescriptionPanel.kt @@ -20,7 +20,10 @@ import javax.swing.JPanel class VulnerabilityDescriptionPanel( private val groupedVulns: Collection -) : IssueDescriptionPanelBase(title = groupedVulns.first().title, severity = groupedVulns.first().getSeverity()) { +) : IssueDescriptionPanelBase( + title = groupedVulns.first().title, + severity = groupedVulns.first().getSeverity() +) { private val labelProvider: LabelProvider = LabelProvider() private val vulnerability = groupedVulns.first() diff --git a/src/main/kotlin/snyk/common/lsp/Types.kt b/src/main/kotlin/snyk/common/lsp/Types.kt index 64563370e..5fa2ae934 100644 --- a/src/main/kotlin/snyk/common/lsp/Types.kt +++ b/src/main/kotlin/snyk/common/lsp/Types.kt @@ -201,8 +201,7 @@ data class IssueData( @SerializedName("priorityScore") val priorityScore: Int, @SerializedName("hasAIFix") val hasAIFix: Boolean, @SerializedName("dataFlow") val dataFlow: List, - @SerializedName("isIgnored") val isIgnored: Boolean?, - @SerializedName("ignoreDetails") val ignoreDetails: IgnoreDetails?, + @SerializedName("details") val details: String?, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -231,6 +230,7 @@ data class IssueData( if (priorityScore != other.priorityScore) return false if (hasAIFix != other.hasAIFix) return false if (dataFlow != other.dataFlow) return false + if (details != other.details) return false return true } @@ -251,6 +251,7 @@ data class IssueData( result = 31 * result + priorityScore result = 31 * result + hasAIFix.hashCode() result = 31 * result + dataFlow.hashCode() + result = 31 * result + details.hashCode() return result } } diff --git a/src/main/kotlin/snyk/container/ui/BaseImageRemediationDetailPanel.kt b/src/main/kotlin/snyk/container/ui/BaseImageRemediationDetailPanel.kt index 31e9c7ab5..7a2a61f66 100644 --- a/src/main/kotlin/snyk/container/ui/BaseImageRemediationDetailPanel.kt +++ b/src/main/kotlin/snyk/container/ui/BaseImageRemediationDetailPanel.kt @@ -27,7 +27,7 @@ import javax.swing.JTextArea class BaseImageRemediationDetailPanel( private val project: Project, - private val imageIssues: ContainerIssuesForImage + private val imageIssues: ContainerIssuesForImage, ) : IssueDescriptionPanelBase( title = imageIssues.imageName, severity = imageIssues.getSeverities().maxOrNull() ?: Severity.UNKNOWN diff --git a/src/main/kotlin/snyk/container/ui/ContainerIssueDetailPanel.kt b/src/main/kotlin/snyk/container/ui/ContainerIssueDetailPanel.kt index aa5ca7e0b..e080340ba 100644 --- a/src/main/kotlin/snyk/container/ui/ContainerIssueDetailPanel.kt +++ b/src/main/kotlin/snyk/container/ui/ContainerIssueDetailPanel.kt @@ -18,7 +18,10 @@ import javax.swing.JPanel class ContainerIssueDetailPanel( private val groupedVulns: Collection -) : IssueDescriptionPanelBase(title = groupedVulns.first().title, severity = groupedVulns.first().getSeverity()) { +) : IssueDescriptionPanelBase( + title = groupedVulns.first().title, + severity = groupedVulns.first().getSeverity() +) { private val issue = groupedVulns.first() diff --git a/src/test/kotlin/io/snyk/plugin/ui/toolwindow/SuggestionDescriptionPanelFromLSTest.kt b/src/test/kotlin/io/snyk/plugin/ui/toolwindow/SuggestionDescriptionPanelFromLSTest.kt new file mode 100644 index 000000000..13bd831de --- /dev/null +++ b/src/test/kotlin/io/snyk/plugin/ui/toolwindow/SuggestionDescriptionPanelFromLSTest.kt @@ -0,0 +1,133 @@ +@file:Suppress("FunctionName") +package io.snyk.plugin.ui.toolwindow + +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiFile +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.jcef.JBCefBrowser +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.snyk.plugin.Severity +import io.snyk.plugin.pluginSettings +import io.snyk.plugin.resetSettings +import io.snyk.plugin.snykcode.core.SnykCodeFile +import io.snyk.plugin.ui.getJBCefBrowserIfSupported +import io.snyk.plugin.ui.toolwindow.panels.SuggestionDescriptionPanelFromLS +import org.cef.browser.CefBrowserFactory +import org.cef.browser.CefRequestContext +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.Range +import org.junit.Before +import org.junit.Test +import snyk.UIComponentFinder.getJBCEFBrowser +import snyk.UIComponentFinder.getJLabelByText +import snyk.code.annotator.SnykCodeAnnotator +import snyk.common.lsp.CommitChangeLine +import snyk.common.lsp.DataFlow +import snyk.common.lsp.ExampleCommitFix +import snyk.common.lsp.ScanIssue +import java.nio.file.Paths +import javax.swing.JPanel + +class SuggestionDescriptionPanelFromLSTest : BasePlatformTestCase() { + private lateinit var cut: SuggestionDescriptionPanelFromLS + private val fileName = "app.js" + private lateinit var snykCodeFile: SnykCodeFile + private lateinit var issue: ScanIssue + + private lateinit var file: VirtualFile + private lateinit var psiFile: PsiFile + + override fun getTestDataPath(): String { + val resource = SnykCodeAnnotator::class.java.getResource("/test-fixtures/code/annotator") + requireNotNull(resource) { "Make sure that the resource $resource exists!" } + return Paths.get(resource.toURI()).toString() + } + + @Before + override fun setUp() { + super.setUp() + unmockkAll() + resetSettings(project) + + file = myFixture.copyFileToProject(fileName) + psiFile = WriteAction.computeAndWait { psiManager.findFile(file)!! } + snykCodeFile = SnykCodeFile(psiFile.project, psiFile.virtualFile) + + issue = mockk() + every { issue.additionalData.message } returns "Test message" + every { issue.additionalData.isSecurityType } returns true + every { issue.additionalData.cwe } returns null + every { issue.additionalData.repoDatasetSize } returns 1 + every { issue.additionalData.exampleCommitFixes } returns listOf(ExampleCommitFix("https://commit-url", listOf( + CommitChangeLine("1", 1, "lineChange") + ))) + every { issue.additionalData.dataFlow } returns listOf(DataFlow(0, getTestDataPath(), Range(Position(1, 1), Position(1, 1)), "")) + every { issue.title } returns "Test title" + every { issue.getSeverityAsEnum() } returns Severity.CRITICAL + } + + @Test + fun `test createUI should build panel with issue message as overview label if the feature flag is not enabled`() { + every { issue.additionalData.details } returns "HTML message" + every { issue.additionalData.details } returns null + cut = SuggestionDescriptionPanelFromLS(snykCodeFile, issue) + + val actual = getJLabelByText(cut, "Test message") + assertNotNull(actual) + + val actualBrowser = getJBCEFBrowser(cut) + assertNull(actualBrowser) + } + + @Test + fun `test createUI should build panel with issue message as overview label if the details are empty even if feature flag is enabled`() { + pluginSettings().isGlobalIgnoresFeatureEnabled = true + + every { issue.additionalData.details } returns null + cut = SuggestionDescriptionPanelFromLS(snykCodeFile, issue) + + val actual = getJLabelByText(cut, "Test message") + assertNotNull(actual) + + val actualBrowser = getJBCEFBrowser(cut) + assertNull(actualBrowser) + } + + @Test + fun `test createUI should show nothing if feature flag is enabled but JCEF is not`() { + pluginSettings().isGlobalIgnoresFeatureEnabled = true + + mockkStatic("io.snyk.plugin.ui.UIUtilsKt") + every { getJBCefBrowserIfSupported() } returns (null to "") + + every { issue.additionalData.details } returns "HTML message" + cut = SuggestionDescriptionPanelFromLS(snykCodeFile, issue) + + val actual = getJLabelByText(cut, "Test message") + assertNull(actual) + + val actualBrowser = getJBCEFBrowser(cut) + assertNull(actualBrowser) + } + + @Test + fun `test createUI should build panel with HTML from details if feature flag is enabled`() { + pluginSettings().isGlobalIgnoresFeatureEnabled = true + + val mockJBCefBrowser = mockk() + every { mockJBCefBrowser.component } returns JPanel() + every { mockJBCefBrowser.loadHTML(eq("HTML message"), eq("http://foo/bar")) } returns + mockkStatic("io.snyk.plugin.ui.UIUtilsKt") + every { getJBCefBrowserIfSupported() } returns (mockJBCefBrowser to "http://foo/bar") + + every { issue.additionalData.details } returns "HTML message" + cut = SuggestionDescriptionPanelFromLS(snykCodeFile, issue) + + val actual = getJLabelByText(cut, "Test message") + assertNull(actual) + } +} diff --git a/src/test/kotlin/snyk/UIComponentFinder.kt b/src/test/kotlin/snyk/UIComponentFinder.kt index 1b20702ef..250f76cfc 100644 --- a/src/test/kotlin/snyk/UIComponentFinder.kt +++ b/src/test/kotlin/snyk/UIComponentFinder.kt @@ -1,5 +1,6 @@ package snyk +import com.intellij.ui.jcef.JBCefBrowser import java.awt.Container import javax.swing.JButton import javax.swing.JLabel @@ -53,4 +54,20 @@ object UIComponentFinder { } return found } + + fun getJBCEFBrowser(parent: Container): JBCefBrowser.MyPanel? { + val components = parent.components + var found: JBCefBrowser.MyPanel? = null + for (component in components) { + if (component is JBCefBrowser.MyPanel) { + found = component + } else if (component is Container) { + found = getJBCEFBrowser(component) + } + if (found != null) { + break + } + } + return found + } }