@@ -58,6 +58,10 @@ export function getSecurityHotspots( | |||
return getJSON('/api/hotspots/search', data).catch(throwGlobalError); | |||
} | |||
export function getSecurityHotspotList(hotspotKeys: string[]): Promise<HotspotSearchResponse> { | |||
return getJSON('/api/hotspots/search', { hotspots: hotspotKeys.join() }).catch(throwGlobalError); | |||
} | |||
export function getSecurityHotspotDetails(securityHotspotKey: string): Promise<Hotspot> { | |||
return getJSON('/api/hotspots/show', { hotspot: securityHotspotKey }) | |||
.then((response: Hotspot & { users: T.UserBase[] }) => { |
@@ -17,10 +17,12 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { Location } from 'history'; | |||
import * as React from 'react'; | |||
import { addNoFooterPageClass, removeNoFooterPageClass } from 'sonar-ui-common/helpers/pages'; | |||
import { getSecurityHotspots } from '../../api/security-hotspots'; | |||
import { getSecurityHotspotList, getSecurityHotspots } from '../../api/security-hotspots'; | |||
import { withCurrentUser } from '../../components/hoc/withCurrentUser'; | |||
import { Router } from '../../components/hoc/withRouter'; | |||
import { getBranchLikeQuery } from '../../helpers/branch-like'; | |||
import { getStandards } from '../../helpers/security-standard'; | |||
import { isLoggedIn } from '../../helpers/users'; | |||
@@ -43,9 +45,12 @@ interface Props { | |||
branchLike?: BranchLike; | |||
currentUser: T.CurrentUser; | |||
component: T.Component; | |||
location: Location; | |||
router: Router; | |||
} | |||
interface State { | |||
hotspotKeys?: string[]; | |||
hotspots: RawHotspot[]; | |||
loading: boolean; | |||
securityCategories: T.StandardSecurityCategories; | |||
@@ -79,7 +84,10 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { | |||
} | |||
componentDidUpdate(previous: Props) { | |||
if (this.props.component.key !== previous.component.key) { | |||
if ( | |||
this.props.component.key !== previous.component.key || | |||
this.props.location.query.hotspots !== previous.location.query.hotspots | |||
) { | |||
this.fetchInitialData(); | |||
} | |||
} | |||
@@ -115,9 +123,19 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { | |||
} | |||
fetchSecurityHotspots() { | |||
const { branchLike, component } = this.props; | |||
const { branchLike, component, location } = this.props; | |||
const { filters } = this.state; | |||
const hotspotKeys = location.query.hotspots | |||
? (location.query.hotspots as string).split(',') | |||
: undefined; | |||
this.setState({ hotspotKeys }); | |||
if (hotspotKeys && hotspotKeys.length > 0) { | |||
return getSecurityHotspotList(hotspotKeys); | |||
} | |||
const status = | |||
filters.status === HotspotStatusFilter.TO_REVIEW | |||
? HotspotStatus.TO_REVIEW | |||
@@ -187,18 +205,34 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { | |||
}); | |||
}; | |||
handleShowAllHotspots = () => { | |||
this.props.router.push({ | |||
...this.props.location, | |||
query: { ...this.props.location.query, hotspots: undefined } | |||
}); | |||
}; | |||
render() { | |||
const { branchLike } = this.props; | |||
const { hotspots, loading, securityCategories, selectedHotspotKey, filters } = this.state; | |||
const { | |||
hotspotKeys, | |||
hotspots, | |||
loading, | |||
securityCategories, | |||
selectedHotspotKey, | |||
filters | |||
} = this.state; | |||
return ( | |||
<SecurityHotspotsAppRenderer | |||
branchLike={branchLike} | |||
filters={filters} | |||
hotspots={hotspots} | |||
isStaticListOfHotspots={Boolean(hotspotKeys && hotspotKeys.length > 0)} | |||
loading={loading} | |||
onChangeFilters={this.handleChangeFilters} | |||
onHotspotClick={this.handleHotspotClick} | |||
onShowAllHotspots={this.handleShowAllHotspots} | |||
onUpdateHotspot={this.handleHotspotUpdate} | |||
securityCategories={securityCategories} | |||
selectedHotspotKey={selectedHotspotKey} |
@@ -37,20 +37,35 @@ export interface SecurityHotspotsAppRendererProps { | |||
branchLike?: BranchLike; | |||
filters: HotspotFilters; | |||
hotspots: RawHotspot[]; | |||
isStaticListOfHotspots: boolean; | |||
loading: boolean; | |||
onChangeFilters: (filters: Partial<HotspotFilters>) => void; | |||
onHotspotClick: (key: string) => void; | |||
onShowAllHotspots: () => void; | |||
onUpdateHotspot: (hotspot: HotspotUpdate) => void; | |||
selectedHotspotKey?: string; | |||
securityCategories: T.StandardSecurityCategories; | |||
} | |||
export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRendererProps) { | |||
const { branchLike, hotspots, loading, securityCategories, selectedHotspotKey, filters } = props; | |||
const { | |||
branchLike, | |||
hotspots, | |||
isStaticListOfHotspots, | |||
loading, | |||
securityCategories, | |||
selectedHotspotKey, | |||
filters | |||
} = props; | |||
return ( | |||
<div id="security_hotspots"> | |||
<FilterBar onChangeFilters={props.onChangeFilters} filters={filters} /> | |||
<FilterBar | |||
filters={filters} | |||
isStaticListOfHotspots={isStaticListOfHotspots} | |||
onChangeFilters={props.onChangeFilters} | |||
onShowAllHotspots={props.onShowAllHotspots} | |||
/> | |||
<ScreenPositionHelper> | |||
{({ top }) => ( | |||
<div className="wrapper" style={{ top }}> | |||
@@ -84,6 +99,7 @@ export default function SecurityHotspotsAppRenderer(props: SecurityHotspotsAppRe | |||
<div className="sidebar"> | |||
<HotspotList | |||
hotspots={hotspots} | |||
isStaticListOfHotspots={isStaticListOfHotspots} | |||
onHotspotClick={props.onHotspotClick} | |||
securityCategories={securityCategories} | |||
selectedHotspotKey={selectedHotspotKey} |
@@ -21,11 +21,16 @@ import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { addNoFooterPageClass } from 'sonar-ui-common/helpers/pages'; | |||
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; | |||
import { getSecurityHotspots } from '../../../api/security-hotspots'; | |||
import { getSecurityHotspotList, getSecurityHotspots } from '../../../api/security-hotspots'; | |||
import { mockBranch } from '../../../helpers/mocks/branch-like'; | |||
import { mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; | |||
import { getStandards } from '../../../helpers/security-standard'; | |||
import { mockComponent, mockCurrentUser } from '../../../helpers/testMocks'; | |||
import { | |||
mockComponent, | |||
mockCurrentUser, | |||
mockLocation, | |||
mockRouter | |||
} from '../../../helpers/testMocks'; | |||
import { | |||
HotspotResolution, | |||
HotspotStatus, | |||
@@ -34,13 +39,16 @@ import { | |||
import { SecurityHotspotsApp } from '../SecurityHotspotsApp'; | |||
import SecurityHotspotsAppRenderer from '../SecurityHotspotsAppRenderer'; | |||
beforeEach(() => jest.clearAllMocks()); | |||
jest.mock('sonar-ui-common/helpers/pages', () => ({ | |||
addNoFooterPageClass: jest.fn(), | |||
removeNoFooterPageClass: jest.fn() | |||
})); | |||
jest.mock('../../../api/security-hotspots', () => ({ | |||
getSecurityHotspots: jest.fn().mockResolvedValue({ hotspots: [], rules: [] }) | |||
getSecurityHotspots: jest.fn().mockResolvedValue({ hotspots: [], rules: [] }), | |||
getSecurityHotspotList: jest.fn().mockResolvedValue({ hotspots: [], rules: [] }) | |||
})); | |||
jest.mock('../../../helpers/security-standard', () => ({ | |||
@@ -84,6 +92,54 @@ it('should load data correctly', async () => { | |||
expect(wrapper.state()); | |||
}); | |||
it('should load data correctly when hotspot key list is forced', async () => { | |||
const sonarsourceSecurity = { cat1: { title: 'cat 1' } }; | |||
(getStandards as jest.Mock).mockResolvedValue({ sonarsourceSecurity }); | |||
const hotspots = [ | |||
mockRawHotspot({ key: 'test1' }), | |||
mockRawHotspot({ key: 'test2' }), | |||
mockRawHotspot({ key: 'test3' }) | |||
]; | |||
const hotspotKeys = hotspots.map(h => h.key); | |||
(getSecurityHotspotList as jest.Mock).mockResolvedValueOnce({ | |||
hotspots | |||
}); | |||
const location = mockLocation({ query: { hotspots: hotspotKeys.join() } }); | |||
const router = mockRouter(); | |||
const wrapper = shallowRender({ | |||
location, | |||
router | |||
}); | |||
await waitAndUpdate(wrapper); | |||
expect(getSecurityHotspotList).toBeCalledWith(hotspotKeys); | |||
expect(wrapper.state().hotspotKeys).toEqual(hotspotKeys); | |||
expect(wrapper.find(SecurityHotspotsAppRenderer).props().isStaticListOfHotspots).toBeTruthy(); | |||
// Reset | |||
(getSecurityHotspots as jest.Mock).mockClear(); | |||
(getSecurityHotspotList as jest.Mock).mockClear(); | |||
wrapper | |||
.find(SecurityHotspotsAppRenderer) | |||
.props() | |||
.onShowAllHotspots(); | |||
expect(router.push).toHaveBeenCalledWith({ | |||
...location, | |||
query: { ...location.query, hotspots: undefined } | |||
}); | |||
// Simulate a new location | |||
wrapper.setProps({ | |||
location: { ...location, query: { ...location.query, hotspots: undefined } } | |||
}); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper.state().hotspotKeys).toBeUndefined(); | |||
expect(getSecurityHotspotList).not.toHaveBeenCalled(); | |||
expect(getSecurityHotspots).toHaveBeenCalled(); | |||
}); | |||
it('should handle hotspot update', async () => { | |||
const key = 'hotspotKey'; | |||
const hotspots = [mockRawHotspot(), mockRawHotspot({ key })]; | |||
@@ -153,6 +209,8 @@ function shallowRender(props: Partial<SecurityHotspotsApp['props']> = {}) { | |||
branchLike={branch} | |||
component={mockComponent()} | |||
currentUser={mockCurrentUser()} | |||
location={mockLocation()} | |||
router={mockRouter()} | |||
{...props} | |||
/> | |||
); |
@@ -22,6 +22,7 @@ import * as React from 'react'; | |||
import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; | |||
import { mockRawHotspot } from '../../../helpers/mocks/security-hotspots'; | |||
import { HotspotStatusFilter } from '../../../types/security-hotspots'; | |||
import FilterBar from '../components/FilterBar'; | |||
import SecurityHotspotsAppRenderer, { | |||
SecurityHotspotsAppRendererProps | |||
} from '../SecurityHotspotsAppRenderer'; | |||
@@ -49,13 +50,27 @@ it('should render correctly with hotspots', () => { | |||
).toMatchSnapshot(); | |||
}); | |||
it('should properly propagate the "show all" call', () => { | |||
const onShowAllHotspots = jest.fn(); | |||
const wrapper = shallowRender({ onShowAllHotspots }); | |||
wrapper | |||
.find(FilterBar) | |||
.props() | |||
.onShowAllHotspots(); | |||
expect(onShowAllHotspots).toHaveBeenCalled(); | |||
}); | |||
function shallowRender(props: Partial<SecurityHotspotsAppRendererProps> = {}) { | |||
return shallow( | |||
<SecurityHotspotsAppRenderer | |||
hotspots={[]} | |||
isStaticListOfHotspots={true} | |||
loading={false} | |||
onChangeFilters={jest.fn()} | |||
onHotspotClick={jest.fn()} | |||
onShowAllHotspots={jest.fn()} | |||
onUpdateHotspot={jest.fn()} | |||
securityCategories={{}} | |||
filters={{ assignedToMe: false, status: HotspotStatusFilter.TO_REVIEW }} |
@@ -17,9 +17,11 @@ exports[`should render correctly 1`] = ` | |||
} | |||
} | |||
hotspots={Array []} | |||
isStaticListOfHotspots={false} | |||
loading={true} | |||
onChangeFilters={[Function]} | |||
onHotspotClick={[Function]} | |||
onShowAllHotspots={[Function]} | |||
onUpdateHotspot={[Function]} | |||
securityCategories={Object {}} | |||
/> |
@@ -11,7 +11,9 @@ exports[`should render correctly 1`] = ` | |||
"status": "TO_REVIEW", | |||
} | |||
} | |||
isStaticListOfHotspots={true} | |||
onChangeFilters={[MockFunction]} | |||
onShowAllHotspots={[MockFunction]} | |||
/> | |||
<ScreenPositionHelper> | |||
<Component /> | |||
@@ -150,6 +152,7 @@ exports[`should render correctly with hotspots 1`] = ` | |||
}, | |||
] | |||
} | |||
isStaticListOfHotspots={true} | |||
onHotspotClick={[MockFunction]} | |||
securityCategories={Object {}} | |||
statusFilter="TO_REVIEW" | |||
@@ -231,6 +234,7 @@ exports[`should render correctly with hotspots 2`] = ` | |||
}, | |||
] | |||
} | |||
isStaticListOfHotspots={true} | |||
onHotspotClick={[MockFunction]} | |||
securityCategories={Object {}} | |||
selectedHotspotKey="h2" |
@@ -28,7 +28,9 @@ import { HotspotFilters, HotspotStatusFilter } from '../../../types/security-hot | |||
export interface FilterBarProps { | |||
currentUser: T.CurrentUser; | |||
filters: HotspotFilters; | |||
isStaticListOfHotspots: boolean; | |||
onChangeFilters: (filters: Partial<HotspotFilters>) => void; | |||
onShowAllHotspots: () => void; | |||
} | |||
const statusOptions: Array<{ label: string; value: string }> = [ | |||
@@ -48,34 +50,43 @@ const assigneeFilterOptions = [ | |||
]; | |||
export function FilterBar(props: FilterBarProps) { | |||
const { currentUser, filters } = props; | |||
const { currentUser, filters, isStaticListOfHotspots } = props; | |||
return ( | |||
<div className="filter-bar display-flex-center"> | |||
<h3 className="huge-spacer-right">{translate('hotspot.filters.title')}</h3> | |||
{isStaticListOfHotspots ? ( | |||
<a id="show_all_hotspot" onClick={() => props.onShowAllHotspots()} role="link" tabIndex={0}> | |||
{translate('hotspot.filters.show_all')} | |||
</a> | |||
) : ( | |||
<> | |||
<h3 className="huge-spacer-right">{translate('hotspot.filters.title')}</h3> | |||
{isLoggedIn(currentUser) && ( | |||
<RadioToggle | |||
className="huge-spacer-right" | |||
name="assignee-filter" | |||
onCheck={(value: AssigneeFilterOption) => | |||
props.onChangeFilters({ assignedToMe: value === AssigneeFilterOption.ME }) | |||
} | |||
options={assigneeFilterOptions} | |||
value={filters.assignedToMe ? AssigneeFilterOption.ME : AssigneeFilterOption.ALL} | |||
/> | |||
)} | |||
{isLoggedIn(currentUser) && ( | |||
<RadioToggle | |||
className="huge-spacer-right" | |||
name="assignee-filter" | |||
onCheck={(value: AssigneeFilterOption) => | |||
props.onChangeFilters({ assignedToMe: value === AssigneeFilterOption.ME }) | |||
} | |||
options={assigneeFilterOptions} | |||
value={filters.assignedToMe ? AssigneeFilterOption.ME : AssigneeFilterOption.ALL} | |||
/> | |||
)} | |||
<span className="spacer-right">{translate('status')}</span> | |||
<Select | |||
className="input-medium big-spacer-right" | |||
clearable={false} | |||
onChange={(option: { value: HotspotStatusFilter }) => | |||
props.onChangeFilters({ status: option.value }) | |||
} | |||
options={statusOptions} | |||
searchable={false} | |||
value={filters.status} | |||
/> | |||
<span className="spacer-right">{translate('status')}</span> | |||
<Select | |||
className="input-medium big-spacer-right" | |||
clearable={false} | |||
onChange={(option: { value: HotspotStatusFilter }) => | |||
props.onChangeFilters({ status: option.value }) | |||
} | |||
options={statusOptions} | |||
searchable={false} | |||
value={filters.status} | |||
/> | |||
</> | |||
)} | |||
</div> | |||
); | |||
} |
@@ -29,6 +29,7 @@ import './HotspotList.css'; | |||
export interface HotspotListProps { | |||
hotspots: RawHotspot[]; | |||
isStaticListOfHotspots: boolean; | |||
onHotspotClick: (key: string) => void; | |||
securityCategories: T.StandardSecurityCategories; | |||
selectedHotspotKey: string | undefined; | |||
@@ -36,7 +37,13 @@ export interface HotspotListProps { | |||
} | |||
export default function HotspotList(props: HotspotListProps) { | |||
const { hotspots, securityCategories, selectedHotspotKey, statusFilter } = props; | |||
const { | |||
hotspots, | |||
isStaticListOfHotspots, | |||
securityCategories, | |||
selectedHotspotKey, | |||
statusFilter | |||
} = props; | |||
const groupedHotspots: Array<{ | |||
risk: RiskExposure; | |||
@@ -54,7 +61,10 @@ export default function HotspotList(props: HotspotListProps) { | |||
<> | |||
<h1 className="hotspot-list-header bordered-bottom"> | |||
<SecurityHotspotIcon className="spacer-right" /> | |||
{translateWithParameters(`hotspots.list_title.${statusFilter}`, hotspots.length)} | |||
{translateWithParameters( | |||
isStaticListOfHotspots ? 'hotspots.list_title' : `hotspots.list_title.${statusFilter}`, | |||
hotspots.length | |||
)} | |||
</h1> | |||
<ul className="huge-spacer-bottom"> | |||
{groupedHotspots.map(riskGroup => ( |
@@ -30,6 +30,19 @@ it('should render correctly', () => { | |||
expect(shallowRender({ currentUser: mockLoggedInUser() })).toMatchSnapshot('logged-in'); | |||
}); | |||
it('should render correctly when the list of hotspot is static', () => { | |||
const onShowAllHotspots = jest.fn(); | |||
const wrapper = shallowRender({ | |||
isStaticListOfHotspots: true, | |||
onShowAllHotspots | |||
}); | |||
expect(wrapper).toMatchSnapshot(); | |||
wrapper.find('a').simulate('click'); | |||
expect(onShowAllHotspots).toHaveBeenCalled(); | |||
}); | |||
it('should trigger onChange for status', () => { | |||
const onChangeFilters = jest.fn(); | |||
const wrapper = shallowRender({ onChangeFilters }); | |||
@@ -60,7 +73,9 @@ function shallowRender(props: Partial<FilterBarProps> = {}) { | |||
return shallow( | |||
<FilterBar | |||
currentUser={mockCurrentUser()} | |||
isStaticListOfHotspots={false} | |||
onChangeFilters={jest.fn()} | |||
onShowAllHotspots={jest.fn()} | |||
filters={{ assignedToMe: false, status: HotspotStatusFilter.TO_REVIEW }} | |||
{...props} | |||
/> |
@@ -27,6 +27,10 @@ it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
it('should render correctly when the list of hotspot is static', () => { | |||
expect(shallowRender({ isStaticListOfHotspots: true })).toMatchSnapshot(); | |||
}); | |||
it('should render correctly with hotspots', () => { | |||
const hotspots = [ | |||
mockRawHotspot({ key: 'h1', securityCategory: 'cat2' }), | |||
@@ -54,6 +58,7 @@ function shallowRender(props: Partial<HotspotListProps> = {}) { | |||
return shallow( | |||
<HotspotList | |||
hotspots={[]} | |||
isStaticListOfHotspots={false} | |||
onHotspotClick={jest.fn()} | |||
securityCategories={{}} | |||
selectedHotspotKey="h2" |
@@ -1,5 +1,20 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly when the list of hotspot is static 1`] = ` | |||
<div | |||
className="filter-bar display-flex-center" | |||
> | |||
<a | |||
id="show_all_hotspot" | |||
onClick={[Function]} | |||
role="link" | |||
tabIndex={0} | |||
> | |||
hotspot.filters.show_all | |||
</a> | |||
</div> | |||
`; | |||
exports[`should render correctly: anonymous 1`] = ` | |||
<div | |||
className="filter-bar display-flex-center" |
@@ -16,6 +16,22 @@ exports[`should render correctly 1`] = ` | |||
</Fragment> | |||
`; | |||
exports[`should render correctly when the list of hotspot is static 1`] = ` | |||
<Fragment> | |||
<h1 | |||
className="hotspot-list-header bordered-bottom" | |||
> | |||
<SecurityHotspotIcon | |||
className="spacer-right" | |||
/> | |||
hotspots.list_title.0 | |||
</h1> | |||
<ul | |||
className="huge-spacer-bottom" | |||
/> | |||
</Fragment> | |||
`; | |||
exports[`should render correctly with hotspots 1`] = ` | |||
<Fragment> | |||
<h1 |
@@ -648,6 +648,7 @@ hotspots.page=Security Hotspots | |||
hotspots.no_hotspots.title=There are no Security Hotspots to review | |||
hotspots.no_hotspots.description=Next time you analyse a piece of code that contains a potential security risk, it will show up here. | |||
hotspots.learn_more=Learn more about Security Hotspots | |||
hotspots.list_title={0} Security Hotspots | |||
hotspots.list_title.TO_REVIEW={0} Security Hotspots to review | |||
hotspots.list_title.FIXED={0} Security Hotspots reviewed as fixed | |||
hotspots.list_title.SAFE={0} Security Hotspots reviewed as safe | |||
@@ -676,6 +677,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 | |||
hotspot.filters.show_all=Show all hotspots | |||
hotspots.review_hotspot=Review Hotspot | |||
hotspots.form.title=Mark Security Hotspot as: |