@cccsaurora/howler-ui 2.19.0-dev.1320 → 2.19.0-dev.1344

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.
@@ -25,7 +25,6 @@ const useMySitemap = () => {
25
25
  routes: [
26
26
  { path: '/', title: t('route.home'), isRoot: true, icon: _jsx(Dashboard, {}) },
27
27
  { path: '/cases', title: t('route.cases'), isRoot: true, icon: _jsx(BookRounded, {}) },
28
- { path: '/cases/:id', title: t('route.cases.view'), breadcrumbs: ['/cases'] },
29
28
  { path: '/cases/:id/*', title: t('route.cases.view'), isLeaf: true, breadcrumbs: ['/cases'] },
30
29
  { path: '/admin/users', title: t('route.admin.user.search'), isRoot: true, icon: _jsx(PersonSearch, {}) },
31
30
  {
@@ -1,14 +1,3 @@
1
- import React, { type PropsWithChildren } from 'react';
2
- declare class ErrorBoundary extends React.Component<PropsWithChildren<{}>, {
3
- hasError: boolean;
4
- error: Error;
5
- }> {
6
- constructor(props: any);
7
- static getDerivedStateFromError(error: any): {
8
- hasError: boolean;
9
- error: any;
10
- };
11
- componentDidCatch(error: Error): void;
12
- render(): string | number | boolean | import("react/jsx-runtime").JSX.Element | Iterable<React.ReactNode>;
13
- }
1
+ import { type PropsWithChildren } from 'react';
2
+ declare const ErrorBoundary: ({ children }: PropsWithChildren) => import("react/jsx-runtime").JSX.Element;
14
3
  export default ErrorBoundary;
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ExpandMore } from '@mui/icons-material';
3
3
  import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '@mui/material';
4
4
  import React, {} from 'react';
5
+ import { useLocation } from 'react-router-dom';
5
6
  import ErrorOccured from './ErrorOccured';
6
- class ErrorBoundary extends React.Component {
7
+ class ErrorBoundaryComponent extends React.Component {
7
8
  constructor(props) {
8
9
  super(props);
9
10
  this.state = { hasError: false, error: null };
@@ -14,6 +15,12 @@ class ErrorBoundary extends React.Component {
14
15
  componentDidCatch(error) {
15
16
  this.setState({ hasError: true, error: error });
16
17
  }
18
+ componentDidUpdate(previousProps) {
19
+ console.log(previousProps, this.props);
20
+ if (this.props.locationKey !== previousProps.locationKey && this.state.hasError) {
21
+ this.setState({ hasError: false, error: null });
22
+ }
23
+ }
17
24
  render() {
18
25
  if (this.state.hasError) {
19
26
  return (_jsxs(Box, { pt: 6, textAlign: "center", fontSize: 20, children: [_jsx(ErrorOccured, {}), _jsxs(Accordion, { elevation: 0, children: [_jsx(AccordionSummary, { expandIcon: _jsx(ExpandMore, {}), "aria-controls": "panel1-content", id: "panel1-header", children: _jsx(Typography, { align: "center", sx: { width: '100%', fontSize: '1.2rem' }, variant: "h5", children: this.state.error.message }) }), _jsx(AccordionDetails, { children: _jsx("code", { children: _jsx(Typography, { variant: "h6", children: this.state.error.stack }) }) })] })] }));
@@ -21,4 +28,8 @@ class ErrorBoundary extends React.Component {
21
28
  return this.props.children;
22
29
  }
23
30
  }
31
+ const ErrorBoundary = ({ children }) => {
32
+ const location = useLocation();
33
+ return _jsx(ErrorBoundaryComponent, { locationKey: location.key, children: children });
34
+ };
24
35
  export default ErrorBoundary;
@@ -1,8 +1,10 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /// <reference types="vitest" />
3
3
  import { render, screen } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
4
5
  import i18n from '@cccsaurora/howler-ui/i18n';
5
6
  import { I18nextProvider } from 'react-i18next';
7
+ import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
6
8
  import { describe, expect, it, vi } from 'vitest';
7
9
  import ErrorBoundary from './ErrorBoundary';
8
10
  vi.mock('commons/components/pages/PageCenter', () => ({
@@ -20,24 +22,45 @@ const Wrapper = ({ children }) => (_jsx(I18nextProvider, { i18n: i18n, children:
20
22
  const ThrowingComponent = ({ error }) => {
21
23
  throw error;
22
24
  };
25
+ const RouteContent = () => {
26
+ const location = useLocation();
27
+ if (location.pathname === '/broken') {
28
+ return _jsx(ThrowingComponent, { error: new Error('Broken route') });
29
+ }
30
+ return _jsx("div", { children: "Recovered route" });
31
+ };
32
+ const NavigationButton = () => {
33
+ const navigate = useNavigate();
34
+ return _jsx("button", { onClick: () => navigate('/recovered'), children: "Navigate away" });
35
+ };
23
36
  describe('ErrorBoundary', () => {
24
37
  it('should render children when no error occurs', () => {
25
- render(_jsx(ErrorBoundary, { children: _jsx("div", { children: "Child content" }) }), { wrapper: Wrapper });
38
+ render(_jsx(MemoryRouter, { children: _jsx(ErrorBoundary, { children: _jsx("div", { children: "Child content" }) }) }), { wrapper: Wrapper });
26
39
  expect(screen.getByText('Child content')).toBeInTheDocument();
27
40
  });
28
41
  it('should render error UI when a child throws', () => {
29
42
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
30
43
  const testError = new Error('Test error message');
31
44
  testError.stack = 'Error: Test error message\n at TestComponent';
32
- render(_jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }), { wrapper: Wrapper });
45
+ render(_jsx(MemoryRouter, { children: _jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }) }), { wrapper: Wrapper });
33
46
  expect(screen.getByText('Test error message')).toBeInTheDocument();
34
47
  consoleSpy.mockRestore();
35
48
  });
36
49
  it('should display the error page title when an error occurs', () => {
37
50
  const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
38
51
  const testError = new Error('Something went wrong');
39
- render(_jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }), { wrapper: Wrapper });
52
+ render(_jsx(MemoryRouter, { children: _jsx(ErrorBoundary, { children: _jsx(ThrowingComponent, { error: testError }) }) }), { wrapper: Wrapper });
40
53
  expect(screen.getByText('Application Stopped Working')).toBeInTheDocument();
41
54
  consoleSpy.mockRestore();
42
55
  });
56
+ it('should reset after navigating away from an error', async () => {
57
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
58
+ const user = userEvent.setup();
59
+ render(_jsxs(MemoryRouter, { initialEntries: ['/broken'], children: [_jsx(NavigationButton, {}), _jsx(ErrorBoundary, { children: _jsx(RouteContent, {}) })] }), { wrapper: Wrapper });
60
+ expect(screen.getByText('Broken route')).toBeInTheDocument();
61
+ await user.click(screen.getByRole('button', { name: 'Navigate away' }));
62
+ expect(screen.queryByText('Broken route')).not.toBeInTheDocument();
63
+ expect(screen.getByText('Recovered route')).toBeInTheDocument();
64
+ consoleSpy.mockRestore();
65
+ });
43
66
  });
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
10
10
  import { useOutletContext } from 'react-router-dom';
11
11
  import useCase from '../hooks/useCase';
12
12
  import ObservableTable from './observables/ObservableTable';
13
- import { OBSERVABLE_FIELDS, buildObservableEntries, classifyRole, resolveSources } from './utils';
13
+ import { OBSERVABLE_FIELDS, buildObservableEntries } from './utils';
14
14
  const RELATED_FIELDS = OBSERVABLE_FIELDS.map(f => `related.${f}`).join(',');
15
15
  const EXTRA_FIELDS = 'howler.escalation,howler.outline.threat,howler.outline.target,howler.outline.indicators,threat.indicator.ip,threat.indicator.description';
16
16
  const CaseObservables = ({ case: providedCase, caseId }) => {
@@ -25,7 +25,6 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
25
25
  const [roleFilters, setRoleFilters] = useState([]);
26
26
  const [escalationOptions, setEscalationOptions] = useState([]);
27
27
  const [activeEscalations, setActiveEscalations] = useState([]);
28
- const [escalationMap, setEscalationMap] = useState(new Map());
29
28
  const ids = useMemo(() => (_case?.items ?? [])
30
29
  .filter(item => ['hit', 'event'].includes(item.type))
31
30
  .map(item => item.value)
@@ -38,19 +37,7 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
38
37
  void dispatchApi(api.v2.search.post(['hit', 'event'], {
39
38
  query: `howler.id:(${ids.join(' OR ')})`,
40
39
  fl: `howler.id,${RELATED_FIELDS},${EXTRA_FIELDS}`
41
- })).then(response => {
42
- setRecords(response.items);
43
- // Build escalation map from fetched records
44
- const escMap = new Map();
45
- for (const record of response.items) {
46
- const id = record.howler?.id ?? record.howler?.id;
47
- const escalation = record.howler?.escalation;
48
- if (id && escalation) {
49
- escMap.set(id, escalation);
50
- }
51
- }
52
- setEscalationMap(escMap);
53
- });
40
+ }), { throwError: false, showError: true }).then(response => response && setRecords(response.items));
54
41
  }, [dispatchApi, ids]);
55
42
  useEffect(() => {
56
43
  if (ids.length < 1) {
@@ -68,16 +55,14 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
68
55
  });
69
56
  }, [dispatchApi, ids]);
70
57
  const allObservables = useMemo(() => {
71
- if (!records || !_case) {
58
+ if (!_case) {
59
+ return [];
60
+ }
61
+ if (!records?.length) {
72
62
  return [];
73
63
  }
74
- const entries = buildObservableEntries(records);
75
- return entries.map(entry => ({
76
- ...entry,
77
- role: classifyRole(entry.value, _case, records),
78
- sources: resolveSources(entry.seenIn, _case.items, escalationMap)
79
- }));
80
- }, [records, _case, escalationMap]);
64
+ return buildObservableEntries(_case, records);
65
+ }, [records, _case]);
81
66
  const observableTypes = useMemo(() => (allObservables ? uniq(allObservables.map(a => a.type)).sort() : []), [allObservables]);
82
67
  const filteredObservables = useMemo(() => {
83
68
  if (allObservables.length < 1) {
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- /// <reference types="vitest" />
3
- import { render, screen, waitFor } from '@testing-library/react';
2
+ import { render, screen } from '@testing-library/react';
4
3
  import userEvent from '@testing-library/user-event';
5
4
  import { createElement } from 'react';
6
5
  import { MemoryRouter } from 'react-router-dom';
@@ -148,10 +147,9 @@ describe('CaseObservables component', () => {
148
147
  });
149
148
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
150
149
  await screen.findByText('1.2.3.4');
151
- await waitFor(() => {
152
- // 'evidence' appears in the table escalation column
153
- expect(screen.getByText('evidence')).toBeTruthy();
154
- });
150
+ await screen.findByText('page.cases.observables.filter_by_escalation');
151
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_escalation'));
152
+ expect(screen.getAllByText('evidence').length).toBeGreaterThanOrEqual(2);
155
153
  });
156
154
  it('classifies roles and renders role chips in the table', async () => {
157
155
  mockDispatchApi.mockResolvedValue({
@@ -18,6 +18,7 @@ import { useContextSelector } from 'use-context-selector';
18
18
  import { ESCALATION_COLORS } from '@cccsaurora/howler-ui/utils/constants';
19
19
  import { isHit } from '@cccsaurora/howler-ui/utils/typeUtils';
20
20
  import useCase from '../hooks/useCase';
21
+ import { buildPathFromID } from '../utils';
21
22
  // builds the additional filters for the lucene query
22
23
  export const buildFilters = (mitre, escalations) => {
23
24
  const filters = [];
@@ -101,6 +102,19 @@ const CaseTimeline = ({ case: providedCase, caseId }) => {
101
102
  if (!_case) {
102
103
  return null;
103
104
  }
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}/${getItemId(entry.howler.id)}`, sx: { flex: 1, minWidth: 0, textDecoration: 'none' }, children: isHit(entry) ? (_jsx(HitCard, { id: entry.howler.id, hit: entry, layout: HitLayout.DENSE, readOnly: true })) : (_jsx(EventCard, { id: entry.howler.id, event: entry })) })] }), _jsx(Divider, { flexItem: true })] }, entry.howler.id))) }))] }));
105
+ 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 }) }))] }), _jsxs(Box, { sx: { flex: 1, minWidth: 0, textDecoration: 'none', position: 'relative' }, children: [isHit(entry) ? (_jsx(HitCard, { id: entry.howler.id, hit: entry, layout: HitLayout.DENSE, readOnly: true })) : (_jsx(EventCard, { id: entry.howler.id, event: entry })), _jsx(Box, { component: Link, to: `/cases/${_case.case_id}/${buildPathFromID(_case, getItemId(entry.howler.id))}`, sx: theme => ({
106
+ position: 'absolute',
107
+ top: 0,
108
+ left: 0,
109
+ width: '100%',
110
+ height: '100%',
111
+ cursor: 'pointer',
112
+ zIndex: 100,
113
+ borderRadius: '4px',
114
+ '&:hover': {
115
+ background: theme.palette.divider,
116
+ border: `thin solid ${theme.palette.primary.light}`
117
+ }
118
+ }) })] })] }), _jsx(Divider, { flexItem: true })] }, entry.howler.id))) }))] }));
105
119
  };
106
120
  export default memo(CaseTimeline);
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /* eslint-disable react/no-children-prop */
3
- /// <reference types="vitest" />
4
3
  import { act, render, screen } from '@testing-library/react';
5
4
  import userEvent from '@testing-library/user-event';
6
5
  import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
@@ -2,12 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Card, CardContent, Chip, Stack, Typography } from '@mui/material';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Link } from 'react-router-dom';
5
- import { buildPathFromID } from '../../utils';
6
5
  const Observable = ({ observable, case: _case }) => {
7
6
  const { t } = useTranslation();
8
- return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: observable.value })] }), observable.seenIn.length > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: observable.seenIn.map(id => {
9
- const entry = _case.items.find(item => item.value === id);
10
- return (_jsx(Chip, { clickable: true, size: "small", label: entry.name, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${buildPathFromID(_case, entry.id)}` }, id));
7
+ return (_jsx(Card, { sx: { height: '100%' }, children: _jsx(CardContent, { children: _jsxs(Stack, { spacing: 1, children: [_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 1, children: [_jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }), _jsx(Typography, { variant: "body2", sx: { wordBreak: 'break-all', fontFamily: 'monospace' }, children: observable.value })] }), (observable.sources?.length ?? 0) > 0 && (_jsxs(Stack, { spacing: 0.5, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: t('page.cases.observables.seen_in') }), _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: observable.sources?.map(source => {
8
+ if (!source.path) {
9
+ return _jsx(Chip, { size: "small", label: source.label ?? source.id, variant: "outlined" }, source.id);
10
+ }
11
+ return (_jsx(Chip, { clickable: true, size: "small", label: source.label ?? source.id, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${source.path}` }, source.id));
11
12
  }) })] }))] }) }) }));
12
13
  };
13
14
  export default Observable;
@@ -8,7 +8,7 @@ import Observable from './Observable';
8
8
  const makeObservable = (overrides = {}) => ({
9
9
  type: 'ip',
10
10
  value: '192.168.1.1',
11
- seenIn: [],
11
+ sources: [],
12
12
  ...overrides
13
13
  });
14
14
  describe('Observable', () => {
@@ -33,19 +33,19 @@ describe('Observable', () => {
33
33
  expect(screen.getByText(hash)).toBeTruthy();
34
34
  });
35
35
  });
36
- describe('seen-in chips', () => {
37
- it('renders nothing when seenIn is empty', () => {
38
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: [] }), case: createMockCase() }) }));
36
+ describe('source chips', () => {
37
+ it('renders nothing when sources are empty', () => {
38
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ sources: [] }), case: createMockCase() }) }));
39
39
  expect(screen.queryByText('page.cases.observables.seen_in')).toBeNull();
40
40
  });
41
- it('renders "Seen in" label when seenIn has entries', () => {
41
+ it('renders "Seen in" label when sources have entries', () => {
42
42
  const _case = createMockCase({
43
43
  items: [{ type: 'hit', value: 'hit-001' }]
44
44
  });
45
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
45
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ sources: [{ id: 'hit-001', type: 'hit' }] }), case: _case }) }));
46
46
  expect(screen.getByText('page.cases.observables.seen_in')).toBeTruthy();
47
47
  });
48
- it('renders chips labelled from item name/value for each seenIn id', () => {
48
+ it('renders chips labelled from source metadata', () => {
49
49
  const _case = createMockCase({
50
50
  items: [
51
51
  { id: 'i1', type: 'hit', value: 'hit-001', name: 'first-hit' },
@@ -53,7 +53,13 @@ describe('Observable', () => {
53
53
  { id: 'i3', type: 'hit', value: 'hit-003', name: 'third-hit' }
54
54
  ]
55
55
  });
56
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001', 'obs-002', 'hit-003'] }), case: _case }) }));
56
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
57
+ sources: [
58
+ { id: 'hit-001', type: 'hit', label: 'first-hit' },
59
+ { id: 'obs-002', type: 'event', label: 'obs-two' },
60
+ { id: 'hit-003', type: 'hit', label: 'third-hit' }
61
+ ]
62
+ }), case: _case }) }));
57
63
  expect(screen.getByText('first-hit')).toBeTruthy();
58
64
  expect(screen.getByText('obs-two')).toBeTruthy();
59
65
  expect(screen.getByText('third-hit')).toBeTruthy();
@@ -63,7 +69,9 @@ describe('Observable', () => {
63
69
  case_id: 'case-abc',
64
70
  items: [{ id: 'item-1', type: 'hit', value: 'hit-001', name: 'first-hit' }]
65
71
  });
66
- render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({ seenIn: ['hit-001'] }), case: _case }) }));
72
+ render(_jsx(MemoryRouter, { children: _jsx(Observable, { observable: makeObservable({
73
+ sources: [{ id: 'hit-001', type: 'hit', path: 'first-hit', label: 'first-hit' }]
74
+ }), case: _case }) }));
67
75
  const link = screen.getByText('first-hit').closest('a');
68
76
  expect(link).not.toBeNull();
69
77
  expect(link?.getAttribute('href')).toBe('/cases/case-abc/first-hit');
@@ -20,7 +20,7 @@ const getSortValue = (observable, column) => {
20
20
  case 'role':
21
21
  return observable.role ?? '';
22
22
  case 'seen_in':
23
- return observable.seenIn.length;
23
+ return observable.sources?.length ?? 0;
24
24
  case 'escalation':
25
25
  return (observable.sources ?? [])
26
26
  .map(s => s.escalation)
@@ -55,7 +55,7 @@ const ObservableTable = ({ observables, case: _case }) => {
55
55
  const sources = observable.sources ?? [];
56
56
  const escalations = [...new Set(sources.map(s => s.escalation).filter(Boolean))];
57
57
  const linkedSources = sources.filter(source => source.path);
58
- return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }) }), _jsx(TableCell, { sx: { maxWidth: 300 }, children: _jsx(PluginTypography, { value: observable.value, context: "table", variant: "body2", sx: { fontFamily: 'monospace', wordBreak: 'break-all' } }) }), _jsx(TableCell, { children: observable.role && (_jsx(Chip, { size: "small", label: t(`page.cases.observables.role.${observable.role}`), color: ROLE_COLORS[observable.role], variant: "outlined" })) }), _jsx(TableCell, { align: "right", children: _jsx(Typography, { variant: "body2", children: observable.seenIn.length }) }), _jsx(TableCell, { children: linkedSources.length === 0 ? null : linkedSources.length === 1 ? (_jsx(Chip, { clickable: true, size: "small", label: linkedSources[0].label ?? linkedSources[0].path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${linkedSources[0].path}` })) : (_jsx(ChipPopper, { label: `${linkedSources[0].label ?? linkedSources[0].path} (+${linkedSources.length - 1})`, slotProps: { chip: { size: 'small', variant: 'outlined' } }, children: _jsx(Stack, { gap: 0.5, children: linkedSources.map(source => (_jsx(Chip, { clickable: true, size: "small", label: source.label ?? source.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${source.path}` }, source.id))) }) })) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: escalations.map(esc => (_jsx(Chip, { size: "small", label: esc, color: ESCALATION_COLORS[esc] ?? 'default' }, esc))) }) })] }, `${observable.type}:${observable.value}`));
58
+ return (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsx(Chip, { size: "small", label: t(`page.cases.observables.type.${observable.type}`), color: "primary", variant: "outlined" }) }), _jsx(TableCell, { sx: { maxWidth: 300 }, children: _jsx(PluginTypography, { value: observable.value, context: "table", variant: "body2", sx: { fontFamily: 'monospace', wordBreak: 'break-all' } }) }), _jsx(TableCell, { children: observable.role && (_jsx(Chip, { size: "small", label: t(`page.cases.observables.role.${observable.role}`), color: ROLE_COLORS[observable.role], variant: "outlined" })) }), _jsx(TableCell, { align: "right", children: _jsx(Typography, { variant: "body2", children: sources.length }) }), _jsx(TableCell, { children: linkedSources.length === 0 ? null : linkedSources.length === 1 ? (_jsx(Chip, { clickable: true, size: "small", label: linkedSources[0].label ?? linkedSources[0].path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${linkedSources[0].path}` })) : (_jsx(ChipPopper, { label: `${linkedSources[0].label ?? linkedSources[0].path} (+${linkedSources.length - 1})`, slotProps: { chip: { size: 'small', variant: 'outlined' } }, children: _jsx(Stack, { gap: 0.5, children: linkedSources.map(source => (_jsx(Chip, { clickable: true, size: "small", label: source.label ?? source.path, variant: "outlined", component: Link, to: `/cases/${_case.case_id}/${source.path}` }, source.id))) }) })) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", flexWrap: "wrap", gap: 0.5, children: escalations.map(esc => (_jsx(Chip, { size: "small", label: esc, color: ESCALATION_COLORS[esc] ?? 'default' }, esc))) }) })] }, `${observable.type}:${observable.value}`));
59
59
  }) })] }) }));
60
60
  };
61
61
  export default memo(ObservableTable);
@@ -1,5 +1,4 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- /// <reference types="vitest" />
3
2
  import { render, screen, within } from '@testing-library/react';
4
3
  import userEvent from '@testing-library/user-event';
5
4
  import { createElement } from 'react';
@@ -16,7 +15,7 @@ vi.mock('components/elements/PluginTypography', () => ({
16
15
  const makeObservable = (overrides = {}) => ({
17
16
  type: 'ip',
18
17
  value: '192.168.1.1',
19
- seenIn: [],
18
+ sources: [],
20
19
  ...overrides
21
20
  });
22
21
  const renderTable = (observables) => {
@@ -38,12 +37,25 @@ describe('ObservableTable', () => {
38
37
  await user.click(screen.getByText('page.cases.observables.columns.type'));
39
38
  expect(getObservableValues()).toEqual(['bravo', 'charlie', 'alpha']);
40
39
  });
41
- it('sorts observables numerically by their seen-in count', async () => {
40
+ it('sorts observables numerically by their source count', async () => {
42
41
  const user = userEvent.setup();
43
42
  renderTable([
44
- makeObservable({ value: 'three', seenIn: ['one', 'two', 'three'] }),
45
- makeObservable({ value: 'one', seenIn: ['one'] }),
46
- makeObservable({ value: 'two', seenIn: ['one', 'two'] })
43
+ makeObservable({
44
+ value: 'three',
45
+ sources: [
46
+ { id: 'one', type: 'hit' },
47
+ { id: 'two', type: 'hit' },
48
+ { id: 'three', type: 'hit' }
49
+ ]
50
+ }),
51
+ makeObservable({ value: 'one', sources: [{ id: 'one', type: 'hit' }] }),
52
+ makeObservable({
53
+ value: 'two',
54
+ sources: [
55
+ { id: 'one', type: 'hit' },
56
+ { id: 'two', type: 'hit' }
57
+ ]
58
+ })
47
59
  ]);
48
60
  await user.click(screen.getByText('page.cases.observables.columns.seen_in'));
49
61
  expect(getObservableValues()).toEqual(['one', 'two', 'three']);
@@ -67,9 +79,9 @@ describe('ObservableTable', () => {
67
79
  { id: 'source-1', type: 'hit', path: 'alerts/one', label: 'Alert one', escalation: 'evidence' },
68
80
  {
69
81
  id: 'source-2',
70
- type: 'observable',
71
- path: 'observables/two',
72
- label: 'Observable two',
82
+ type: 'event',
83
+ path: 'events/two',
84
+ label: 'Event two',
73
85
  escalation: 'evidence'
74
86
  },
75
87
  { id: 'source-3', type: 'case', escalation: 'malicious' }
@@ -80,6 +92,6 @@ describe('ObservableTable', () => {
80
92
  expect(screen.getAllByText('evidence')).toHaveLength(1);
81
93
  expect(screen.getByText('malicious')).toBeInTheDocument();
82
94
  await user.click(screen.getByText('Alert one (+1)'));
83
- expect(screen.getByText('Observable two').closest('a')).toHaveAttribute('href', '/cases/case-123/observables/two');
95
+ expect(screen.getByText('Event two').closest('a')).toHaveAttribute('href', '/cases/case-123/events/two');
84
96
  });
85
97
  });
@@ -4,7 +4,7 @@ export type ObservableRole = 'threat' | 'target' | 'indicator';
4
4
 
5
5
  export interface ObservableSource {
6
6
  id: string;
7
- type: 'hit' | 'observable' | 'case';
7
+ type: 'hit' | 'event' | 'case';
8
8
  path?: string;
9
9
  label?: string;
10
10
  escalation?: string;
@@ -13,12 +13,10 @@ export interface ObservableSource {
13
13
  export interface ObservableEntry {
14
14
  type: ObservableType;
15
15
  value: string;
16
- /** IDs of the hits/observables this observable was seen in */
17
- seenIn: string[];
18
16
  /** Resolved source metadata for each seenIn item */
19
17
  sources?: ObservableSource[];
20
18
  /** Classified role of this observable */
21
19
  role?: ObservableRole;
22
20
  }
23
21
 
24
- export type OriginType = 'hit' | 'observable';
22
+ export type OriginType = 'hit' | 'event';
@@ -6,13 +6,12 @@ import type { ObservableEntry, ObservableRole, ObservableSource, ObservableType
6
6
  /** All Related fields that carry asset values */
7
7
  export declare const OBSERVABLE_FIELDS: ObservableType[];
8
8
  /** Extract (type, value, seenInId) triples from a record's related field */
9
- export declare const extractObservables: (related: Related | undefined, recordId: string) => {
9
+ export declare const extractObservables: (related: Related | undefined) => {
10
10
  type: ObservableType;
11
11
  value: string;
12
- id: string;
13
12
  }[];
14
- /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
15
- export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
13
+ /** Deduplicate observables and resolve their record IDs to case-item sources */
14
+ export declare const buildObservableEntries: (_case: Case, records: (Hit | Event)[]) => ObservableEntry[];
16
15
  /**
17
16
  * Classify an asset's role based on case-level lists and per-record outline fields.
18
17
  *
@@ -25,5 +24,5 @@ export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) =
25
24
  * Comparison is case-insensitive and trimmed.
26
25
  */
27
26
  export declare const classifyRole: (value: string, _case: Case, records: Partial<Hit | Event>[]) => ObservableRole;
28
- /** Resolve source metadata for an asset's seenIn IDs */
29
- export declare const resolveSources: (seenIn: string[], caseItems: Case['items'], escalationMap: Map<string, string>) => ObservableSource[];
27
+ /** Resolve source metadata for an observable's record IDs */
28
+ export declare const resolveSource: (record: Hit | Event, _case: Case) => ObservableSource;
@@ -1,7 +1,9 @@
1
+ import { has } from 'lodash-es';
2
+ import { buildPathFromID } from '../utils';
1
3
  /** All Related fields that carry asset values */
2
4
  export const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
3
5
  /** Extract (type, value, seenInId) triples from a record's related field */
4
- export const extractObservables = (related, recordId) => {
6
+ export const extractObservables = (related) => {
5
7
  if (!related) {
6
8
  return [];
7
9
  }
@@ -14,33 +16,38 @@ export const extractObservables = (related, recordId) => {
14
16
  const values = Array.isArray(raw) ? raw : [raw];
15
17
  for (const value of values) {
16
18
  if (value) {
17
- results.push({ type: field, value: String(value), id: recordId });
19
+ results.push({ type: field, value: String(value) });
18
20
  }
19
21
  }
20
22
  }
21
23
  return results;
22
24
  };
23
- /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
24
- export const buildObservableEntries = (records) => {
25
- const map = new Map();
25
+ /** Deduplicate observables and resolve their record IDs to case-item sources */
26
+ export const buildObservableEntries = (_case, records) => {
27
+ const map = {};
26
28
  for (const record of records) {
27
29
  const related = record.related ?? record.related;
28
30
  const recordId = record.howler?.id ?? record.howler?.id;
29
31
  if (!recordId) {
30
32
  continue;
31
33
  }
32
- for (const { type, value, id } of extractObservables(related, recordId)) {
34
+ for (const { type, value } of extractObservables(related)) {
33
35
  const key = `${type}:${value}`;
34
- if (!map.has(key)) {
35
- map.set(key, { type, value, seenIn: [] });
36
+ if (!has(map, key)) {
37
+ map[key] = { type, value, sources: [], role: classifyRole(value, _case, records) };
36
38
  }
37
- const entry = map.get(key);
38
- if (!entry.seenIn.includes(id)) {
39
- entry.seenIn.push(id);
39
+ const entry = map[key];
40
+ if (entry.sources.some(existingSource => existingSource.id === recordId)) {
41
+ continue;
40
42
  }
43
+ const source = resolveSource(record, _case);
44
+ if (!source) {
45
+ continue;
46
+ }
47
+ entry.sources.push(source);
41
48
  }
42
49
  }
43
- return Array.from(map.values());
50
+ return Object.values(map).filter(Boolean);
44
51
  };
45
52
  /**
46
53
  * Classify an asset's role based on case-level lists and per-record outline fields.
@@ -91,24 +98,20 @@ export const classifyRole = (value, _case, records) => {
91
98
  // Default: assets from related.* are IOCs
92
99
  return 'indicator';
93
100
  };
94
- /** Resolve source metadata for an asset's seenIn IDs */
95
- export const resolveSources = (seenIn, caseItems, escalationMap) => {
96
- if (!caseItems?.length) {
97
- return [];
101
+ /** Resolve source metadata for an observable's record IDs */
102
+ export const resolveSource = (record, _case) => {
103
+ if (!_case?.items?.length) {
104
+ return null;
98
105
  }
99
- return seenIn
100
- .map(id => {
101
- const item = caseItems.find(i => i.value === id);
102
- if (!item) {
103
- return null;
104
- }
105
- return {
106
- id,
107
- type: item.type,
108
- path: item.id,
109
- label: item.name ?? item.value,
110
- escalation: escalationMap.get(id)
111
- };
112
- })
113
- .filter(Boolean);
106
+ const item = _case.items.find(i => i.value === record.howler.id);
107
+ if (!item) {
108
+ return null;
109
+ }
110
+ return {
111
+ id: record.howler.id,
112
+ type: item.type,
113
+ path: item.id ? buildPathFromID(_case, item.id) : undefined,
114
+ label: item.name ?? item.value,
115
+ escalation: record.howler.escalation
116
+ };
114
117
  };
@@ -6,50 +6,72 @@ import { buildObservableEntries, classifyRole } from './utils';
6
6
  // ---------------------------------------------------------------------------
7
7
  describe('buildObservableEntries', () => {
8
8
  it('returns an empty array for records with no related field', () => {
9
- expect(buildObservableEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
10
- });
11
- it('extracts a single IP from a hit', () => {
12
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
9
+ expect(buildObservableEntries(createMockCase(), [createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
10
+ });
11
+ it('extracts a single IP with a resolved source', () => {
12
+ const _case = createMockCase({
13
+ items: [{ id: 'hit-item', type: 'hit', value: 'h1', name: 'First hit' }]
14
+ });
15
+ const result = buildObservableEntries(_case, [
16
+ createMockHit({ howler: { id: 'h1', escalation: 'evidence' }, related: { ip: ['1.2.3.4'] } })
17
+ ]);
13
18
  expect(result).toHaveLength(1);
14
- expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
19
+ expect(result[0]).toEqual({
20
+ type: 'ip',
21
+ value: '1.2.3.4',
22
+ role: 'indicator',
23
+ sources: [{ id: 'h1', type: 'hit', path: 'First hit', label: 'First hit', escalation: 'evidence' }]
24
+ });
15
25
  });
16
26
  it('extracts multiple fields from a single record', () => {
17
- const result = buildObservableEntries([
27
+ const result = buildObservableEntries(createMockCase(), [
18
28
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
19
29
  ]);
20
30
  const types = result.map(a => a.type).sort();
21
31
  expect(types).toEqual(['ip', 'user']);
22
32
  });
23
33
  it('deduplicates the same observable value across multiple records', () => {
24
- const result = buildObservableEntries([
34
+ const _case = createMockCase({
35
+ items: [
36
+ { id: 'hit-item', type: 'hit', value: 'h1', name: 'First hit' },
37
+ { id: 'event-item', type: 'event', value: 'obs1', name: 'First event' }
38
+ ]
39
+ });
40
+ const result = buildObservableEntries(_case, [
25
41
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
26
42
  createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
27
43
  ]);
28
44
  expect(result).toHaveLength(1);
29
- expect(result[0].seenIn).toEqual(['h1', 'obs1']);
45
+ expect(result[0].sources).toEqual([
46
+ { id: 'h1', type: 'hit', path: 'First hit', label: 'First hit', escalation: undefined },
47
+ { id: 'obs1', type: 'event', path: 'First event', label: 'First event', escalation: undefined }
48
+ ]);
30
49
  });
31
50
  it('keeps distinct observable values as separate entries', () => {
32
- const result = buildObservableEntries([
51
+ const result = buildObservableEntries(createMockCase(), [
33
52
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
34
53
  createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
35
54
  ]);
36
55
  expect(result).toHaveLength(2);
37
56
  });
38
- it('does not duplicate seenIn ids when the same record appears twice for the same observable', () => {
39
- const result = buildObservableEntries([
57
+ it('does not duplicate sources when the same record appears twice for the same observable', () => {
58
+ const _case = createMockCase({ items: [{ id: 'hit-item', type: 'hit', value: 'h1' }] });
59
+ const result = buildObservableEntries(_case, [
40
60
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
41
61
  createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
42
62
  ]);
43
- expect(result[0].seenIn).toEqual(['h1']);
63
+ expect(result[0].sources).toEqual([{ id: 'h1', type: 'hit', path: 'h1', label: 'h1', escalation: undefined }]);
44
64
  });
45
65
  it('skips records with no howler.id', () => {
46
66
  const noId = { related: { ip: ['1.2.3.4'] } };
47
- expect(buildObservableEntries([noId])).toEqual([]);
67
+ expect(buildObservableEntries(createMockCase(), [noId])).toEqual([]);
48
68
  });
49
69
  it('handles the scalar `id` field on Related', () => {
50
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
70
+ const result = buildObservableEntries(createMockCase(), [
71
+ createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })
72
+ ]);
51
73
  expect(result).toHaveLength(1);
52
- expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
74
+ expect(result[0]).toEqual({ type: 'id', value: 'some-id', role: 'indicator', sources: [] });
53
75
  });
54
76
  it('handles array fields like hash, hosts, user, ids, uri, signature', () => {
55
77
  const related = {
@@ -60,7 +82,7 @@ describe('buildObservableEntries', () => {
60
82
  uri: ['https://example.com'],
61
83
  signature: ['rule-X']
62
84
  };
63
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
85
+ const result = buildObservableEntries(createMockCase(), [createMockHit({ howler: { id: 'h1' }, related })]);
64
86
  const types = result.map(a => a.type).sort();
65
87
  expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
66
88
  });
package/package.json CHANGED
@@ -93,7 +93,7 @@
93
93
  "url": "https://github.com/CybercentreCanada/howler"
94
94
  },
95
95
  "type": "module",
96
- "version": "2.19.0-dev.1320",
96
+ "version": "2.19.0-dev.1344",
97
97
  "exports": {
98
98
  "./i18n": "./i18n.js",
99
99
  "./index.css": "./index.css",