@cccsaurora/howler-ui 2.18.0-dev.705 → 2.18.0-dev.710

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.
@@ -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>;
@@ -8,6 +8,8 @@ export type HowlerFacetSearchRequest = {
8
8
  filters?: string[];
9
9
  };
10
10
  export type HowlerFacetSearchResponse = {
11
- [value: string]: number;
11
+ [field: string]: {
12
+ [value: string]: number;
13
+ };
12
14
  };
13
15
  export { hit };
@@ -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, {})
@@ -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 (!hit) {
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 }) }) }));
@@ -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 CaseAssets: FC<{
7
+ declare const _default: import("react").NamedExoticComponent<{
9
8
  case?: Case;
10
9
  caseId?: string;
11
10
  }>;
12
- export default CaseAssets;
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,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
+ });
@@ -395,6 +395,13 @@
395
395
  "page.cases.sidebar.item.remove": "Remove item",
396
396
  "page.cases.sidebar.item.rename": "Rename item",
397
397
  "page.cases.sources": "Sources",
398
+ "page.cases.timeline": "Timeline",
399
+ "page.cases.timeline.empty": "No events match the selected filters.",
400
+ "page.cases.timeline.filter.escalation": "Escalation",
401
+ "page.cases.timeline.filter.escalation.empty": "No escalation levels found.",
402
+ "page.cases.timeline.filter.label": "Show only",
403
+ "page.cases.timeline.filter.mitre": "MITRE ATT&CK",
404
+ "page.cases.timeline.filter.mitre.empty": "No tactics or techniques found.",
398
405
  "page.cases.updated": "Updated",
399
406
  "page.dashboard.settings.edit": "Edit Dashboard",
400
407
  "page.dashboard.settings.refreshRate": "Refresh Rate",
@@ -395,6 +395,13 @@
395
395
  "page.cases.sidebar.item.remove": "Supprimer l'élément",
396
396
  "page.cases.sidebar.item.rename": "Renommer l'élément",
397
397
  "page.cases.sources": "Sources",
398
+ "page.cases.timeline": "Chronologie",
399
+ "page.cases.timeline.empty": "Aucun événement ne correspond aux filtres sélectionnés.",
400
+ "page.cases.timeline.filter.escalation": "Escalade",
401
+ "page.cases.timeline.filter.escalation.empty": "Aucun niveau d'escalade trouvé.",
402
+ "page.cases.timeline.filter.label": "Afficher uniquement",
403
+ "page.cases.timeline.filter.mitre": "MITRE ATT&CK",
404
+ "page.cases.timeline.filter.mitre.empty": "Aucune tactique ou technique trouvée.",
398
405
  "page.cases.updated": "Mis à jour",
399
406
  "page.dashboard.settings.edit": "Modifier le tableau de bord",
400
407
  "page.dashboard.settings.refreshRate": "Fréquence de rafraîchissement",
package/package.json CHANGED
@@ -101,7 +101,7 @@
101
101
  "internal-slot": "1.0.7"
102
102
  },
103
103
  "type": "module",
104
- "version": "2.18.0-dev.705",
104
+ "version": "2.18.0-dev.710",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",