From 6c3ab41a66e68914f5c9daa709024fa835312578 Mon Sep 17 00:00:00 2001 From: Philippe Perrin Date: Tue, 17 Dec 2019 18:49:19 +0100 Subject: [PATCH] SONAR-12720 Review tab displays the changelog of the hotspot --- .../src/main/js/api/security-hotspots.ts | 4 +- .../main/js/app/styles/components/badges.css | 11 + server/sonar-web/src/main/js/app/theme.js | 4 + .../securityHotspots/__tests__/utils-test.ts | 54 +- .../components/HotspotActions.tsx | 4 +- .../components/HotspotCategory.tsx | 2 +- .../components/HotspotList.css | 7 - .../components/HotspotSnippetContainer.tsx | 4 +- .../HotspotSnippetContainerRenderer.tsx | 4 +- .../components/HotspotViewer.tsx | 15 +- .../components/HotspotViewerRenderer.tsx | 10 +- .../HotspotViewerReviewHistoryTab.tsx | 80 +++ .../components/HotspotViewerTabs.tsx | 71 ++- .../__tests__/HotspotActions-test.tsx | 4 +- .../HotspotSnippetContainer-test.tsx | 10 +- .../HotspotSnippetContainerRenderer-test.tsx | 4 +- .../__tests__/HotspotViewerRenderer-test.tsx | 16 +- .../HotspotViewerReviewHistoryTab-test.tsx | 52 ++ .../__tests__/HotspotViewerTabs-test.tsx | 19 +- .../HotspotCategory-test.tsx.snap | 6 +- .../HotspotSnippetContainer-test.tsx.snap | 1 + ...spotSnippetContainerRenderer-test.tsx.snap | 1 + .../HotspotViewerRenderer-test.tsx.snap | 465 +++++++++++++++++- ...otspotViewerReviewHistoryTab-test.tsx.snap | 119 +++++ .../HotspotViewerTabs-test.tsx.snap | 286 +++++++++-- .../main/js/apps/securityHotspots/utils.ts | 43 +- .../js/helpers/mocks/security-hotspots.ts | 26 +- .../src/main/js/types/security-hotspots.ts | 21 +- .../resources/org/sonar/l10n/core.properties | 10 +- 29 files changed, 1208 insertions(+), 145 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerReviewHistoryTab-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerReviewHistoryTab-test.tsx.snap diff --git a/server/sonar-web/src/main/js/api/security-hotspots.ts b/server/sonar-web/src/main/js/api/security-hotspots.ts index 5714f9834b0..cd4bd24150a 100644 --- a/server/sonar-web/src/main/js/api/security-hotspots.ts +++ b/server/sonar-web/src/main/js/api/security-hotspots.ts @@ -21,7 +21,7 @@ import { getJSON, post } from 'sonar-ui-common/helpers/request'; import throwGlobalError from '../app/utils/throwGlobalError'; import { BranchParameters } from '../types/branch-like'; import { - DetailedHotspot, + Hotspot, HotspotAssignRequest, HotspotResolution, HotspotSearchResponse, @@ -58,6 +58,6 @@ export function getSecurityHotspots( return getJSON('/api/hotspots/search', data).catch(throwGlobalError); } -export function getSecurityHotspotDetails(securityHotspotKey: string): Promise { +export function getSecurityHotspotDetails(securityHotspotKey: string): Promise { return getJSON('/api/hotspots/show', { hotspot: securityHotspotKey }).catch(throwGlobalError); } diff --git a/server/sonar-web/src/main/js/app/styles/components/badges.css b/server/sonar-web/src/main/js/app/styles/components/badges.css index 807cfa0a910..58dfd9b7a29 100644 --- a/server/sonar-web/src/main/js/app/styles/components/badges.css +++ b/server/sonar-web/src/main/js/app/styles/components/badges.css @@ -69,3 +69,14 @@ a.badge { background-color: var(--alertBackgroundError); color: var(--alertTextError); } + +.counter-badge { + color: var(--badgeBlueColor); + background-color: var(--badgeBlueBackground); + padding: calc(var(--gridSize) / 2) var(--gridSize); + border-radius: 1em; +} + +.counter-badge:empty { + display: none; +} diff --git a/server/sonar-web/src/main/js/app/theme.js b/server/sonar-web/src/main/js/app/theme.js index 8b2de50ca23..48b05a12810 100644 --- a/server/sonar-web/src/main/js/app/theme.js +++ b/server/sonar-web/src/main/js/app/theme.js @@ -103,6 +103,10 @@ module.exports = { alertTextInfo: '#0e516f', alertIconInfo: '#0271b9', + // badge + badgeBlueBackground: '#2E7CB5', + badgeBlueColor: '#FFFFFF', + // alm azure: '#0078d7', bitbucket: '#0052CC', diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts index 3d4fa359524..cf0ec56d0d7 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/securityHotspots/__tests__/utils-test.ts @@ -17,9 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; -import { RiskExposure } from '../../../types/security-hotspots'; -import { groupByCategory, mapRules, sortHotspots } from '../utils'; +import { mockHotspot, mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; +import { ReviewHistoryType, RiskExposure } from '../../../types/security-hotspots'; +import { getHotspotReviewHistory, groupByCategory, mapRules, sortHotspots } from '../utils'; const hotspots = [ mockRawHotspot({ @@ -142,3 +142,51 @@ describe('mapRules', () => { }); }); }); + +describe('getHotspotReviewHistory', () => { + it('should properly create the review history', () => { + const changelogElement: T.IssueChangelog = { + creationDate: '2018-10-01', + isUserActive: true, + user: 'me', + userName: 'me-name', + diffs: [ + { + key: 'assign', + newValue: 'me', + oldValue: 'him' + } + ] + }; + const hotspot = mockHotspot({ + creationDate: '2018-09-01', + changelog: [changelogElement] + }); + const history = getHotspotReviewHistory(hotspot); + + expect(history.length).toBe(2); + expect(history[0]).toEqual( + expect.objectContaining({ + type: ReviewHistoryType.Creation, + date: hotspot.creationDate, + user: { + avatar: hotspot.author.avatar, + name: hotspot.author.name, + active: hotspot.author.active + } + }) + ); + expect(history[1]).toEqual( + expect.objectContaining({ + type: ReviewHistoryType.Diff, + date: changelogElement.creationDate, + user: { + avatar: changelogElement.avatar, + name: changelogElement.userName, + active: changelogElement.isUserActive + }, + diffs: changelogElement.diffs + }) + ); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx index 783129cf176..bca775c9ad7 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotActions.tsx @@ -24,11 +24,11 @@ import OutsideClickHandler from 'sonar-ui-common/components/controls/OutsideClic import DropdownIcon from 'sonar-ui-common/components/icons/DropdownIcon'; import { PopupPlacement } from 'sonar-ui-common/components/ui/popups'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import { DetailedHotspot, HotspotUpdateFields } from '../../../types/security-hotspots'; +import { Hotspot, HotspotUpdateFields } from '../../../types/security-hotspots'; import HotspotActionsForm from './HotspotActionsForm'; export interface HotspotActionsProps { - hotspot: DetailedHotspot; + hotspot: Hotspot; onSubmit: (hotspot: HotspotUpdateFields) => void; } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx index e4c32574ac2..91b142503e4 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotCategory.tsx @@ -53,7 +53,7 @@ export default function HotspotCategory(props: HotspotCategoryProps) { onClick={() => setExpanded(!expanded)}> {category.title} - {hotspots.length} + {hotspots.length} {expanded ? ( ) : ( diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.css b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.css index e8f6972ca43..242baa85c30 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.css +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.css @@ -75,13 +75,6 @@ cursor: unset; } -.hotspot-counter { - color: var(--baseFontColor); - background-color: var(--gray94); - border-radius: 50%; - padding: calc(var(--gridSize) / 2) var(--gridSize); -} - .hotspot-risk-badge { color: white; text-transform: uppercase; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainer.tsx index 5974f278b7f..c9afaceffa5 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainer.tsx @@ -22,13 +22,13 @@ import { getSources } from '../../../api/components'; import { locationsByLine } from '../../../components/SourceViewer/helpers/indexing'; import { getBranchLikeQuery } from '../../../helpers/branch-like'; import { BranchLike } from '../../../types/branch-like'; -import { DetailedHotspot } from '../../../types/security-hotspots'; +import { Hotspot } from '../../../types/security-hotspots'; import { constructSourceViewerFile } from '../utils'; import HotspotSnippetContainerRenderer from './HotspotSnippetContainerRenderer'; interface Props { branchLike?: BranchLike; - hotspot: DetailedHotspot; + hotspot: Hotspot; } interface State { diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx index d8dd52cabea..03f708f70ac 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotSnippetContainerRenderer.tsx @@ -22,13 +22,13 @@ import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; import { SourceViewerContext } from '../../../components/SourceViewer/SourceViewerContext'; import SourceViewerHeaderSlim from '../../../components/SourceViewer/SourceViewerHeaderSlim'; import { BranchLike } from '../../../types/branch-like'; -import { DetailedHotspot } from '../../../types/security-hotspots'; +import { Hotspot } from '../../../types/security-hotspots'; import SnippetViewer from '../../issues/crossComponentSourceViewer/SnippetViewer'; export interface HotspotSnippetContainerRendererProps { branchLike?: BranchLike; highlightedSymbols: string[]; - hotspot: DetailedHotspot; + hotspot: Hotspot; lastLine?: number; loading: boolean; locations: { [line: number]: T.LinearIssueLocation[] }; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx index 1b66d70803f..4adcd768d32 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewer.tsx @@ -21,11 +21,7 @@ import * as React from 'react'; import { getSecurityHotspotDetails } from '../../../api/security-hotspots'; import { BranchLike } from '../../../types/branch-like'; -import { - DetailedHotspot, - HotspotUpdate, - HotspotUpdateFields -} from '../../../types/security-hotspots'; +import { Hotspot, HotspotUpdate, HotspotUpdateFields } from '../../../types/security-hotspots'; import HotspotViewerRenderer from './HotspotViewerRenderer'; interface Props { @@ -36,14 +32,15 @@ interface Props { } interface State { - hotspot?: DetailedHotspot; + hotspot?: Hotspot; loading: boolean; } export default class HotspotViewer extends React.PureComponent { mounted = false; + state: State = { loading: false }; - componentWillMount() { + componentDidMount() { this.mounted = true; this.fetchHotspot(); } @@ -61,8 +58,8 @@ export default class HotspotViewer extends React.PureComponent { fetchHotspot() { this.setState({ loading: true }); return getSecurityHotspotDetails(this.props.hotspotKey) - .then(hotspot => this.mounted && this.setState({ hotspot })) - .finally(() => this.mounted && this.setState({ loading: false })); + .then(hotspot => this.mounted && this.setState({ hotspot, loading: false })) + .catch(() => this.mounted && this.setState({ loading: false })); } handleHotspotUpdate = (data: HotspotUpdateFields) => { diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx index 8ce753caee8..cf2b54a7e6e 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerRenderer.tsx @@ -23,7 +23,7 @@ import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n import { withCurrentUser } from '../../../components/hoc/withCurrentUser'; import { isLoggedIn } from '../../../helpers/users'; import { BranchLike } from '../../../types/branch-like'; -import { DetailedHotspot, HotspotUpdateFields } from '../../../types/security-hotspots'; +import { Hotspot, HotspotUpdateFields } from '../../../types/security-hotspots'; import HotspotActions from './HotspotActions'; import HotspotSnippetContainer from './HotspotSnippetContainer'; import HotspotViewerTabs from './HotspotViewerTabs'; @@ -31,7 +31,7 @@ import HotspotViewerTabs from './HotspotViewerTabs'; export interface HotspotViewerRendererProps { branchLike?: BranchLike; currentUser: T.CurrentUser; - hotspot?: DetailedHotspot; + hotspot?: Hotspot; loading: boolean; onUpdateHotspot: (hotspot: HotspotUpdateFields) => void; securityCategories: T.StandardSecurityCategories; @@ -52,20 +52,20 @@ export function HotspotViewerRenderer(props: HotspotViewerRendererProps) { )}
- {translate('hotspot.category')} + {translate('category')}: {securityCategories[hotspot.rule.securityCategory].title}
- {translate('hotspot.status')} + {translate('status')}: {translate('hotspot.status', hotspot.resolution || hotspot.status)} {hotspot.assignee && hotspot.assignee.name && ( <> - {translate('hotspot.assigned_to')} + {translate('assigned_to')}: {hotspot.assignee.active ? hotspot.assignee.name diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx new file mode 100644 index 00000000000..de1fee9b3c2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerReviewHistoryTab.tsx @@ -0,0 +1,80 @@ +/* + * SonarQube + * Copyright (C) 2009-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { translate, translateWithParameters } from 'sonar-ui-common/helpers/l10n'; +import DateTimeFormatter from '../../../components/intl/DateTimeFormatter'; +import IssueChangelogDiff from '../../../components/issue/components/IssueChangelogDiff'; +import Avatar from '../../../components/ui/Avatar'; +import { ReviewHistoryElement, ReviewHistoryType } from '../../../types/security-hotspots'; + +export interface HotspotViewerReviewHistoryTabProps { + history: ReviewHistoryElement[]; +} + +export default function HotspotViewerReviewHistoryTab(props: HotspotViewerReviewHistoryTabProps) { + const { history } = props; + + return ( + <> + {history.map((elt, i) => ( + + {i > 0 &&
} +
+
+ {elt.user.name && ( + <> + + + {elt.user.active + ? elt.user.name + : translateWithParameters('user.x_deleted', elt.user.name)} + + {elt.type === ReviewHistoryType.Creation && ( + + {translate('hotspots.tabs.review_history.created')} + + )} + - + + )} + +
+ + {elt.type === ReviewHistoryType.Diff && elt.diffs && ( +
+ {elt.diffs.map(diff => ( + + ))} +
+ )} +
+
+ ))} + + ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx index e02c11b164d..de17ec8ffad 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotViewerTabs.tsx @@ -21,56 +21,79 @@ import { sanitize } from 'dompurify'; import * as React from 'react'; import BoxedTabs from 'sonar-ui-common/components/controls/BoxedTabs'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import { DetailedHotspot } from '../../../types/security-hotspots'; +import { Hotspot } from '../../../types/security-hotspots'; +import { getHotspotReviewHistory } from '../utils'; +import HotspotViewerReviewHistoryTab from './HotspotViewerReviewHistoryTab'; export interface HotspotViewerTabsProps { - hotspot: DetailedHotspot; + hotspot: Hotspot; } export enum Tabs { RiskDescription = 'risk', VulnerabilityDescription = 'vulnerability', - FixRecommendation = 'fix' + FixRecommendation = 'fix', + ReviewHistory = 'review' } export default function HotspotViewerTabs(props: HotspotViewerTabsProps) { const { hotspot } = props; - const [currentTab, setCurrentTab] = React.useState(Tabs.RiskDescription); + const [currentTabKey, setCurrentTabKey] = React.useState(Tabs.RiskDescription); + const hotspotReviewHistory = React.useMemo(() => getHotspotReviewHistory(hotspot), [hotspot]); - const tabs = { - [Tabs.RiskDescription]: { - title: translate('hotspot.tabs.risk_description'), + const tabs = [ + { + key: Tabs.RiskDescription, + label: translate('hotspots.tabs.risk_description'), content: hotspot.rule.riskDescription || '' }, - [Tabs.VulnerabilityDescription]: { - title: translate('hotspot.tabs.vulnerability_description'), + { + key: Tabs.VulnerabilityDescription, + label: translate('hotspots.tabs.vulnerability_description'), content: hotspot.rule.vulnerabilityDescription || '' }, - [Tabs.FixRecommendation]: { - title: translate('hotspot.tabs.fix_recommendations'), + { + key: Tabs.FixRecommendation, + label: translate('hotspots.tabs.fix_recommendations'), content: hotspot.rule.fixRecommendations || '' + }, + { + key: Tabs.ReviewHistory, + label: ( + <> + {translate('hotspots.tabs.review_history')} + {hotspotReviewHistory.length} + + ), + content: hotspotReviewHistory.length > 0 && ( + + ) } - }; - - const tabsToDisplay = Object.values(Tabs) - .filter(tab => Boolean(tabs[tab].content)) - .map(tab => ({ key: tab, label: tabs[tab].title })); + ].filter(tab => Boolean(tab.content)); - if (tabsToDisplay.length === 0) { + if (tabs.length === 0) { return null; } - if (!tabsToDisplay.find(tab => tab.key === currentTab)) { - setCurrentTab(tabsToDisplay[0].key); - } + const currentTab = tabs.find(tab => tab.key === currentTabKey) || tabs[0]; return ( <> - setCurrentTab(tab)} selected={currentTab} tabs={tabsToDisplay} /> -
setCurrentTabKey(tabKey)} + selected={currentTabKey} + tabs={tabs} /> +
+ {typeof currentTab.content === 'string' ? ( +
+ ) : ( + <>{currentTab.content} + )} +
); } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx index 2dca00aa32e..1f67a35002f 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotActions-test.tsx @@ -21,7 +21,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { Button } from 'sonar-ui-common/components/controls/buttons'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots'; +import { mockHotspot } from '../../../../helpers/mocks/security-hotspots'; import { HotspotStatus } from '../../../../types/security-hotspots'; import HotspotActions, { HotspotActionsProps } from '../HotspotActions'; @@ -70,7 +70,7 @@ it('should register an eventlistener', () => { function shallowRender(props: Partial = {}) { return shallow( diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx index 622840289f8..23b4bad0dc9 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainer-test.tsx @@ -23,7 +23,7 @@ import * as React from 'react'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; import { getSources } from '../../../../api/components'; import { mockBranch } from '../../../../helpers/mocks/branch-like'; -import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots'; +import { mockHotspot } from '../../../../helpers/mocks/security-hotspots'; import { mockSourceLine } from '../../../../helpers/testMocks'; import HotspotSnippetContainer from '../HotspotSnippetContainer'; import HotspotSnippetContainerRenderer from '../HotspotSnippetContainerRenderer'; @@ -43,7 +43,7 @@ it('should load sources on mount', async () => { range(5, 18).map(line => mockSourceLine({ line })) ); - const hotspot = mockDetailledHotspot({ + const hotspot = mockHotspot({ textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 } }); @@ -65,7 +65,7 @@ it('should handle end-of-file on mount', async () => { range(5, 15).map(line => mockSourceLine({ line })) ); - const hotspot = mockDetailledHotspot({ + const hotspot = mockHotspot({ textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 } }); @@ -85,7 +85,7 @@ describe('Expansion', () => { ); }); - const hotspot = mockDetailledHotspot({ + const hotspot = mockHotspot({ textRange: { startLine: 10, endLine: 11, startOffset: 0, endOffset: 12 } }); @@ -193,6 +193,6 @@ it('should handle symbol click', () => { function shallowRender(props?: Partial) { return shallow( - + ); } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx index 9fd30ab5216..064ef445734 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotSnippetContainerRenderer-test.tsx @@ -20,7 +20,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { mockMainBranch } from '../../../../helpers/mocks/branch-like'; -import { mockDetailledHotspot } from '../../../../helpers/mocks/security-hotspots'; +import { mockHotspot } from '../../../../helpers/mocks/security-hotspots'; import { mockSourceLine, mockSourceViewerFile } from '../../../../helpers/testMocks'; import HotspotSnippetContainerRenderer, { HotspotSnippetContainerRendererProps @@ -36,7 +36,7 @@ function shallowRender(props?: Partial) { { const wrapper = shallowRender(); expect(wrapper).toMatchSnapshot(); expect(shallowRender({ hotspot: undefined })).toMatchSnapshot('no hotspot'); + expect(shallowRender({ hotspot: mockHotspot({ assignee: undefined }) })).toMatchSnapshot( + 'unassigned' + ); expect( - shallowRender({ hotspot: mockDetailledHotspot({ assignee: mockUser({ active: false }) }) }) + shallowRender({ hotspot: mockHotspot({ assignee: mockUser({ active: false }) }) }) ).toMatchSnapshot('deleted assignee'); + expect( + shallowRender({ + hotspot: mockHotspot({ + assignee: mockUser({ name: undefined, login: 'assignee_login' }) + }) + }) + ).toMatchSnapshot('assignee without name'); expect(shallowRender()).toMatchSnapshot('anonymous user'); expect(shallowRender({ currentUser: mockLoggedInUser() })).toMatchSnapshot('user logged in'); }); @@ -38,7 +48,7 @@ function shallowRender(props?: Partial) { return shallow( { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); +}); + +function shallowRender(props?: Partial) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx index 12fa76200b9..a28be0a541b 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotViewerTabs-test.tsx @@ -20,10 +20,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import BoxedTabs from 'sonar-ui-common/components/controls/BoxedTabs'; -import { - mockDetailledHotspot, - mockDetailledHotspotRule -} from '../../../../helpers/mocks/security-hotspots'; +import { mockHotspot, mockHotspotRule } from '../../../../helpers/mocks/security-hotspots'; import HotspotViewerTabs, { HotspotViewerTabsProps, Tabs } from '../HotspotViewerTabs'; it('should render correctly', () => { @@ -40,20 +37,24 @@ it('should render correctly', () => { onSelect(Tabs.FixRecommendation); expect(wrapper).toMatchSnapshot('fix'); + + onSelect(Tabs.ReviewHistory); + expect(wrapper).toMatchSnapshot('review'); } expect( shallowRender({ - hotspot: mockDetailledHotspot({ - rule: mockDetailledHotspotRule({ riskDescription: undefined }) + hotspot: mockHotspot({ + rule: mockHotspotRule({ riskDescription: undefined }) }) }) ).toMatchSnapshot('empty tab'); expect( shallowRender({ - hotspot: mockDetailledHotspot({ - rule: mockDetailledHotspotRule({ + hotspot: mockHotspot({ + creationDate: undefined, + rule: mockHotspotRule({ riskDescription: undefined, fixRecommendations: undefined, vulnerabilityDescription: undefined @@ -64,5 +65,5 @@ it('should render correctly', () => { }); function shallowRender(props?: Partial) { - return shallow(); + return shallow(); } diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap index 9bf2e5f400d..66a40e18e94 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotCategory-test.tsx.snap @@ -16,7 +16,7 @@ exports[`should handle collapse and expand 1`] = `
1 @@ -44,7 +44,7 @@ exports[`should handle collapse and expand 2`] = ` 1 @@ -101,7 +101,7 @@ exports[`should render correctly with hotspots 1`] = ` 2 diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap index f24c9b4ed5a..adcc51f65c5 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainer-test.tsx.snap @@ -25,6 +25,7 @@ exports[`should render correctly 1`] = ` "login": "john.doe", "name": "John Doe", }, + "changelog": Array [], "component": Object { "breadcrumbs": Array [], "key": "my-project", diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap index df32f19c2b8..e4fe72741f2 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotSnippetContainerRenderer-test.tsx.snap @@ -149,6 +149,7 @@ exports[`should render correctly: with sourcelines 1`] = ` "login": "john.doe", "name": "John Doe", }, + "changelog": Array [], "component": Object { "breadcrumbs": Array [], "key": "my-project", diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap index 716dfc0dca7..51794f6d687 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerRenderer-test.tsx.snap @@ -22,7 +22,8 @@ exports[`should render correctly 1`] = ` className="text-muted" > - hotspot.category + category + : - hotspot.status + status + : - hotspot.assigned_to + assigned_to + : - hotspot.category + category + : - hotspot.status + status + : - hotspot.assigned_to + assigned_to + : This a strong message about fixing !

", + "key": "squid:S2077", + "name": "That rule", + "riskDescription": "

This a strong message about risk !

", + "securityCategory": "sql-injection", + "vulnerabilityDescription": "

This a strong message about vulnerability !

", + "vulnerabilityProbability": "HIGH", + }, + "status": "REVIEWED", + "textRange": Object { + "endLine": 142, + "endOffset": 83, + "startLine": 142, + "startOffset": 26, + }, + "updateDate": "2013-05-13T17:55:42+0200", + } + } + /> +
+ +`; + +exports[`should render correctly: assignee without name 1`] = ` + +
+
+
+

+ '3' is a magic number. +

+
+
+ + category + : + + + SQL injection + +
+
+
+ + status + : + + + hotspot.status.FIXED + +
+ This a strong message about fixing !

", + "key": "squid:S2077", + "name": "That rule", + "riskDescription": "

This a strong message about risk !

", + "securityCategory": "sql-injection", + "vulnerabilityDescription": "

This a strong message about vulnerability !

", + "vulnerabilityProbability": "HIGH", + }, + "status": "REVIEWED", + "textRange": Object { + "endLine": 142, + "endOffset": 83, + "startLine": 142, + "startOffset": 26, + }, + "updateDate": "2013-05-13T17:55:42+0200", + } + } + /> + - hotspot.category + category + : - hotspot.status + status + : - hotspot.assigned_to + assigned_to + : `; +exports[`should render correctly: unassigned 1`] = ` + +
+
+
+

+ '3' is a magic number. +

+
+
+ + category + : + + + SQL injection + +
+
+
+ + status + : + + + hotspot.status.FIXED + +
+ This a strong message about fixing !

", + "key": "squid:S2077", + "name": "That rule", + "riskDescription": "

This a strong message about risk !

", + "securityCategory": "sql-injection", + "vulnerabilityDescription": "

This a strong message about vulnerability !

", + "vulnerabilityProbability": "HIGH", + }, + "status": "REVIEWED", + "textRange": Object { + "endLine": 142, + "endOffset": 83, + "startLine": 142, + "startOffset": 26, + }, + "updateDate": "2013-05-13T17:55:42+0200", + } + } + /> + This a strong message about fixing !

", + "key": "squid:S2077", + "name": "That rule", + "riskDescription": "

This a strong message about risk !

", + "securityCategory": "sql-injection", + "vulnerabilityDescription": "

This a strong message about vulnerability !

", + "vulnerabilityProbability": "HIGH", + }, + "status": "REVIEWED", + "textRange": Object { + "endLine": 142, + "endOffset": 83, + "startLine": 142, + "startOffset": 26, + }, + "updateDate": "2013-05-13T17:55:42+0200", + } + } + /> +
+
+`; + exports[`should render correctly: user logged in 1`] = ` - hotspot.category + category + : - hotspot.status + status + : - hotspot.assigned_to + assigned_to + : +
+
+ + + John Doe + + + hotspots.tabs.review_history.created + + + - + + +
+
+
+
+
+ + + user.x_deleted.John Doe + + + hotspots.tabs.review_history.created + + + - + + +
+
+
+
+
+ +
+
+
+
+
+ + + John Doe + + + - + + +
+
+ + +
+
+ +`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap index 5e0f68e43d9..7262c691e11 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotViewerTabs-test.tsx.snap @@ -4,28 +4,62 @@ exports[`should render correctly: empty tab 1`] = ` This a strong message about vulnerability !

", "key": "vulnerability", - "label": "hotspot.tabs.vulnerability_description", + "label": "hotspots.tabs.vulnerability_description", }, Object { + "content": "

This a strong message about fixing !

", "key": "fix", - "label": "hotspot.tabs.fix_recommendations", + "label": "hotspots.tabs.fix_recommendations", + }, + Object { + "content": , + "key": "review", + "label": + + hotspots.tabs.review_history + + + 1 + + , }, ] } />
This a strong message about vulnerability !

", + className="boxed-group big-padded" + > +
This a strong message about vulnerability !

", + } } - } - /> + /> +
`; @@ -37,63 +71,208 @@ exports[`should render correctly: fix 1`] = ` tabs={ Array [ Object { + "content": "

This a strong message about risk !

", "key": "risk", - "label": "hotspot.tabs.risk_description", + "label": "hotspots.tabs.risk_description", }, Object { + "content": "

This a strong message about vulnerability !

", "key": "vulnerability", - "label": "hotspot.tabs.vulnerability_description", + "label": "hotspots.tabs.vulnerability_description", }, Object { + "content": "

This a strong message about fixing !

", "key": "fix", - "label": "hotspot.tabs.fix_recommendations", + "label": "hotspots.tabs.fix_recommendations", + }, + Object { + "content": , + "key": "review", + "label": + + hotspots.tabs.review_history + + + 1 + + , }, ] } />
This a strong message about fixing !

", + className="boxed-group big-padded" + > +
This a strong message about fixing !

", + } } - } - /> + /> +
`; exports[`should render correctly: no tabs 1`] = `""`; -exports[`should render correctly: risk 1`] = ` +exports[`should render correctly: review 1`] = ` This a strong message about risk !

", "key": "risk", - "label": "hotspot.tabs.risk_description", + "label": "hotspots.tabs.risk_description", }, Object { + "content": "

This a strong message about vulnerability !

", "key": "vulnerability", - "label": "hotspot.tabs.vulnerability_description", + "label": "hotspots.tabs.vulnerability_description", }, Object { + "content": "

This a strong message about fixing !

", "key": "fix", - "label": "hotspot.tabs.fix_recommendations", + "label": "hotspots.tabs.fix_recommendations", + }, + Object { + "content": , + "key": "review", + "label": + + hotspots.tabs.review_history + + + 1 + + , }, ] } />
This a strong message about risk !

", + className="boxed-group big-padded" + > + +
+
+`; + +exports[`should render correctly: risk 1`] = ` + + This a strong message about risk !

", + "key": "risk", + "label": "hotspots.tabs.risk_description", + }, + Object { + "content": "

This a strong message about vulnerability !

", + "key": "vulnerability", + "label": "hotspots.tabs.vulnerability_description", + }, + Object { + "content": "

This a strong message about fixing !

", + "key": "fix", + "label": "hotspots.tabs.fix_recommendations", + }, + Object { + "content": , + "key": "review", + "label": + + hotspots.tabs.review_history + + + 1 + + , + }, + ] } /> +
+
This a strong message about risk !

", + } + } + /> +
`; @@ -105,27 +284,62 @@ exports[`should render correctly: vulnerability 1`] = ` tabs={ Array [ Object { + "content": "

This a strong message about risk !

", "key": "risk", - "label": "hotspot.tabs.risk_description", + "label": "hotspots.tabs.risk_description", }, Object { + "content": "

This a strong message about vulnerability !

", "key": "vulnerability", - "label": "hotspot.tabs.vulnerability_description", + "label": "hotspots.tabs.vulnerability_description", }, Object { + "content": "

This a strong message about fixing !

", "key": "fix", - "label": "hotspot.tabs.fix_recommendations", + "label": "hotspots.tabs.fix_recommendations", + }, + Object { + "content": , + "key": "review", + "label": + + hotspots.tabs.review_history + + + 1 + + , }, ] } />
This a strong message about vulnerability !

", + className="boxed-group big-padded" + > +
This a strong message about vulnerability !

", + } } - } - /> + /> +
`; diff --git a/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts b/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts index e2d86f7ecc1..bed8333ed12 100644 --- a/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts +++ b/server/sonar-web/src/main/js/apps/securityHotspots/utils.ts @@ -18,7 +18,13 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { groupBy, sortBy } from 'lodash'; -import { DetailedHotspot, RawHotspot, RiskExposure } from '../../types/security-hotspots'; +import { + Hotspot, + RawHotspot, + ReviewHistoryElement, + ReviewHistoryType, + RiskExposure +} from '../../types/security-hotspots'; export const RISK_EXPOSURE_LEVELS = [RiskExposure.HIGH, RiskExposure.MEDIUM, RiskExposure.LOW]; @@ -61,7 +67,7 @@ function getCategoryTitle(key: string, securityCategories: T.StandardSecurityCat } export function constructSourceViewerFile( - { component, project }: DetailedHotspot, + { component, project }: Hotspot, lines?: number ): T.SourceViewerFile { return { @@ -74,3 +80,36 @@ export function constructSourceViewerFile( uuid: '' }; } + +export function getHotspotReviewHistory(hotspot: Hotspot): ReviewHistoryElement[] { + const history: ReviewHistoryElement[] = []; + + if (hotspot.creationDate) { + history.push({ + type: ReviewHistoryType.Creation, + date: hotspot.creationDate, + user: { + avatar: hotspot.author.avatar, + name: hotspot.author.name || hotspot.author.login, + active: hotspot.author.active + } + }); + } + + if (hotspot.changelog) { + history.push( + ...hotspot.changelog.map(log => ({ + type: ReviewHistoryType.Diff, + date: log.creationDate, + user: { + avatar: log.avatar, + name: log.userName || log.user, + active: log.isUserActive + }, + diffs: log.diffs + })) + ); + } + + return sortBy(history, elt => elt.date); +} diff --git a/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts b/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts index a0633c1892e..4351dc56ecb 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/security-hotspots.ts @@ -19,11 +19,13 @@ */ import { ComponentQualifier } from '../../types/component'; import { - DetailedHotspot, - DetailedHotspotRule, + Hotspot, HotspotResolution, + HotspotRule, HotspotStatus, RawHotspot, + ReviewHistoryElement, + ReviewHistoryType, RiskExposure } from '../../types/security-hotspots'; import { mockComponent, mockUser } from '../testMocks'; @@ -47,10 +49,11 @@ export function mockRawHotspot(overrides: Partial = {}): RawHotspot }; } -export function mockDetailledHotspot(overrides?: Partial): DetailedHotspot { +export function mockHotspot(overrides?: Partial): Hotspot { return { assignee: mockUser(), author: mockUser(), + changelog: [], component: mockComponent({ qualifier: ComponentQualifier.File }), creationDate: '2013-05-13T17:55:41+0200', key: '01fc972e-2a3c-433e-bcae-0bd7f88f5123', @@ -58,7 +61,7 @@ export function mockDetailledHotspot(overrides?: Partial): Deta message: "'3' is a magic number.", project: mockComponent({ qualifier: ComponentQualifier.Project }), resolution: HotspotResolution.FIXED, - rule: mockDetailledHotspotRule(), + rule: mockHotspotRule(), status: HotspotStatus.REVIEWED, textRange: { startLine: 142, @@ -71,9 +74,7 @@ export function mockDetailledHotspot(overrides?: Partial): Deta }; } -export function mockDetailledHotspotRule( - overrides?: Partial -): DetailedHotspotRule { +export function mockHotspotRule(overrides?: Partial): HotspotRule { return { key: 'squid:S2077', name: 'That rule', @@ -85,3 +86,14 @@ export function mockDetailledHotspotRule( ...overrides }; } + +export function mockHotspotReviewHistoryElement( + overrides?: Partial +): ReviewHistoryElement { + return { + date: '2019-09-13T17:55:42+0200', + type: ReviewHistoryType.Creation, + user: mockUser(), + ...overrides + }; +} diff --git a/server/sonar-web/src/main/js/types/security-hotspots.ts b/server/sonar-web/src/main/js/types/security-hotspots.ts index 51d8134ca39..a7e3536f414 100644 --- a/server/sonar-web/src/main/js/types/security-hotspots.ts +++ b/server/sonar-web/src/main/js/types/security-hotspots.ts @@ -68,9 +68,10 @@ export interface RawHotspot { vulnerabilityProbability: RiskExposure; } -export interface DetailedHotspot { +export interface Hotspot { assignee?: Pick; - author?: Pick; + author: Pick; + changelog?: T.IssueChangelog[]; component: T.Component; creationDate: string; key: string; @@ -78,7 +79,7 @@ export interface DetailedHotspot { message: string; project: T.Component; resolution?: string; - rule: DetailedHotspotRule; + rule: HotspotRule; status: string; textRange: T.TextRange; updateDate: string; @@ -93,7 +94,7 @@ export interface HotspotUpdate extends HotspotUpdateFields { key: string; } -export interface DetailedHotspotRule { +export interface HotspotRule { fixRecommendations?: string; key: string; name: string; @@ -103,6 +104,18 @@ export interface DetailedHotspotRule { vulnerabilityProbability: RiskExposure; } +export interface ReviewHistoryElement { + type: ReviewHistoryType; + date: string; + user: Pick; + diffs?: T.IssueChangelogDiff[]; +} + +export enum ReviewHistoryType { + Creation, + Diff +} + export interface HotspotSearchResponse { components?: { key: string; qualifier: string; name: string }[]; hotspots: RawHotspot[]; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 237bc380512..8c26488e343 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -656,9 +656,12 @@ hotspots.risk_exposure=Review priority: hotspot.category=Category: hotspot.status=Status: hotspot.assigned_to=Assigned to: -hotspot.tabs.risk_description=What's the risk? -hotspot.tabs.vulnerability_description=Are you vulnerable? -hotspot.tabs.fix_recommendations=How can you fix it? +hotspots.tabs.risk_description=What's the risk? +hotspots.tabs.vulnerability_description=Are you vulnerable? +hotspots.tabs.fix_recommendations=How can you fix it? +hotspots.tabs.review_history=Review history +hotspots.tabs.review_history.created=created Security Hotspot + hotspot.change_status.REVIEWED=Change status hotspot.change_status.TO_REVIEW=Review Hotspot @@ -672,6 +675,7 @@ hotspot.filters.assignee.all=All hotspot.filters.status.to_review=To review hotspot.filters.status.fixed=Reviewed as fixed hotspot.filters.status.safe=Reviewed as safe +hotspots.review_hotspot=Review Hotspot hotspots.form.title=Mark Security Hotspot as: -- 2.39.5