@cccsaurora/howler-ui 2.18.0-dev.758 → 2.18.0-dev.762
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.
|
@@ -101,6 +101,6 @@ const CaseTimeline = ({ case: providedCase, caseId }) => {
|
|
|
101
101
|
if (!_case) {
|
|
102
102
|
return null;
|
|
103
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: "
|
|
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: [_jsxs(Stack, { spacing: 0.5, alignItems: "end", children: [_jsx(Typography, { variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: dayjs(entry.event?.created ?? entry.timestamp).format('YYYY-MM-DD HH:mm:ss') }), entry.threat?.technique?.id && (_jsx(Tooltip, { title: `${entry.threat.technique.id}: ${config.lookups?.techniques?.[entry.threat.technique.id].name}`, children: _jsx(Typography, { component: config.lookups?.techniques?.[entry.threat.technique.id]?.url ? 'a' : undefined, href: config.lookups?.techniques?.[entry.threat.technique.id]?.url, variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: entry.threat.technique.id }) })), entry.threat?.tactic?.id && (_jsx(Tooltip, { title: `${entry.threat.tactic.id}: ${config.lookups?.tactics?.[entry.threat.tactic.id]?.name ?? t('unknown')}`, children: _jsx(Typography, { component: config.lookups?.tactics?.[entry.threat.tactic.id]?.url ? 'a' : undefined, href: config.lookups?.tactics?.[entry.threat.tactic.id]?.url, variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: entry.threat.tactic.id }) }))] }), _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
105
|
};
|
|
106
106
|
export default memo(CaseTimeline);
|
|
@@ -102,6 +102,15 @@ const mockCase = {
|
|
|
102
102
|
]
|
|
103
103
|
};
|
|
104
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 mockConfigWithUrls = {
|
|
106
|
+
lookups: {
|
|
107
|
+
tactics: { TA0001: { key: 'TA0001', name: 'Initial Access', url: 'https://attack.mitre.org/tactics/TA0001' } },
|
|
108
|
+
techniques: {
|
|
109
|
+
T1059: { key: 'T1059', name: 'Command Scripting', url: 'https://attack.mitre.org/techniques/T1059' }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const WrapperWithUrl = ({ children }) => (_jsx(ApiConfigContext.Provider, { value: { config: mockConfigWithUrls, setConfig: vi.fn() }, children: _jsx(RecordContext.Provider, { value: { records: {}, loadRecords: mockLoadRecords }, children: _jsx(MemoryRouter, { initialEntries: ['/cases/case-001/timeline'], children: children }) }) }));
|
|
105
114
|
const CaseTimeline = (await import('./CaseTimeline')).default;
|
|
106
115
|
// Reusable mock response factories
|
|
107
116
|
const mockFacetResponse = {
|
|
@@ -224,4 +233,88 @@ describe('CaseTimeline component', () => {
|
|
|
224
233
|
render(_jsx(CaseTimeline, { case: { case_id: 'empty', items: [] } }), { wrapper: Wrapper });
|
|
225
234
|
expect(mockDispatchApi).not.toHaveBeenCalled();
|
|
226
235
|
});
|
|
236
|
+
describe('tactic and technique inline entries', () => {
|
|
237
|
+
it('renders the technique ID link for a hit with threat.technique', async () => {
|
|
238
|
+
mockDispatchApi
|
|
239
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
240
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' }, threat: { technique: { id: 'T1059' } } })]));
|
|
241
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
242
|
+
expect(await screen.findByText('T1059')).toBeTruthy();
|
|
243
|
+
});
|
|
244
|
+
it('renders the tactic ID link for a hit with threat.tactic', async () => {
|
|
245
|
+
mockDispatchApi
|
|
246
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
247
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' }, threat: { tactic: { id: 'TA0001' } } })]));
|
|
248
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
249
|
+
expect(await screen.findByText('TA0001')).toBeTruthy();
|
|
250
|
+
});
|
|
251
|
+
it('renders both technique and tactic ID links when the entry has both threat fields', async () => {
|
|
252
|
+
mockDispatchApi.mockResolvedValueOnce(mockFacetResponse).mockResolvedValueOnce(mockSearchResponse([
|
|
253
|
+
createMockHit({
|
|
254
|
+
howler: { id: 'hit-1' },
|
|
255
|
+
threat: { technique: { id: 'T1059' }, tactic: { id: 'TA0001' } }
|
|
256
|
+
})
|
|
257
|
+
]));
|
|
258
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
259
|
+
await screen.findByText('T1059');
|
|
260
|
+
expect(screen.getByText('TA0001')).toBeTruthy();
|
|
261
|
+
});
|
|
262
|
+
it('does not render tactic or technique links when threat data is absent', async () => {
|
|
263
|
+
mockDispatchApi
|
|
264
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
265
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' } })]));
|
|
266
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
267
|
+
await screen.findByText('HitCard:hit-1');
|
|
268
|
+
expect(screen.queryByText('T1059')).toBeNull();
|
|
269
|
+
expect(screen.queryByText('TA0001')).toBeNull();
|
|
270
|
+
});
|
|
271
|
+
it('sets href on the technique link from config.lookups.techniques.url', async () => {
|
|
272
|
+
mockDispatchApi
|
|
273
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
274
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' }, threat: { technique: { id: 'T1059' } } })]));
|
|
275
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: WrapperWithUrl });
|
|
276
|
+
const link = await screen.findByText('T1059');
|
|
277
|
+
expect(link.getAttribute('href')).toBe('https://attack.mitre.org/techniques/T1059');
|
|
278
|
+
});
|
|
279
|
+
it('sets href on the tactic link from config.lookups.tactics.url', async () => {
|
|
280
|
+
mockDispatchApi
|
|
281
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
282
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' }, threat: { tactic: { id: 'TA0001' } } })]));
|
|
283
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: WrapperWithUrl });
|
|
284
|
+
const link = await screen.findByText('TA0001');
|
|
285
|
+
expect(link.getAttribute('href')).toBe('https://attack.mitre.org/tactics/TA0001');
|
|
286
|
+
});
|
|
287
|
+
describe('tooltip content', () => {
|
|
288
|
+
beforeEach(() => {
|
|
289
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
290
|
+
});
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
vi.useRealTimers();
|
|
293
|
+
});
|
|
294
|
+
it('shows a tooltip with technique ID and name on hover', async () => {
|
|
295
|
+
mockDispatchApi
|
|
296
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
297
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' }, threat: { technique: { id: 'T1059' } } })]));
|
|
298
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
299
|
+
const link = await screen.findByText('T1059');
|
|
300
|
+
await act(async () => {
|
|
301
|
+
await userEvent.hover(link);
|
|
302
|
+
});
|
|
303
|
+
const tooltip = await screen.findByRole('tooltip');
|
|
304
|
+
expect(tooltip.textContent).toContain('T1059: Command Scripting');
|
|
305
|
+
});
|
|
306
|
+
it('shows a tooltip with tactic ID and name on hover', async () => {
|
|
307
|
+
mockDispatchApi
|
|
308
|
+
.mockResolvedValueOnce(mockFacetResponse)
|
|
309
|
+
.mockResolvedValueOnce(mockSearchResponse([createMockHit({ howler: { id: 'hit-1' }, threat: { tactic: { id: 'TA0001' } } })]));
|
|
310
|
+
render(_jsx(CaseTimeline, { case: mockCase }), { wrapper: Wrapper });
|
|
311
|
+
const link = await screen.findByText('TA0001');
|
|
312
|
+
await act(async () => {
|
|
313
|
+
await userEvent.hover(link);
|
|
314
|
+
});
|
|
315
|
+
const tooltip = await screen.findByRole('tooltip');
|
|
316
|
+
expect(tooltip.textContent).toContain('TA0001: Initial Access');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
227
320
|
});
|