Browse Source

SONAR-12797 Security Hotspots page allows to filter by hotspots keys param

tags/8.2.0.32929
Philippe Perrin 4 years ago
parent
commit
2a80a0dc10

+ 4
- 0
server/sonar-web/src/main/js/api/security-hotspots.ts View File

@@ -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[] }) => {

+ 38
- 4
server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsApp.tsx View File

@@ -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}

+ 18
- 2
server/sonar-web/src/main/js/apps/securityHotspots/SecurityHotspotsAppRenderer.tsx View File

@@ -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}

+ 61
- 3
server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsApp-test.tsx View File

@@ -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}
/>
);

+ 15
- 0
server/sonar-web/src/main/js/apps/securityHotspots/__tests__/SecurityHotspotsAppRenderer-test.tsx View File

@@ -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 }}

+ 2
- 0
server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsApp-test.tsx.snap View File

@@ -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 {}}
/>

+ 4
- 0
server/sonar-web/src/main/js/apps/securityHotspots/__tests__/__snapshots__/SecurityHotspotsAppRenderer-test.tsx.snap View File

@@ -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"

+ 35
- 24
server/sonar-web/src/main/js/apps/securityHotspots/components/FilterBar.tsx View File

@@ -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>
);
}

+ 12
- 2
server/sonar-web/src/main/js/apps/securityHotspots/components/HotspotList.tsx View File

@@ -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 => (

+ 15
- 0
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/FilterBar-test.tsx View File

@@ -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}
/>

+ 5
- 0
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/HotspotList-test.tsx View File

@@ -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"

+ 15
- 0
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/FilterBar-test.tsx.snap View File

@@ -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
- 0
server/sonar-web/src/main/js/apps/securityHotspots/components/__tests__/__snapshots__/HotspotList-test.tsx.snap View File

@@ -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

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -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:

Loading…
Cancel
Save