@cccsaurora/howler-ui 2.18.0-dev.705 → 2.18.0-dev.716
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/search/facet/hit.d.ts +1 -3
- package/api/search/facet/index.d.ts +3 -1
- package/components/app/App.js +5 -0
- package/components/elements/hit/HitCard.d.ts +1 -0
- package/components/elements/hit/HitCard.js +2 -2
- package/components/elements/observable/ObservableCard.js +1 -2
- package/components/routes/cases/CaseViewer.js +5 -5
- package/components/routes/cases/detail/CaseAssets.d.ts +2 -3
- package/components/routes/cases/detail/CaseAssets.js +2 -2
- package/components/routes/cases/detail/CaseSidebar.js +28 -39
- package/components/routes/cases/detail/CaseTimeline.d.ts +12 -0
- package/components/routes/cases/detail/CaseTimeline.js +106 -0
- package/components/routes/cases/detail/CaseTimeline.test.d.ts +1 -0
- package/components/routes/cases/detail/CaseTimeline.test.js +227 -0
- package/components/routes/cases/detail/sidebar/CaseFolder.js +8 -5
- package/components/routes/cases/modals/ResolveModal.js +75 -22
- package/components/routes/cases/modals/ResolveModal.test.d.ts +1 -0
- package/components/routes/cases/modals/ResolveModal.test.js +384 -0
- package/locales/en/translation.json +8 -0
- package/locales/fr/translation.json +8 -0
- package/package.json +1 -1
|
@@ -1,5 +1,3 @@
|
|
|
1
1
|
import type { HowlerFacetSearchRequest, HowlerFacetSearchResponse } from '@cccsaurora/howler-ui/api/search/facet';
|
|
2
2
|
export declare const uri: () => string;
|
|
3
|
-
export declare const post: (request?: HowlerFacetSearchRequest) => Promise<
|
|
4
|
-
[index: string]: HowlerFacetSearchResponse;
|
|
5
|
-
}>;
|
|
3
|
+
export declare const post: (request?: HowlerFacetSearchRequest) => Promise<HowlerFacetSearchResponse>;
|
package/components/app/App.js
CHANGED
|
@@ -29,6 +29,7 @@ import CaseViewer from '@cccsaurora/howler-ui/components/routes/cases/CaseViewer
|
|
|
29
29
|
import Cases from '@cccsaurora/howler-ui/components/routes/cases/Cases';
|
|
30
30
|
import CaseAssets from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseAssets';
|
|
31
31
|
import CaseDashboard from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseDashboard';
|
|
32
|
+
import CaseTimeline from '@cccsaurora/howler-ui/components/routes/cases/detail/CaseTimeline';
|
|
32
33
|
import ItemPage from '@cccsaurora/howler-ui/components/routes/cases/detail/ItemPage';
|
|
33
34
|
import DossierEditor from '@cccsaurora/howler-ui/components/routes/dossiers/DossierEditor';
|
|
34
35
|
import Dossiers from '@cccsaurora/howler-ui/components/routes/dossiers/Dossiers';
|
|
@@ -207,6 +208,10 @@ const router = createBrowserRouter([
|
|
|
207
208
|
path: 'assets',
|
|
208
209
|
element: _jsx(CaseAssets, {})
|
|
209
210
|
},
|
|
211
|
+
{
|
|
212
|
+
path: 'timeline',
|
|
213
|
+
element: _jsx(CaseTimeline, {})
|
|
214
|
+
},
|
|
210
215
|
{
|
|
211
216
|
path: '*',
|
|
212
217
|
element: _jsx(ItemPage, {})
|
|
@@ -7,7 +7,7 @@ import HowlerCard from '../display/HowlerCard';
|
|
|
7
7
|
import HitBanner from './HitBanner';
|
|
8
8
|
import HitLabels from './HitLabels';
|
|
9
9
|
import HitOutline from './HitOutline';
|
|
10
|
-
const HitCard = ({ id, layout, readOnly = true }) => {
|
|
10
|
+
const HitCard = ({ id, layout, readOnly = true, elevation }) => {
|
|
11
11
|
const getRecord = useContextSelector(RecordContext, ctx => ctx.getRecord);
|
|
12
12
|
const hit = useContextSelector(RecordContext, ctx => ctx.records[id]);
|
|
13
13
|
useEffect(() => {
|
|
@@ -19,6 +19,6 @@ const HitCard = ({ id, layout, readOnly = true }) => {
|
|
|
19
19
|
if (!hit) {
|
|
20
20
|
return _jsx(Skeleton, { variant: "rounded", height: "200px" });
|
|
21
21
|
}
|
|
22
|
-
return (_jsx(HowlerCard, { id: hit?.howler.id, tabIndex: 0, sx: { position: 'relative' }, children: _jsxs(CardContent, { children: [_jsx(HitBanner, { hit: hit, layout: layout }), _jsx(HitOutline, { hit: hit, layout: layout }), _jsx(HitLabels, { hit: hit, readOnly: readOnly })] }) }));
|
|
22
|
+
return (_jsx(HowlerCard, { id: hit?.howler.id, tabIndex: 0, sx: { position: 'relative' }, elevation: elevation, children: _jsxs(CardContent, { children: [_jsx(HitBanner, { hit: hit, layout: layout }), _jsx(HitOutline, { hit: hit, layout: layout }), _jsx(HitLabels, { hit: hit, readOnly: readOnly })] }) }));
|
|
23
23
|
};
|
|
24
24
|
export default memo(HitCard);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { CardContent, Skeleton } from '@mui/material';
|
|
3
|
-
import { hit } from '@cccsaurora/howler-ui/api/search';
|
|
4
3
|
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
5
4
|
import HowlerCard from '@cccsaurora/howler-ui/components/elements/display/HowlerCard';
|
|
6
5
|
import { memo, useEffect } from 'react';
|
|
@@ -15,7 +14,7 @@ const ObservableCard = ({ id, observable: _observable }) => {
|
|
|
15
14
|
}
|
|
16
15
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
17
16
|
}, [id]);
|
|
18
|
-
if (!
|
|
17
|
+
if (!observable) {
|
|
19
18
|
return _jsx(Skeleton, { variant: "rounded", height: "200px" });
|
|
20
19
|
}
|
|
21
20
|
return (_jsx(HowlerCard, { sx: { position: 'relative' }, children: _jsx(CardContent, { children: _jsx(ObservablePreview, { observable: observable }) }) }));
|
|
@@ -13,10 +13,10 @@ const CaseViewer = () => {
|
|
|
13
13
|
if (missing) {
|
|
14
14
|
return _jsx(NotFoundPage, {});
|
|
15
15
|
}
|
|
16
|
-
return (_jsxs(Stack, { direction: "row", height: "100%", children: [_jsx(CaseSidebar, { case: _case, update: updatedCase => update(updatedCase, false) }), _jsx(Box, { sx: {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
return (_jsx(ErrorBoundary, { children: _jsxs(Stack, { direction: "row", height: "100%", children: [_jsx(CaseSidebar, { case: _case, update: updatedCase => update(updatedCase, false) }), _jsx(Box, { sx: {
|
|
17
|
+
maxHeight: 'calc(100vh - 64px)',
|
|
18
|
+
flex: 1,
|
|
19
|
+
overflow: 'auto'
|
|
20
|
+
}, children: _jsx(ErrorBoundary, { children: _jsx(Outlet, { context: _case }) }) }), _jsx(CaseDetails, { case: _case })] }) }));
|
|
21
21
|
};
|
|
22
22
|
export default memo(CaseViewer);
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
2
|
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
3
3
|
import type { Observable } from '@cccsaurora/howler-ui/models/entities/generated/Observable';
|
|
4
|
-
import { type FC } from 'react';
|
|
5
4
|
import { type AssetEntry } from './assets/Asset';
|
|
6
5
|
/** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
|
|
7
6
|
export declare const buildAssetEntries: (records: Partial<Hit | Observable>[]) => AssetEntry[];
|
|
8
|
-
declare const
|
|
7
|
+
declare const _default: import("react").NamedExoticComponent<{
|
|
9
8
|
case?: Case;
|
|
10
9
|
caseId?: string;
|
|
11
10
|
}>;
|
|
12
|
-
export default
|
|
11
|
+
export default _default;
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { Chip, Grid, Skeleton, Stack, Typography } from '@mui/material';
|
|
3
3
|
import api from '@cccsaurora/howler-ui/api';
|
|
4
4
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
5
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
5
|
+
import { memo, useEffect, useMemo, useState } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { useOutletContext } from 'react-router-dom';
|
|
8
8
|
import useCase from '../hooks/useCase';
|
|
@@ -101,4 +101,4 @@ const CaseAssets = ({ case: providedCase, caseId }) => {
|
|
|
101
101
|
}
|
|
102
102
|
return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, flexWrap: "wrap", children: [_jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: t('page.cases.assets.filter_by_type') }), records === null ? (_jsx(Skeleton, { width: 240, height: 32 })) : (assetTypes.map(type => (_jsx(Chip, { label: t(`page.cases.assets.type.${type}`), size: "small", onClick: () => toggleFilter(type), color: activeFilters.has(type) ? 'primary' : 'default', variant: activeFilters.has(type) ? 'filled' : 'outlined' }, type))))] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, sm: 6, md: 4, xl: 3, children: _jsx(Skeleton, { height: 100 }) }, `skeleton-${i}`)))) : filteredAssets.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.assets.empty') }) })) : (filteredAssets.map(asset => (_jsx(Grid, { item: true, xs: 12, md: 6, xl: 4, children: _jsx(Asset, { asset: asset, case: _case }) }, `${asset.type}:${asset.value}`))))] }));
|
|
103
103
|
};
|
|
104
|
-
export default CaseAssets;
|
|
104
|
+
export default memo(CaseAssets);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Circle, Dashboard, Dataset } from '@mui/icons-material';
|
|
2
|
+
import { CalendarMonth, Circle, Dashboard, Dataset } from '@mui/icons-material';
|
|
3
3
|
import { alpha, Box, Card, Chip, Divider, Skeleton, Stack, Typography, useTheme } from '@mui/material';
|
|
4
4
|
import dayjs from 'dayjs';
|
|
5
|
-
import {} from 'react';
|
|
5
|
+
import { useCallback } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { Link, useLocation } from 'react-router-dom';
|
|
8
8
|
import { ESCALATION_COLOR_MAP } from '../constants';
|
|
@@ -11,49 +11,38 @@ const CaseSidebar = ({ case: _case, update }) => {
|
|
|
11
11
|
const { t } = useTranslation();
|
|
12
12
|
const location = useLocation();
|
|
13
13
|
const theme = useTheme();
|
|
14
|
+
const navItemSx = useCallback((isActive) => [
|
|
15
|
+
{
|
|
16
|
+
cursor: 'pointer',
|
|
17
|
+
px: 1,
|
|
18
|
+
py: 1,
|
|
19
|
+
transition: theme.transitions.create('background', { duration: 100 }),
|
|
20
|
+
color: `${theme.palette.text.primary} !important`,
|
|
21
|
+
textDecoration: 'none',
|
|
22
|
+
background: 'transparent',
|
|
23
|
+
borderRight: `thin solid ${theme.palette.divider}`,
|
|
24
|
+
'&:hover': {
|
|
25
|
+
background: alpha(theme.palette.grey[600], 0.25)
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
isActive && {
|
|
29
|
+
background: alpha(theme.palette.grey[600], 0.15),
|
|
30
|
+
borderRight: `3px solid ${theme.palette.primary.main}`
|
|
31
|
+
}
|
|
32
|
+
], [
|
|
33
|
+
theme.palette.divider,
|
|
34
|
+
theme.palette.grey,
|
|
35
|
+
theme.palette.primary.main,
|
|
36
|
+
theme.palette.text.primary,
|
|
37
|
+
theme.transitions
|
|
38
|
+
]);
|
|
14
39
|
return (_jsxs(Box, { sx: {
|
|
15
40
|
flex: 1,
|
|
16
41
|
maxWidth: '350px',
|
|
17
42
|
maxHeight: 'calc(100vh - 64px)',
|
|
18
43
|
display: 'flex',
|
|
19
44
|
flexDirection: 'column'
|
|
20
|
-
}, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: [
|
|
21
|
-
{
|
|
22
|
-
cursor: 'pointer',
|
|
23
|
-
px: 1,
|
|
24
|
-
py: 1,
|
|
25
|
-
transition: theme.transitions.create('background', { duration: 100 }),
|
|
26
|
-
color: `${theme.palette.text.primary} !important`,
|
|
27
|
-
textDecoration: 'none',
|
|
28
|
-
background: 'transparent',
|
|
29
|
-
borderRight: `thin solid ${theme.palette.divider}`,
|
|
30
|
-
'&:hover': {
|
|
31
|
-
background: alpha(theme.palette.grey[600], 0.25)
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
location.pathname === `/cases/${_case?.case_id}` && {
|
|
35
|
-
background: alpha(theme.palette.grey[600], 0.15),
|
|
36
|
-
borderRight: `3px solid ${theme.palette.primary.main}`
|
|
37
|
-
}
|
|
38
|
-
], component: Link, to: `/cases/${_case?.case_id}`, children: [_jsx(Dashboard, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: [
|
|
39
|
-
{
|
|
40
|
-
cursor: 'pointer',
|
|
41
|
-
px: 1,
|
|
42
|
-
py: 1,
|
|
43
|
-
transition: theme.transitions.create('background', { duration: 100 }),
|
|
44
|
-
color: `${theme.palette.text.primary} !important`,
|
|
45
|
-
textDecoration: 'none',
|
|
46
|
-
background: 'transparent',
|
|
47
|
-
borderRight: `thin solid ${theme.palette.divider}`,
|
|
48
|
-
'&:hover': {
|
|
49
|
-
background: alpha(theme.palette.grey[600], 0.25)
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
location.pathname === `/cases/${_case?.case_id}/assets` && {
|
|
53
|
-
background: alpha(theme.palette.grey[600], 0.15),
|
|
54
|
-
borderRight: `3px solid ${theme.palette.primary.main}`
|
|
55
|
-
}
|
|
56
|
-
], component: Link, to: `/cases/${_case?.case_id}/assets`, children: [_jsx(Dataset, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.assets') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
|
|
45
|
+
}, children: [_jsxs(Card, { sx: { borderRadius: 0, px: 2, py: 1 }, children: [_case?.title ? _jsx(Typography, { variant: "body1", children: _case.title }) : _jsx(Skeleton, { height: 24 }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", divider: _jsx(Circle, { color: "disabled", sx: { fontSize: '8px' } }), children: [_jsxs(Typography, { variant: "caption", color: "textSecondary", children: [t('started'), ": ", _case?.created ? dayjs(_case.created).toString() : _jsx(Skeleton, { height: 14 })] }), _case?.escalation ? (_jsx(Chip, { color: ESCALATION_COLOR_MAP[_case.escalation], label: t(_case.escalation) })) : (_jsx(Skeleton, { height: 24 }))] })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname === `/cases/${_case?.case_id}`), component: Link, to: `/cases/${_case?.case_id}`, children: [_jsx(Dashboard, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.dashboard') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname === `/cases/${_case?.case_id}/assets`), component: Link, to: `/cases/${_case?.case_id}/assets`, children: [_jsx(Dataset, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.assets') })] }), _jsxs(Stack, { direction: "row", alignItems: "center", sx: navItemSx(location.pathname === `/cases/${_case?.case_id}/timeline`), component: Link, to: `/cases/${_case?.case_id}/timeline`, children: [_jsx(CalendarMonth, {}), _jsx(Typography, { sx: { userSelect: 'none', pl: 0.5, textWrap: 'nowrap' }, children: t('page.cases.timeline') })] }), _jsx(Divider, {}), _case && (_jsx(Box, { flex: 1, overflow: "auto", width: "100%", sx: {
|
|
57
46
|
position: 'relative',
|
|
58
47
|
borderRight: `thin solid ${theme.palette.divider}`
|
|
59
48
|
}, children: _jsx(Box, { position: "absolute", sx: { left: 0, right: 0 }, children: _jsx(CaseFolder, { case: _case, onItemUpdated: update }) }) }))] }));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
|
|
2
|
+
interface MitreOption {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
kind: 'tactic' | 'technique';
|
|
6
|
+
}
|
|
7
|
+
export declare const buildFilters: (mitre: MitreOption[], escalations: string[]) => string[];
|
|
8
|
+
declare const _default: import("react").NamedExoticComponent<{
|
|
9
|
+
case?: Case;
|
|
10
|
+
caseId?: string;
|
|
11
|
+
}>;
|
|
12
|
+
export default _default;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createElement as _createElement } from "react";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { FilterList } from '@mui/icons-material';
|
|
4
|
+
import { Autocomplete, Box, Chip, Divider, Skeleton, Stack, TextField, Tooltip, Typography } from '@mui/material';
|
|
5
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
6
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
7
|
+
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
8
|
+
import HitCard from '@cccsaurora/howler-ui/components/elements/hit/HitCard';
|
|
9
|
+
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
10
|
+
import ObservableCard from '@cccsaurora/howler-ui/components/elements/observable/ObservableCard';
|
|
11
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
12
|
+
import dayjs from 'dayjs';
|
|
13
|
+
import { capitalize } from 'lodash-es';
|
|
14
|
+
import { memo, useContext, useEffect, useMemo, useState } from 'react';
|
|
15
|
+
import { useTranslation } from 'react-i18next';
|
|
16
|
+
import { Link, useOutletContext } from 'react-router-dom';
|
|
17
|
+
import { useContextSelector } from 'use-context-selector';
|
|
18
|
+
import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
|
|
19
|
+
import { isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
|
|
20
|
+
import useCase from '../hooks/useCase';
|
|
21
|
+
// builds the additional filters for the lucene query
|
|
22
|
+
export const buildFilters = (mitre, escalations) => {
|
|
23
|
+
const filters = [];
|
|
24
|
+
const tacticIds = mitre.filter(o => o.kind === 'tactic').map(o => o.id);
|
|
25
|
+
const techniqueIds = mitre.filter(o => o.kind === 'technique').map(o => o.id);
|
|
26
|
+
if (tacticIds.length) {
|
|
27
|
+
filters.push(`threat.tactic.id:(${tacticIds.join(' OR ')})`);
|
|
28
|
+
}
|
|
29
|
+
if (techniqueIds.length) {
|
|
30
|
+
filters.push(`threat.technique.id:(${techniqueIds.join(' OR ')})`);
|
|
31
|
+
}
|
|
32
|
+
if (escalations.length) {
|
|
33
|
+
filters.push(`howler.escalation:(${escalations.join(' OR ')})`);
|
|
34
|
+
}
|
|
35
|
+
return filters;
|
|
36
|
+
};
|
|
37
|
+
const CaseTimeline = ({ case: providedCase, caseId }) => {
|
|
38
|
+
const { t } = useTranslation();
|
|
39
|
+
const { dispatchApi } = useMyApi();
|
|
40
|
+
const { config } = useContext(ApiConfigContext);
|
|
41
|
+
const routeCase = useOutletContext();
|
|
42
|
+
const { case: _case } = useCase({ case: providedCase ?? routeCase, caseId });
|
|
43
|
+
const loadRecords = useContextSelector(RecordContext, ctx => ctx.loadRecords);
|
|
44
|
+
const [mitreOptions, setMitreOptions] = useState([]);
|
|
45
|
+
const [escalationOptions, setEscalationOptions] = useState([]);
|
|
46
|
+
const [displayedEntries, setDisplayedEntries] = useState([]);
|
|
47
|
+
const [loading, setLoading] = useState(false);
|
|
48
|
+
const [selectedMitres, setSelectedMitres] = useState([]);
|
|
49
|
+
const [selectedEscalations, setSelectedEscalations] = useState(['evidence']);
|
|
50
|
+
const ids = useMemo(() => (_case?.items ?? [])
|
|
51
|
+
.filter(item => ['hit', 'observable'].includes(item.type))
|
|
52
|
+
.map(item => item.value)
|
|
53
|
+
.filter(Boolean), [_case]);
|
|
54
|
+
const getPath = (value) => _case.items.find(item => item.value === value)?.path;
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (ids.length < 1) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
dispatchApi(api.v2.search.facet.post(['hit', 'observable'], {
|
|
60
|
+
fields: ['threat.tactic.id', 'threat.technique.id', 'howler.escalation'],
|
|
61
|
+
filters: [`howler.id:(${ids.join(' OR ')})`]
|
|
62
|
+
}), { throwError: false }).then(result => {
|
|
63
|
+
if (!result) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setEscalationOptions(Object.keys(result['howler.escalation'] ?? {}));
|
|
67
|
+
const tactics = Object.keys(result['threat.tactic.id'] ?? {}).map(tactic => ({
|
|
68
|
+
id: tactic,
|
|
69
|
+
name: config.lookups?.tactics?.[tactic].name ?? tactic,
|
|
70
|
+
kind: 'tactic'
|
|
71
|
+
}));
|
|
72
|
+
const techniques = Object.keys(result['threat.technique.id'] ?? {}).map(technique => ({
|
|
73
|
+
id: technique,
|
|
74
|
+
name: config.lookups?.techniques?.[technique].name ?? technique,
|
|
75
|
+
kind: 'technique'
|
|
76
|
+
}));
|
|
77
|
+
setMitreOptions([...tactics, ...techniques]);
|
|
78
|
+
});
|
|
79
|
+
}, [config.lookups?.tactics, config.lookups?.techniques, dispatchApi, ids]);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!ids.length) {
|
|
82
|
+
setDisplayedEntries([]);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setLoading(true);
|
|
86
|
+
const filters = buildFilters(selectedMitres, selectedEscalations);
|
|
87
|
+
dispatchApi(api.v2.search.post(['hit', 'observable'], {
|
|
88
|
+
query: `howler.id:(${ids.join(' OR ')})`,
|
|
89
|
+
sort: 'event.created asc',
|
|
90
|
+
rows: ids.length,
|
|
91
|
+
filters
|
|
92
|
+
}), { throwError: false }).then(response => {
|
|
93
|
+
setLoading(false);
|
|
94
|
+
if (!response) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
loadRecords(response.items);
|
|
98
|
+
setDisplayedEntries(response.items);
|
|
99
|
+
});
|
|
100
|
+
}, [ids, selectedMitres, selectedEscalations, dispatchApi, loadRecords]);
|
|
101
|
+
if (!_case) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return (_jsxs(Stack, { spacing: 0, sx: { height: '100%' }, children: [_jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", flexWrap: "wrap", sx: { p: 1, gap: 1 }, children: [_jsx(Tooltip, { title: t('page.cases.timeline.filter.label'), children: _jsx(FilterList, { fontSize: "small", color: "action" }) }), _jsx(Autocomplete, { multiple: true, size: "small", options: mitreOptions, value: selectedMitres, onChange: (_e, values) => setSelectedMitres(values), getOptionLabel: opt => `${opt.id} - ${opt.name}`, isOptionEqualToValue: (opt, val) => opt.id === val.id, groupBy: opt => capitalize(opt.kind), renderTags: (value, getTagProps) => value.map((opt, index) => (_createElement(Chip, { ...getTagProps({ index }), key: opt.id, size: "small", label: opt.id, color: "primary" }))), renderInput: params => (_jsx(TextField, { ...params, label: t('page.cases.timeline.filter.mitre'), sx: { minWidth: 260 } })), noOptionsText: t('page.cases.timeline.filter.mitre.empty') }), _jsx(Autocomplete, { multiple: true, size: "small", options: escalationOptions, value: selectedEscalations, onChange: (_e, value) => setSelectedEscalations(value), getOptionLabel: opt => t(`howler.escalation.${opt}`, opt), renderTags: (value, getTagProps) => value.map((opt, index) => (_createElement(Chip, { ...getTagProps({ index }), key: opt, size: "small", label: opt, color: ESCALATION_COLORS[opt] }))), renderInput: params => (_jsx(TextField, { ...params, label: t('page.cases.timeline.filter.escalation'), sx: { minWidth: 220 } })), noOptionsText: t('page.cases.timeline.filter.escalation.empty') })] }), _jsx(Divider, {}), loading ? (_jsx(Stack, { spacing: 2, sx: { px: 2, py: 1 }, children: [0, 1, 2].map(i => (_jsxs(Stack, { direction: "row", width: "100%", spacing: 1, children: [_jsx(Skeleton, { variant: "text", width: 120, height: 24 }), _jsx(Skeleton, { variant: "rounded", height: 120, sx: { flex: 1 } })] }, i))) })) : displayedEntries.length === 0 ? (_jsx(Box, { sx: { pt: 4, textAlign: 'center' }, children: _jsx(Typography, { color: "textSecondary", children: t('page.cases.timeline.empty') }) })) : (_jsx(Stack, { component: "ol", spacing: 0, sx: { px: 2, py: 1, listStyle: 'none', m: 0, overflow: 'auto' }, children: displayedEntries.map(entry => (_jsxs(Stack, { component: "li", spacing: 1, sx: { pb: 1 }, children: [_jsxs(Stack, { direction: "row", spacing: 2, alignItems: "flex-start", children: [_jsx(Typography, { variant: "caption", color: "textSecondary", sx: { whiteSpace: 'nowrap' }, children: dayjs(entry.event?.created ?? entry.timestamp).format('YYYY-MM-DD HH:mm:ss') }), _jsx(Box, { component: Link, to: `/cases/${_case.case_id}/${getPath(entry.howler.id)}`, sx: { flex: 1, minWidth: 0, textDecoration: 'none' }, children: isHit(entry) ? (_jsx(HitCard, { id: entry.howler.id, layout: HitLayout.DENSE, readOnly: true })) : (_jsx(ObservableCard, { id: entry.howler.id })) })] }), _jsx(Divider, { flexItem: true })] }, entry.howler.id))) }))] }));
|
|
105
|
+
};
|
|
106
|
+
export default memo(CaseTimeline);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/* eslint-disable react/no-children-prop */
|
|
3
|
+
/// <reference types="vitest" />
|
|
4
|
+
import { act, render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
7
|
+
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
8
|
+
import { createElement } from 'react';
|
|
9
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
10
|
+
import { createMockHit, createMockObservable } from '@cccsaurora/howler-ui/tests/utils';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { buildFilters } from './CaseTimeline';
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Pure logic tests
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
describe('buildFilters', () => {
|
|
17
|
+
it('returns an empty array when both lists are empty', () => {
|
|
18
|
+
expect(buildFilters([], [])).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
it('builds a tactic filter', () => {
|
|
21
|
+
const result = buildFilters([{ id: 'TA0001', name: 'Initial Access', kind: 'tactic' }], []);
|
|
22
|
+
expect(result).toEqual(['threat.tactic.id:(TA0001)']);
|
|
23
|
+
});
|
|
24
|
+
it('builds a technique filter', () => {
|
|
25
|
+
const result = buildFilters([{ id: 'T1059', name: 'Command Scripting', kind: 'technique' }], []);
|
|
26
|
+
expect(result).toEqual(['threat.technique.id:(T1059)']);
|
|
27
|
+
});
|
|
28
|
+
it('OR-combines multiple tactics in one clause', () => {
|
|
29
|
+
const result = buildFilters([
|
|
30
|
+
{ id: 'TA0001', name: 'Initial Access', kind: 'tactic' },
|
|
31
|
+
{ id: 'TA0002', name: 'Execution', kind: 'tactic' }
|
|
32
|
+
], []);
|
|
33
|
+
expect(result).toEqual(['threat.tactic.id:(TA0001 OR TA0002)']);
|
|
34
|
+
});
|
|
35
|
+
it('emits separate clauses for tactics and techniques', () => {
|
|
36
|
+
const result = buildFilters([
|
|
37
|
+
{ id: 'TA0001', name: 'Initial Access', kind: 'tactic' },
|
|
38
|
+
{ id: 'T1059', name: 'Command Scripting', kind: 'technique' }
|
|
39
|
+
], []);
|
|
40
|
+
expect(result).toContain('threat.tactic.id:(TA0001)');
|
|
41
|
+
expect(result).toContain('threat.technique.id:(T1059)');
|
|
42
|
+
expect(result).toHaveLength(2);
|
|
43
|
+
});
|
|
44
|
+
it('builds an escalation filter', () => {
|
|
45
|
+
const result = buildFilters([], ['evidence', 'hit']);
|
|
46
|
+
expect(result).toEqual(['howler.escalation:(evidence OR hit)']);
|
|
47
|
+
});
|
|
48
|
+
it('combines mitre and escalation filters', () => {
|
|
49
|
+
const result = buildFilters([{ id: 'TA0001', name: 'Initial Access', kind: 'tactic' }], ['evidence']);
|
|
50
|
+
expect(result).toHaveLength(2);
|
|
51
|
+
expect(result).toContain('threat.tactic.id:(TA0001)');
|
|
52
|
+
expect(result).toContain('howler.escalation:(evidence)');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Component rendering tests
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
const mockDispatchApi = vi.fn();
|
|
59
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
60
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
61
|
+
}));
|
|
62
|
+
vi.mock('../hooks/useCase', () => ({
|
|
63
|
+
default: ({ case: c }) => ({ case: c, update: vi.fn(), loading: false, missing: false })
|
|
64
|
+
}));
|
|
65
|
+
vi.mock('react-router-dom', async () => {
|
|
66
|
+
const actual = await vi.importActual('react-router-dom');
|
|
67
|
+
return { ...actual, useOutletContext: () => undefined };
|
|
68
|
+
});
|
|
69
|
+
// Stub card components — their internals are irrelevant to timeline tests
|
|
70
|
+
vi.mock('components/elements/hit/HitCard', () => ({
|
|
71
|
+
default: ({ id }) => _jsx("div", { children: `HitCard:${id}` })
|
|
72
|
+
}));
|
|
73
|
+
vi.mock('components/elements/observable/ObservableCard', () => ({
|
|
74
|
+
default: ({ id }) => _jsx("div", { children: `ObservableCard:${id}` })
|
|
75
|
+
}));
|
|
76
|
+
// Return the request object rather than a Promise so dispatchApi's first
|
|
77
|
+
// argument is JSON-serialisable (JSON.stringify(Promise) === '{}').
|
|
78
|
+
vi.mock('api', () => ({
|
|
79
|
+
default: {
|
|
80
|
+
v2: {
|
|
81
|
+
search: {
|
|
82
|
+
post: (_indexes, request) => request ?? {},
|
|
83
|
+
facet: {
|
|
84
|
+
post: (_indexes, request) => request ?? {}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}));
|
|
90
|
+
const mockLoadRecords = vi.fn();
|
|
91
|
+
const mockConfig = {
|
|
92
|
+
lookups: {
|
|
93
|
+
tactics: { TA0001: { key: 'TA0001', name: 'Initial Access', url: '' } },
|
|
94
|
+
techniques: { T1059: { key: 'T1059', name: 'Command Scripting', url: '' } }
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const mockCase = {
|
|
98
|
+
case_id: 'case-001',
|
|
99
|
+
items: [
|
|
100
|
+
{ type: 'hit', value: 'hit-1', path: 'hits/hit-1' },
|
|
101
|
+
{ type: 'observable', value: 'obs-1', path: 'observables/obs-1' }
|
|
102
|
+
]
|
|
103
|
+
};
|
|
104
|
+
const Wrapper = ({ children }) => (_jsx(ApiConfigContext.Provider, { value: { config: mockConfig, setConfig: vi.fn() }, children: _jsxs(RecordContext.Provider, { value: { records: {}, loadRecords: mockLoadRecords }, children: [_jsx(MemoryRouter, { initialEntries: ['/cases/case-001/timeline'], children: children }), ' '] }) }));
|
|
105
|
+
const CaseTimeline = (await import('./CaseTimeline')).default;
|
|
106
|
+
// Reusable mock response factories
|
|
107
|
+
const mockFacetResponse = {
|
|
108
|
+
'threat.tactic.id': { TA0001: 1 },
|
|
109
|
+
'threat.technique.id': { T1059: 1 },
|
|
110
|
+
'howler.escalation': { evidence: 2, hit: 1 }
|
|
111
|
+
};
|
|
112
|
+
const mockSearchResponse = (items = [
|
|
113
|
+
createMockHit({ howler: { id: 'hit-1' }, event: { created: '2024-01-01T00:00:00Z' } }),
|
|
114
|
+
createMockObservable({ howler: { id: 'obs-1' } })
|
|
115
|
+
]) => ({ items, total: items.length, rows: items.length, offset: 0 });
|
|
116
|
+
describe('CaseTimeline component', () => {
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
mockDispatchApi.mockClear();
|
|
119
|
+
mockLoadRecords.mockClear();
|
|
120
|
+
});
|
|
121
|
+
it('renders loading skeletons while the search is pending', () => {
|
|
122
|
+
mockDispatchApi.mockReturnValue(new Promise(() => { })); // never resolves
|
|
123
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
124
|
+
expect(document.querySelectorAll('.MuiSkeleton-root').length).toBeGreaterThan(0);
|
|
125
|
+
});
|
|
126
|
+
it('shows the empty state when no entries are returned', async () => {
|
|
127
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse([]));
|
|
128
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
129
|
+
await screen.findByText('page.cases.timeline.empty');
|
|
130
|
+
});
|
|
131
|
+
it('renders HitCard for hit entries and ObservableCard for observable entries', async () => {
|
|
132
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse());
|
|
133
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
134
|
+
expect(await screen.findByText('HitCard:hit-1')).toBeTruthy();
|
|
135
|
+
expect(screen.getByText('ObservableCard:obs-1')).toBeTruthy();
|
|
136
|
+
});
|
|
137
|
+
it('renders the formatted event.created timestamp for hits', async () => {
|
|
138
|
+
mockDispatchApi
|
|
139
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
140
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' }, event: { created: '2024-06-15T12:34:56Z' } })]));
|
|
141
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
142
|
+
await screen.findByText(/2024-06-15/);
|
|
143
|
+
});
|
|
144
|
+
it('falls back to entry.timestamp when event.created is absent', async () => {
|
|
145
|
+
const entry = createMockHit({ howler: { id: 'hit-1' } });
|
|
146
|
+
entry.timestamp = '2024-03-10T08:00:00Z';
|
|
147
|
+
delete entry.event;
|
|
148
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse([entry]));
|
|
149
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
150
|
+
await screen.findByText(/2024-03-10/);
|
|
151
|
+
});
|
|
152
|
+
it('calls loadRecords with the search results', async () => {
|
|
153
|
+
const items = [createMockHit({ howler: { id: 'hit-1' } })];
|
|
154
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse(items));
|
|
155
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
156
|
+
await screen.findByText('HitCard:hit-1');
|
|
157
|
+
expect(mockLoadRecords).toHaveBeenCalledWith(items);
|
|
158
|
+
});
|
|
159
|
+
it('renders MITRE autocomplete with options derived from the facet response', async () => {
|
|
160
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse());
|
|
161
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
162
|
+
await screen.findByText('HitCard:hit-1');
|
|
163
|
+
expect(screen.getByRole('combobox', { name: /page.cases.timeline.filter.mitre/i })).toBeTruthy();
|
|
164
|
+
});
|
|
165
|
+
it('renders escalation autocomplete pre-populated with "evidence"', async () => {
|
|
166
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse());
|
|
167
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
168
|
+
await screen.findByText('HitCard:hit-1');
|
|
169
|
+
// "evidence" chip should be pre-selected as a tag
|
|
170
|
+
expect(screen.getByText('evidence')).toBeTruthy();
|
|
171
|
+
});
|
|
172
|
+
it('re-fetches with an escalation filter when a new escalation is selected', async () => {
|
|
173
|
+
mockDispatchApi
|
|
174
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
175
|
+
.mockResolvedValueOnce(mockSearchResponse())
|
|
176
|
+
.mockResolvedValueOnce(mockSearchResponse());
|
|
177
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
178
|
+
await screen.findByText('HitCard:hit-1');
|
|
179
|
+
const escalationInput = screen.getByRole('combobox', { name: /page.cases.timeline.filter.escalation/i });
|
|
180
|
+
await act(async () => {
|
|
181
|
+
await userEvent.click(escalationInput);
|
|
182
|
+
});
|
|
183
|
+
const option = await screen.findByRole('option', { name: /\bhit\b/i });
|
|
184
|
+
await act(async () => {
|
|
185
|
+
await userEvent.click(option);
|
|
186
|
+
});
|
|
187
|
+
// The third dispatchApi call should carry a filter containing "hit"
|
|
188
|
+
const thirdCallArgs = mockDispatchApi.mock.calls[2];
|
|
189
|
+
expect(JSON.stringify(thirdCallArgs[0])).toContain('hit');
|
|
190
|
+
});
|
|
191
|
+
it('re-fetches when a MITRE tactic is selected', async () => {
|
|
192
|
+
mockDispatchApi
|
|
193
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
194
|
+
.mockResolvedValueOnce(mockSearchResponse())
|
|
195
|
+
.mockResolvedValueOnce(mockSearchResponse());
|
|
196
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
197
|
+
await screen.findByText('HitCard:hit-1');
|
|
198
|
+
const mitreInput = screen.getByRole('combobox', { name: /page.cases.timeline.filter.mitre/i });
|
|
199
|
+
await act(async () => {
|
|
200
|
+
await userEvent.click(mitreInput);
|
|
201
|
+
});
|
|
202
|
+
const tacticOption = await screen.findByRole('option', { name: /TA0001/i });
|
|
203
|
+
await act(async () => {
|
|
204
|
+
await userEvent.click(tacticOption);
|
|
205
|
+
});
|
|
206
|
+
const thirdCallArgs = mockDispatchApi.mock.calls[2];
|
|
207
|
+
expect(JSON.stringify(thirdCallArgs[0])).toContain('TA0001');
|
|
208
|
+
});
|
|
209
|
+
it('passes the case item ids in the base query on every search call', async () => {
|
|
210
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse());
|
|
211
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
212
|
+
await screen.findByText('HitCard:hit-1');
|
|
213
|
+
const searchCallArg = mockDispatchApi.mock.calls[1][0];
|
|
214
|
+
expect(JSON.stringify(searchCallArg)).toContain('hit-1');
|
|
215
|
+
expect(JSON.stringify(searchCallArg)).toContain('obs-1');
|
|
216
|
+
});
|
|
217
|
+
it('renders nothing when _case is not yet available', () => {
|
|
218
|
+
const emptyCaseWrapper = ({ children }) => createElement(ApiConfigContext.Provider, { value: { config: mockConfig, setConfig: vi.fn() }, children: null }, createElement(RecordContext.Provider, { value: { records: {}, loadRecords: mockLoadRecords }, children: null }, createElement(MemoryRouter, null, children)));
|
|
219
|
+
const { container } = render(_jsx(CaseTimeline, {}), { wrapper: emptyCaseWrapper });
|
|
220
|
+
expect(container.firstChild).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
it('renders nothing and skips fetching when the case has no items', () => {
|
|
223
|
+
mockDispatchApi.mockResolvedValue({});
|
|
224
|
+
render(_jsx(CaseTimeline, { case: { case_id: 'empty', items: [] } }), { wrapper: Wrapper });
|
|
225
|
+
expect(mockDispatchApi).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -2,10 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Article, BookRounded, CheckCircle, ChevronRight, Folder as FolderIcon, Lightbulb, Link as LinkIcon, TableChart, Visibility } from '@mui/icons-material';
|
|
3
3
|
import { alpha, Skeleton, Stack, Typography, useTheme } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
|
+
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
5
6
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
6
7
|
import { omit } from 'lodash-es';
|
|
7
8
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
8
9
|
import { Link, useLocation } from 'react-router-dom';
|
|
10
|
+
import { useContextSelector } from 'use-context-selector';
|
|
9
11
|
import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
|
|
10
12
|
import CaseFolderContextMenu from './CaseFolderContextMenu';
|
|
11
13
|
import { buildTree } from './utils';
|
|
@@ -24,7 +26,8 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
24
26
|
const { dispatchApi } = useMyApi();
|
|
25
27
|
const [open, setOpen] = useState(true);
|
|
26
28
|
const [caseStates, setCaseStates] = useState({});
|
|
27
|
-
const
|
|
29
|
+
const loadRecords = useContextSelector(RecordContext, ctx => ctx.loadRecords);
|
|
30
|
+
const records = useContextSelector(RecordContext, ctx => ctx.records);
|
|
28
31
|
const tree = useMemo(() => folder || buildTree(_case?.items), [folder, _case?.items]);
|
|
29
32
|
const currentRootCaseId = rootCaseId || _case?.case_id;
|
|
30
33
|
const hitIds = useMemo(() => _case?.items
|
|
@@ -36,15 +39,15 @@ const CaseFolder = ({ case: _case, folder, name, step = -1, rootCaseId, pathPref
|
|
|
36
39
|
return;
|
|
37
40
|
}
|
|
38
41
|
dispatchApi(api.search.hit.post({ query: `howler.id:(${hitIds.join(' OR ')})` }), { throwError: false }).then(result => {
|
|
39
|
-
if (
|
|
42
|
+
if (result?.items?.length < 1) {
|
|
40
43
|
return;
|
|
41
|
-
|
|
44
|
+
}
|
|
42
45
|
});
|
|
43
|
-
}, [hitIds, dispatchApi]);
|
|
46
|
+
}, [hitIds, dispatchApi, _case.status, loadRecords]);
|
|
44
47
|
// Returns the MUI colour token for the item's escalation, or undefined if none.
|
|
45
48
|
const getEscalationColor = (itemType, itemKey, leafId) => {
|
|
46
49
|
if (itemType === 'hit' && leafId) {
|
|
47
|
-
const color = ESCALATION_COLORS[
|
|
50
|
+
const color = ESCALATION_COLORS[records[leafId]?.howler?.escalation];
|
|
48
51
|
if (color)
|
|
49
52
|
return color;
|
|
50
53
|
}
|
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { OpenInNew } from '@mui/icons-material';
|
|
3
|
-
import { Autocomplete, Box, Button,
|
|
2
|
+
import { KeyboardArrowDown, OpenInNew } from '@mui/icons-material';
|
|
3
|
+
import { Accordion, AccordionDetails, AccordionSummary, Autocomplete, Box, Button, Checkbox, Chip, CircularProgress, Divider, IconButton, LinearProgress, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
6
|
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
7
|
+
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
7
8
|
import AnalyticLink from '@cccsaurora/howler-ui/components/elements/hit/elements/AnalyticLink';
|
|
8
9
|
import EscalationChip from '@cccsaurora/howler-ui/components/elements/hit/elements/EscalationChip';
|
|
10
|
+
import HitCard from '@cccsaurora/howler-ui/components/elements/hit/HitCard';
|
|
9
11
|
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
10
12
|
import useHitActions from '@cccsaurora/howler-ui/components/hooks/useHitActions';
|
|
11
13
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
12
|
-
import { uniq } from 'lodash-es';
|
|
13
|
-
import { useContext, useEffect, useMemo, useState } from 'react';
|
|
14
|
+
import { isNil, uniq } from 'lodash-es';
|
|
15
|
+
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
14
16
|
import { useTranslation } from 'react-i18next';
|
|
15
17
|
import { Link } from 'react-router-dom';
|
|
18
|
+
import { useContextSelector } from 'use-context-selector';
|
|
16
19
|
import useCase from '../hooks/useCase';
|
|
20
|
+
const HitEntry = ({ hit, checked, onChange }) => {
|
|
21
|
+
if (!hit) {
|
|
22
|
+
return _jsx(Skeleton, { variant: "rounded", height: "40px", width: "100%" });
|
|
23
|
+
}
|
|
24
|
+
return (_jsxs(Accordion, { sx: { flexShrink: 0, px: 0, py: 0 }, children: [_jsx(AccordionSummary, { expandIcon: _jsx(KeyboardArrowDown, {}), sx: {
|
|
25
|
+
px: 1,
|
|
26
|
+
py: 0,
|
|
27
|
+
minHeight: '48px !important',
|
|
28
|
+
'& > *': {
|
|
29
|
+
margin: '0 !important'
|
|
30
|
+
}
|
|
31
|
+
}, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, pr: 1, width: "100%", children: [!isNil(checked) && (_jsx(Checkbox, { size: "small", checked: checked, onClick: e => {
|
|
32
|
+
onChange?.();
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
} })), _jsx(AnalyticLink, { hit: hit, compressed: true, alignSelf: "center" }), _jsx(EscalationChip, { hit: hit, layout: HitLayout.DENSE }), _jsx(Chip, { sx: { width: 'fit-content', display: 'inline-flex' }, label: hit.howler.status, size: "small", color: "primary" }), _jsx("div", { style: { flex: 1 } }), _jsx(IconButton, { size: "small", component: Link, to: `/hits/${hit.howler.id}`, children: _jsx(OpenInNew, { fontSize: "small" }) })] }) }), _jsx(AccordionDetails, { children: _jsx(HitCard, { id: hit.howler.id, layout: HitLayout.NORMAL, elevation: 0 }) })] }, hit.howler.id));
|
|
36
|
+
};
|
|
17
37
|
const ResolveModal = ({ case: _case, onConfirm }) => {
|
|
18
38
|
const { t } = useTranslation();
|
|
19
39
|
const { dispatchApi } = useMyApi();
|
|
@@ -23,40 +43,73 @@ const ResolveModal = ({ case: _case, onConfirm }) => {
|
|
|
23
43
|
const [loading, setLoading] = useState(true);
|
|
24
44
|
const [rationale, setRationale] = useState('');
|
|
25
45
|
const [assessment, setAssessment] = useState(null);
|
|
26
|
-
const [
|
|
46
|
+
const [selectedHitIds, setSelectedHitIds] = useState(new Set());
|
|
27
47
|
const hitIds = useMemo(() => uniq((_case?.items ?? [])
|
|
28
48
|
.filter(item => item.type === 'hit')
|
|
29
49
|
.map(item => item.value)
|
|
30
50
|
.filter(Boolean)), [_case?.items]);
|
|
31
|
-
const
|
|
51
|
+
const loadRecords = useContextSelector(RecordContext, ctx => ctx.loadRecords);
|
|
52
|
+
const records = useContextSelector(RecordContext, ctx => ctx.records);
|
|
53
|
+
const hits = useMemo(() => hitIds.map(id => records[id]).filter(Boolean), [hitIds, records]);
|
|
54
|
+
const selectedHits = useMemo(() => hits.filter(hit => selectedHitIds.has(hit.howler.id)), [hits, selectedHitIds]);
|
|
55
|
+
const { assess } = useHitActions(selectedHits);
|
|
56
|
+
const unresolvedHits = useMemo(() => hitIds.filter(id => {
|
|
57
|
+
const record = records[id];
|
|
58
|
+
if (!record) {
|
|
59
|
+
// Treat missing records as unresolved until they are loaded
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
return record.howler.status !== 'resolved';
|
|
63
|
+
}), [hitIds, records]);
|
|
64
|
+
const handleConfirm = async () => {
|
|
65
|
+
setLoading(true);
|
|
66
|
+
try {
|
|
67
|
+
await assess(assessment, true, rationale);
|
|
68
|
+
setSelectedHitIds(new Set());
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
setLoading(false);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const handleToggleHit = useCallback((hitId) => {
|
|
75
|
+
setSelectedHitIds(prev => {
|
|
76
|
+
const next = new Set(prev);
|
|
77
|
+
if (next.has(hitId)) {
|
|
78
|
+
next.delete(hitId);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
next.add(hitId);
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
});
|
|
85
|
+
}, []);
|
|
32
86
|
useEffect(() => {
|
|
33
87
|
(async () => {
|
|
34
88
|
try {
|
|
35
89
|
const result = await dispatchApi(api.search.hit.post({
|
|
36
|
-
query: `howler.id:(${hitIds.join(' OR ')})
|
|
90
|
+
query: `howler.id:(${hitIds.join(' OR ')})`,
|
|
37
91
|
metadata: ['analytic']
|
|
38
92
|
}));
|
|
39
|
-
|
|
93
|
+
loadRecords(result.items);
|
|
40
94
|
}
|
|
41
95
|
finally {
|
|
42
96
|
setLoading(false);
|
|
43
97
|
}
|
|
44
98
|
})();
|
|
45
|
-
}, [dispatchApi, hitIds]);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
99
|
+
}, [dispatchApi, hitIds, loadRecords]);
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (loading || unresolvedHits.length > 0) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
updateCase({ status: 'resolved' }).then(() => {
|
|
51
105
|
onConfirm();
|
|
52
106
|
close();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
: hits.map(hit => (_jsx(Card, { sx: { p: 1, flexShrink: 0 }, children: _jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, width: "100%", children: [_jsx(AnalyticLink, { hit: hit, compressed: true, alignSelf: "center" }), _jsx(EscalationChip, { hit: hit, layout: HitLayout.DENSE }), _jsx(Chip, { sx: { width: 'fit-content', display: 'inline-flex' }, label: hit.howler.status, size: "small", color: "primary" }), _jsx("div", { style: { flex: 1 } }), _jsx(IconButton, { size: "small", component: Link, to: `/hits/${hit.howler.id}`, children: _jsx(OpenInNew, { fontSize: "small" }) })] }) }, hit.howler.id)))] }), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: loading || !assessment || !rationale, onClick: handleConfirm, children: t('confirm') })] })] }));
|
|
107
|
+
});
|
|
108
|
+
}, [close, loading, onConfirm, unresolvedHits.length, updateCase]);
|
|
109
|
+
return (_jsxs(Stack, { spacing: 2, p: 2, alignItems: "start", sx: { minWidth: 'min(1000px, 60vw)', maxHeight: '100%', height: '100%' }, children: [_jsx(Typography, { variant: "h4", children: t('modal.cases.resolve') }), _jsx(Typography, { children: t('modal.cases.resolve.description') }), _jsxs(Stack, { spacing: 1, overflow: "auto", width: "100%", flex: 1, children: [_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(Box, { flex: 1, children: _jsx(TextField, { size: "small", fullWidth: true, placeholder: t('modal.rationale.label'), "aria-label": t('modal.rationale.label'), value: rationale, onChange: ev => setRationale(ev.target.value) }) }), _jsx(Box, { flex: 1, children: _jsx(Autocomplete, { size: "small", value: assessment, onChange: (_ev, _assessment) => setAssessment(_assessment), options: config.lookups['howler.assessment'], disablePortal: true, renderInput: params => (_jsx(TextField, { ...params, placeholder: t('hit.details.actions.assessment'), "aria-label": t('hit.details.actions.assessment'), fullWidth: true })) }) })] }), _jsxs(Stack, { position: "relative", children: [_jsx(Divider, {}), _jsx(LinearProgress, { sx: { opacity: +loading } })] }), hits
|
|
110
|
+
.filter(hit => unresolvedHits.includes(hit.howler.id))
|
|
111
|
+
.map(hit => (_jsx(HitEntry, { hit: hit, checked: selectedHitIds.has(hit.howler.id), onChange: () => handleToggleHit(hit.howler.id) }, hit.howler.id))), _jsxs(Accordion, { variant: "outlined", children: [_jsx(AccordionSummary, { expandIcon: _jsx(KeyboardArrowDown, {}), children: t('modal.cases.alerts.resolved') }), _jsx(AccordionDetails, { children: _jsx(Stack, { spacing: 1, children: hits
|
|
112
|
+
.filter(hit => !unresolvedHits.includes(hit.howler.id))
|
|
113
|
+
.map(hit => (_jsx(HitEntry, { hit: hit }, hit.howler.id))) }) })] })] }), _jsxs(Stack, { direction: "row", spacing: 1, alignSelf: "end", children: [_jsx(Button, { variant: "outlined", color: "error", onClick: close, children: t('cancel') }), _jsx(Button, { variant: "outlined", color: "success", disabled: loading || !assessment || !rationale || selectedHitIds.size === 0, startIcon: loading ? _jsx(CircularProgress, { size: 16, color: "inherit" }) : undefined, onClick: handleConfirm, children: t('confirm') })] })] }));
|
|
61
114
|
};
|
|
62
115
|
export default ResolveModal;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/// <reference types="vitest" />
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import userEvent, {} from '@testing-library/user-event';
|
|
5
|
+
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
|
+
import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
|
|
7
|
+
import { RecordContext } from '@cccsaurora/howler-ui/components/app/providers/RecordProvider';
|
|
8
|
+
import i18n from '@cccsaurora/howler-ui/i18n';
|
|
9
|
+
import {} from 'react';
|
|
10
|
+
import { I18nextProvider } from 'react-i18next';
|
|
11
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
12
|
+
import { createMockCase, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
|
|
13
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import ResolveModal from './ResolveModal';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Hoisted mocks
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const mockAssess = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
19
|
+
const mockDispatchApi = vi.hoisted(() => vi.fn());
|
|
20
|
+
const mockUpdateCase = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
21
|
+
const mockClose = vi.hoisted(() => vi.fn());
|
|
22
|
+
const mockLoadRecords = vi.hoisted(() => vi.fn());
|
|
23
|
+
vi.mock('components/hooks/useMyApi', () => ({
|
|
24
|
+
default: () => ({ dispatchApi: mockDispatchApi })
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('components/hooks/useHitActions', () => ({
|
|
27
|
+
default: () => ({ assess: mockAssess })
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('../hooks/useCase', () => ({
|
|
30
|
+
default: () => ({ update: mockUpdateCase })
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('components/elements/hit/elements/AnalyticLink', () => ({
|
|
33
|
+
default: ({ hit }) => _jsx("span", { "data-testid": `analytic-${hit.howler.id}`, children: hit.howler.analytic })
|
|
34
|
+
}));
|
|
35
|
+
vi.mock('components/elements/hit/elements/EscalationChip', () => ({
|
|
36
|
+
default: () => null
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('components/elements/hit/HitCard', () => ({
|
|
39
|
+
default: ({ id }) => _jsx("div", { children: `HitCard:${id}` })
|
|
40
|
+
}));
|
|
41
|
+
vi.mock('api', () => ({
|
|
42
|
+
default: {
|
|
43
|
+
search: {
|
|
44
|
+
hit: {
|
|
45
|
+
post: (params) => params
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}));
|
|
50
|
+
vi.mock('commons/components/app/hooks/useAppUser', () => ({
|
|
51
|
+
useAppUser: () => ({ user: { username: 'test-user' } })
|
|
52
|
+
}));
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Fixtures
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
const mockConfig = {
|
|
57
|
+
lookups: {
|
|
58
|
+
'howler.assessment': ['legitimate', 'false_positive', 'non_issue']
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const makeUnresolvedHit = (id) => createMockHit({ howler: { id, status: 'open', analytic: `analytic-${id}` } });
|
|
62
|
+
const makeResolvedHit = (id) => createMockHit({ howler: { id, status: 'resolved', analytic: `analytic-${id}` } });
|
|
63
|
+
const HIT_1 = makeUnresolvedHit('hit-1');
|
|
64
|
+
const HIT_2 = makeUnresolvedHit('hit-2');
|
|
65
|
+
const HIT_RESOLVED = makeResolvedHit('hit-resolved');
|
|
66
|
+
const caseWithHits = (hitIds) => createMockCase({
|
|
67
|
+
case_id: 'case-1',
|
|
68
|
+
items: hitIds.map(id => ({ type: 'hit', value: id }))
|
|
69
|
+
});
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Wrapper factory
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
const createWrapper = (records = {}) => {
|
|
74
|
+
const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children: _jsx(ModalContext.Provider, { value: { close: mockClose, open: vi.fn(), setContent: vi.fn() }, children: _jsx(ApiConfigContext.Provider, { value: { config: mockConfig, setConfig: vi.fn() }, children: _jsx(RecordContext.Provider, { value: { records, loadRecords: mockLoadRecords }, children: _jsx(MemoryRouter, { children: children }) }) }) }) }));
|
|
75
|
+
return Wrapper;
|
|
76
|
+
};
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Helpers
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
/**
|
|
81
|
+
* Clicks a MUI Checkbox — operates on the ButtonBase parent of the hidden input
|
|
82
|
+
* so that user-event's special checkbox-input code path is bypassed and the
|
|
83
|
+
* component's own onClick handler fires correctly.
|
|
84
|
+
*/
|
|
85
|
+
const clickCheckbox = (checkbox) => {
|
|
86
|
+
fireEvent.click(checkbox.parentElement);
|
|
87
|
+
};
|
|
88
|
+
/** Fills in assessment and rationale so the confirm button becomes enabled. */
|
|
89
|
+
const fillForm = async (user, assessment = 'legitimate', rationale = 'Test rationale') => {
|
|
90
|
+
const rationaleInput = screen.getByPlaceholderText(i18n.t('modal.rationale.label'));
|
|
91
|
+
await user.clear(rationaleInput);
|
|
92
|
+
await user.type(rationaleInput, rationale);
|
|
93
|
+
// Typing into the combobox triggers MUI Autocomplete's onInputChange which
|
|
94
|
+
// opens the listbox — a plain click does not reliably open it in jsdom.
|
|
95
|
+
// The combobox has no accessible label (only a placeholder), so we query by
|
|
96
|
+
// role alone — there is exactly one combobox in the modal.
|
|
97
|
+
const assessmentInput = screen.getByRole('combobox');
|
|
98
|
+
await user.type(assessmentInput, assessment.slice(0, 3));
|
|
99
|
+
const option = await screen.findByRole('option', { name: assessment });
|
|
100
|
+
await user.click(option);
|
|
101
|
+
};
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Tests
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
describe('ResolveModal', () => {
|
|
106
|
+
let user;
|
|
107
|
+
let mockOnConfirm;
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
user = userEvent.setup();
|
|
110
|
+
mockOnConfirm = vi.fn();
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
// Default: resolve immediately with an empty items list
|
|
113
|
+
mockDispatchApi.mockResolvedValue({ items: [] });
|
|
114
|
+
});
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
// Initial render
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
describe('initial render', () => {
|
|
119
|
+
it('shows the modal title', () => {
|
|
120
|
+
render(_jsx(ResolveModal, { case: caseWithHits([]), onConfirm: mockOnConfirm }), {
|
|
121
|
+
wrapper: createWrapper()
|
|
122
|
+
});
|
|
123
|
+
expect(screen.getByText(i18n.t('modal.cases.resolve'))).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
it('shows the modal description', () => {
|
|
126
|
+
render(_jsx(ResolveModal, { case: caseWithHits([]), onConfirm: mockOnConfirm }), {
|
|
127
|
+
wrapper: createWrapper()
|
|
128
|
+
});
|
|
129
|
+
expect(screen.getByText(i18n.t('modal.cases.resolve.description'))).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
it('renders a cancel button', () => {
|
|
132
|
+
render(_jsx(ResolveModal, { case: caseWithHits([]), onConfirm: mockOnConfirm }), {
|
|
133
|
+
wrapper: createWrapper()
|
|
134
|
+
});
|
|
135
|
+
expect(screen.getByRole('button', { name: i18n.t('cancel') })).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
it('renders a confirm button', () => {
|
|
138
|
+
render(_jsx(ResolveModal, { case: caseWithHits([]), onConfirm: mockOnConfirm }), {
|
|
139
|
+
wrapper: createWrapper()
|
|
140
|
+
});
|
|
141
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeInTheDocument();
|
|
142
|
+
});
|
|
143
|
+
it('shows the "Resolved Alerts" accordion', () => {
|
|
144
|
+
render(_jsx(ResolveModal, { case: caseWithHits([]), onConfirm: mockOnConfirm }), {
|
|
145
|
+
wrapper: createWrapper()
|
|
146
|
+
});
|
|
147
|
+
expect(screen.getByText(i18n.t('modal.cases.alerts.resolved'))).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
// -------------------------------------------------------------------------
|
|
151
|
+
// Loading state
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
describe('loading state', () => {
|
|
154
|
+
it('shows a LinearProgress while the API call is pending', () => {
|
|
155
|
+
mockDispatchApi.mockReturnValue(new Promise(() => { })); // never resolves
|
|
156
|
+
const { container } = render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
157
|
+
wrapper: createWrapper()
|
|
158
|
+
});
|
|
159
|
+
const progress = container.querySelector('.MuiLinearProgress-root');
|
|
160
|
+
expect(progress).toBeInTheDocument();
|
|
161
|
+
// opacity is 1 while loading
|
|
162
|
+
expect(progress).toHaveStyle({ opacity: '1' });
|
|
163
|
+
});
|
|
164
|
+
it('disables the confirm button while loading', () => {
|
|
165
|
+
mockDispatchApi.mockReturnValue(new Promise(() => { }));
|
|
166
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
167
|
+
wrapper: createWrapper()
|
|
168
|
+
});
|
|
169
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
170
|
+
});
|
|
171
|
+
it('shows a CircularProgress inside the confirm button while loading', () => {
|
|
172
|
+
mockDispatchApi.mockReturnValue(new Promise(() => { }));
|
|
173
|
+
const { container } = render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
174
|
+
wrapper: createWrapper()
|
|
175
|
+
});
|
|
176
|
+
expect(container.querySelector('.MuiCircularProgress-root')).toBeInTheDocument();
|
|
177
|
+
});
|
|
178
|
+
it('fades out the LinearProgress after loading completes', async () => {
|
|
179
|
+
const { container } = render(_jsx(ResolveModal, { case: caseWithHits([]), onConfirm: mockOnConfirm }), {
|
|
180
|
+
wrapper: createWrapper()
|
|
181
|
+
});
|
|
182
|
+
await waitFor(() => {
|
|
183
|
+
const progress = container.querySelector('.MuiLinearProgress-root');
|
|
184
|
+
expect(progress).toHaveStyle({ opacity: '0' });
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
// Hit rendering
|
|
190
|
+
// -------------------------------------------------------------------------
|
|
191
|
+
describe('hit rendering', () => {
|
|
192
|
+
it('renders unresolved hits with checkboxes after loading', async () => {
|
|
193
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1', 'hit-2']), onConfirm: mockOnConfirm }), {
|
|
194
|
+
wrapper: createWrapper({ 'hit-1': HIT_1, 'hit-2': HIT_2 })
|
|
195
|
+
});
|
|
196
|
+
await waitFor(() => {
|
|
197
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(2);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
it('does not show a checkbox for resolved hits', async () => {
|
|
201
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1', 'hit-resolved']), onConfirm: mockOnConfirm }), {
|
|
202
|
+
wrapper: createWrapper({ 'hit-1': HIT_1, 'hit-resolved': HIT_RESOLVED })
|
|
203
|
+
});
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
// only the single unresolved hit gets a checkbox
|
|
206
|
+
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
it('calls loadRecords with the API search results', async () => {
|
|
210
|
+
const items = [HIT_1];
|
|
211
|
+
mockDispatchApi.mockResolvedValueOnce({ items });
|
|
212
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
213
|
+
wrapper: createWrapper()
|
|
214
|
+
});
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(mockLoadRecords).toHaveBeenCalledWith(items);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
// -------------------------------------------------------------------------
|
|
221
|
+
// Confirm button disabled states
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
describe('confirm button enablement', () => {
|
|
224
|
+
it('is disabled when no hits are selected', async () => {
|
|
225
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
226
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
227
|
+
});
|
|
228
|
+
// Wait for the hit to load (checkbox appears) but don't select it
|
|
229
|
+
await screen.findAllByRole('checkbox');
|
|
230
|
+
await fillForm(user);
|
|
231
|
+
// No hit selected → selectedHitIds.size === 0 → button still disabled
|
|
232
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
233
|
+
});
|
|
234
|
+
it('is disabled when assessment is missing', async () => {
|
|
235
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
236
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
237
|
+
});
|
|
238
|
+
const [checkbox] = await screen.findAllByRole('checkbox');
|
|
239
|
+
clickCheckbox(checkbox);
|
|
240
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
241
|
+
const rationaleInput = screen.getByPlaceholderText(i18n.t('modal.rationale.label'));
|
|
242
|
+
await user.type(rationaleInput, 'some reason');
|
|
243
|
+
// no assessment chosen → still disabled
|
|
244
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
245
|
+
});
|
|
246
|
+
it('is disabled when rationale is empty', async () => {
|
|
247
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
248
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
249
|
+
});
|
|
250
|
+
const [checkbox] = await screen.findAllByRole('checkbox');
|
|
251
|
+
clickCheckbox(checkbox);
|
|
252
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
253
|
+
// fill only assessment, no rationale
|
|
254
|
+
const assessmentInput = screen.getByRole('combobox');
|
|
255
|
+
await user.type(assessmentInput, 'leg');
|
|
256
|
+
const option = await screen.findByRole('option', { name: 'legitimate' });
|
|
257
|
+
await user.click(option);
|
|
258
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeDisabled();
|
|
259
|
+
});
|
|
260
|
+
it('is enabled when a hit is selected + assessment + rationale are provided', async () => {
|
|
261
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
262
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
263
|
+
});
|
|
264
|
+
const [checkbox] = await screen.findAllByRole('checkbox');
|
|
265
|
+
clickCheckbox(checkbox);
|
|
266
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
267
|
+
await fillForm(user);
|
|
268
|
+
expect(screen.getByRole('button', { name: i18n.t('confirm') })).toBeEnabled();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
// -------------------------------------------------------------------------
|
|
272
|
+
// Checkbox / selection toggling
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
describe('hit selection', () => {
|
|
275
|
+
it('checks a hit when its checkbox is clicked', async () => {
|
|
276
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
277
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
278
|
+
});
|
|
279
|
+
const [checkbox] = await screen.findAllByRole('checkbox');
|
|
280
|
+
expect(checkbox).not.toBeChecked();
|
|
281
|
+
clickCheckbox(checkbox);
|
|
282
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
283
|
+
});
|
|
284
|
+
it('unchecks a hit when its checkbox is clicked a second time', async () => {
|
|
285
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
286
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
287
|
+
});
|
|
288
|
+
const [checkbox] = await screen.findAllByRole('checkbox');
|
|
289
|
+
clickCheckbox(checkbox);
|
|
290
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
291
|
+
clickCheckbox(checkbox);
|
|
292
|
+
await waitFor(() => expect(checkbox).not.toBeChecked());
|
|
293
|
+
});
|
|
294
|
+
it('tracks each hit independently when there are multiple', async () => {
|
|
295
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1', 'hit-2']), onConfirm: mockOnConfirm }), {
|
|
296
|
+
wrapper: createWrapper({ 'hit-1': HIT_1, 'hit-2': HIT_2 })
|
|
297
|
+
});
|
|
298
|
+
const checkboxes = await screen.findAllByRole('checkbox');
|
|
299
|
+
expect(checkboxes).toHaveLength(2);
|
|
300
|
+
clickCheckbox(checkboxes[0]);
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(checkboxes[0]).toBeChecked();
|
|
303
|
+
expect(checkboxes[1]).not.toBeChecked();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
// Confirm action
|
|
309
|
+
// -------------------------------------------------------------------------
|
|
310
|
+
describe('confirm action', () => {
|
|
311
|
+
it('calls assess with the chosen assessment, true, and rationale', async () => {
|
|
312
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
313
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
314
|
+
});
|
|
315
|
+
const [checkbox] = await screen.findAllByRole('checkbox');
|
|
316
|
+
clickCheckbox(checkbox);
|
|
317
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
318
|
+
await fillForm(user, 'legitimate', 'My rationale');
|
|
319
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
320
|
+
await waitFor(() => {
|
|
321
|
+
expect(mockAssess).toHaveBeenCalledWith('legitimate', true, 'My rationale');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
it('clears the selection after confirm completes', async () => {
|
|
325
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
326
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
327
|
+
});
|
|
328
|
+
const [checkbox] = await screen.findAllByRole('checkbox');
|
|
329
|
+
clickCheckbox(checkbox);
|
|
330
|
+
await waitFor(() => expect(checkbox).toBeChecked());
|
|
331
|
+
await fillForm(user);
|
|
332
|
+
await user.click(screen.getByRole('button', { name: i18n.t('confirm') }));
|
|
333
|
+
await waitFor(() => {
|
|
334
|
+
expect(checkbox).not.toBeChecked();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
// -------------------------------------------------------------------------
|
|
339
|
+
// Cancel button
|
|
340
|
+
// -------------------------------------------------------------------------
|
|
341
|
+
describe('cancel button', () => {
|
|
342
|
+
it('calls close() when cancel is clicked', async () => {
|
|
343
|
+
// Use a case with an unresolved hit so the auto-resolve effect does not fire
|
|
344
|
+
// and call close() before we even click cancel.
|
|
345
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
346
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
347
|
+
});
|
|
348
|
+
// Wait for loading to finish so no unexpected state transitions happen mid-click
|
|
349
|
+
await screen.findAllByRole('checkbox');
|
|
350
|
+
await user.click(screen.getByRole('button', { name: i18n.t('cancel') }));
|
|
351
|
+
expect(mockClose).toHaveBeenCalledTimes(1);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
// -------------------------------------------------------------------------
|
|
355
|
+
// Auto-resolve when all hits are already resolved
|
|
356
|
+
// -------------------------------------------------------------------------
|
|
357
|
+
describe('auto-resolve', () => {
|
|
358
|
+
it('calls updateCase, onConfirm, and close when no unresolved hits remain after loading', async () => {
|
|
359
|
+
// All hits in the case are already resolved
|
|
360
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-resolved']), onConfirm: mockOnConfirm }), {
|
|
361
|
+
wrapper: createWrapper({ 'hit-resolved': HIT_RESOLVED })
|
|
362
|
+
});
|
|
363
|
+
// dispatchApi resolves → loading becomes false → unresolvedHits.length === 0 → auto-close
|
|
364
|
+
await waitFor(() => {
|
|
365
|
+
expect(mockUpdateCase).toHaveBeenCalledWith({ status: 'resolved' });
|
|
366
|
+
});
|
|
367
|
+
await waitFor(() => {
|
|
368
|
+
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
|
369
|
+
expect(mockClose).toHaveBeenCalledTimes(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
it('does NOT auto-resolve while hits are still unresolved', async () => {
|
|
373
|
+
render(_jsx(ResolveModal, { case: caseWithHits(['hit-1']), onConfirm: mockOnConfirm }), {
|
|
374
|
+
wrapper: createWrapper({ 'hit-1': HIT_1 })
|
|
375
|
+
});
|
|
376
|
+
// Wait for loading to complete: the unresolved hit's checkbox appears once the
|
|
377
|
+
// dispatchApi call settles and the LinearProgress opacity drops to 0.
|
|
378
|
+
await screen.findAllByRole('checkbox');
|
|
379
|
+
// Should not have auto-resolved
|
|
380
|
+
expect(mockUpdateCase).not.toHaveBeenCalled();
|
|
381
|
+
expect(mockClose).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|
|
@@ -315,6 +315,7 @@
|
|
|
315
315
|
"modal.cases.add_to_case.select_case": "Search Cases",
|
|
316
316
|
"modal.cases.add_to_case.select_path": "Select Folder Path",
|
|
317
317
|
"modal.cases.add_to_case.title": "Item Title",
|
|
318
|
+
"modal.cases.alerts.resolved": "Resolved Alerts",
|
|
318
319
|
"modal.cases.rename_item": "Rename Item",
|
|
319
320
|
"modal.cases.rename_item.error.empty": "Name cannot be empty",
|
|
320
321
|
"modal.cases.rename_item.error.slash": "Name cannot contain '/'",
|
|
@@ -395,6 +396,13 @@
|
|
|
395
396
|
"page.cases.sidebar.item.remove": "Remove item",
|
|
396
397
|
"page.cases.sidebar.item.rename": "Rename item",
|
|
397
398
|
"page.cases.sources": "Sources",
|
|
399
|
+
"page.cases.timeline": "Timeline",
|
|
400
|
+
"page.cases.timeline.empty": "No events match the selected filters.",
|
|
401
|
+
"page.cases.timeline.filter.escalation": "Escalation",
|
|
402
|
+
"page.cases.timeline.filter.escalation.empty": "No escalation levels found.",
|
|
403
|
+
"page.cases.timeline.filter.label": "Show only",
|
|
404
|
+
"page.cases.timeline.filter.mitre": "MITRE ATT&CK",
|
|
405
|
+
"page.cases.timeline.filter.mitre.empty": "No tactics or techniques found.",
|
|
398
406
|
"page.cases.updated": "Updated",
|
|
399
407
|
"page.dashboard.settings.edit": "Edit Dashboard",
|
|
400
408
|
"page.dashboard.settings.refreshRate": "Refresh Rate",
|
|
@@ -315,6 +315,7 @@
|
|
|
315
315
|
"modal.cases.add_to_case.select_case": "Rechercher des cas",
|
|
316
316
|
"modal.cases.add_to_case.select_path": "Sélectionner le chemin du dossier",
|
|
317
317
|
"modal.cases.add_to_case.title": "Titre de l'élément",
|
|
318
|
+
"modal.cases.alerts.resolved": "Alertes résolues",
|
|
318
319
|
"modal.cases.rename_item": "Renommer l'élément",
|
|
319
320
|
"modal.cases.rename_item.error.empty": "Le nom ne peut pas être vide",
|
|
320
321
|
"modal.cases.rename_item.error.slash": "Le nom ne peut pas contenir '/'",
|
|
@@ -395,6 +396,13 @@
|
|
|
395
396
|
"page.cases.sidebar.item.remove": "Supprimer l'élément",
|
|
396
397
|
"page.cases.sidebar.item.rename": "Renommer l'élément",
|
|
397
398
|
"page.cases.sources": "Sources",
|
|
399
|
+
"page.cases.timeline": "Chronologie",
|
|
400
|
+
"page.cases.timeline.empty": "Aucun événement ne correspond aux filtres sélectionnés.",
|
|
401
|
+
"page.cases.timeline.filter.escalation": "Escalade",
|
|
402
|
+
"page.cases.timeline.filter.escalation.empty": "Aucun niveau d'escalade trouvé.",
|
|
403
|
+
"page.cases.timeline.filter.label": "Afficher uniquement",
|
|
404
|
+
"page.cases.timeline.filter.mitre": "MITRE ATT&CK",
|
|
405
|
+
"page.cases.timeline.filter.mitre.empty": "Aucune tactique ou technique trouvée.",
|
|
398
406
|
"page.cases.updated": "Mis à jour",
|
|
399
407
|
"page.dashboard.settings.edit": "Modifier le tableau de bord",
|
|
400
408
|
"page.dashboard.settings.refreshRate": "Fréquence de rafraîchissement",
|