@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: "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))) }))] }));
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
  });
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.758",
104
+ "version": "2.18.0-dev.762",
105
105
  "exports": {
106
106
  "./i18n": "./i18n.js",
107
107
  "./index.css": "./index.css",