@cccsaurora/howler-ui 2.19.0-cases.1122 → 2.19.0-cases.1131

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.
@@ -2,5 +2,6 @@ import type { FC } from 'react';
2
2
  declare const QueryResultText: FC<{
3
3
  count: number;
4
4
  query: string;
5
+ mb?: number;
5
6
  }>;
6
7
  export default QueryResultText;
@@ -2,13 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Typography } from '@mui/material';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { Link } from 'react-router-dom';
5
- const QueryResultText = ({ count, query }) => {
5
+ const QueryResultText = ({ count, query, mb = 0.5 }) => {
6
6
  const { t } = useTranslation();
7
7
  return (_jsxs(Typography, { sx: theme => ({
8
8
  color: theme.palette.text.secondary,
9
9
  fontSize: '0.9em',
10
10
  fontStyle: 'italic',
11
- mb: 0.5,
11
+ mb,
12
12
  '& a': { color: theme.palette.text.secondary }
13
13
  }), variant: "body2", children: [t('search.total', { count }), " ", _jsx(Link, { to: `/hits?query=${encodeURIComponent(query)}`, children: t('search.open') })] }));
14
14
  };
@@ -1,9 +1,4 @@
1
1
  import type { Case } from '@cccsaurora/howler-ui/models/entities/generated/Case';
2
- import type { Event } from '@cccsaurora/howler-ui/models/entities/generated/Event';
3
- import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
4
- import { type ObservableEntry } from './observables/Observable';
5
- /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
6
- export declare const buildObservableEntries: (records: Partial<Hit | Event>[]) => ObservableEntry[];
7
2
  declare const _default: import("react").NamedExoticComponent<{
8
3
  case?: Case;
9
4
  caseId?: string;
@@ -1,64 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Chip, Grid, Skeleton, Stack, Typography } from '@mui/material';
2
+ import { FilterList } from '@mui/icons-material';
3
+ import { Chip, Grid, Skeleton, Stack, TextField, Typography } from '@mui/material';
3
4
  import api from '@cccsaurora/howler-ui/api';
5
+ import ChipPopper from '@cccsaurora/howler-ui/components/elements/display/ChipPopper';
4
6
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
7
+ import { toArray, uniq } from 'lodash-es';
5
8
  import { memo, useEffect, useMemo, useState } from 'react';
6
9
  import { useTranslation } from 'react-i18next';
7
10
  import { useOutletContext } from 'react-router-dom';
8
11
  import useCase from '../hooks/useCase';
9
- import Observable, {} from './observables/Observable';
10
- /** All Related fields that carry observable values */
11
- const OBSERVABLE_FIELDS = ['hash', 'hosts', 'ip', 'user', 'ids', 'id', 'uri', 'signature'];
12
- /** Extract (type, value, seenInId) triples from a record's related field */
13
- const extractObservables = (related, recordId) => {
14
- if (!related) {
15
- return [];
16
- }
17
- const results = [];
18
- for (const field of OBSERVABLE_FIELDS) {
19
- const raw = related[field];
20
- if (!raw) {
21
- continue;
22
- }
23
- const values = Array.isArray(raw) ? raw : [raw];
24
- for (const value of values) {
25
- if (value) {
26
- results.push({ type: field, value: String(value), id: recordId });
27
- }
28
- }
29
- }
30
- return results;
31
- };
32
- /** Deduplicate and merge seenIn lists into a map keyed by `type:value` */
33
- export const buildObservableEntries = (records) => {
34
- const map = new Map();
35
- for (const record of records) {
36
- const related = record.related ?? record.related;
37
- const recordId = record.howler?.id ?? record.howler?.id;
38
- if (!recordId) {
39
- continue;
40
- }
41
- for (const { type, value, id } of extractObservables(related, recordId)) {
42
- const key = `${type}:${value}`;
43
- if (!map.has(key)) {
44
- map.set(key, { type, value, seenIn: [] });
45
- }
46
- const entry = map.get(key);
47
- if (!entry.seenIn.includes(id)) {
48
- entry.seenIn.push(id);
49
- }
50
- }
51
- }
52
- return Array.from(map.values());
53
- };
12
+ import ObservableTable from './observables/ObservableTable';
13
+ import { OBSERVABLE_FIELDS, buildObservableEntries, classifyRole, resolveSources } from './utils';
54
14
  const RELATED_FIELDS = OBSERVABLE_FIELDS.map(f => `related.${f}`).join(',');
15
+ const EXTRA_FIELDS = 'howler.escalation,howler.outline.threat,howler.outline.target,howler.outline.indicators,threat.indicator.ip,threat.indicator.description';
55
16
  const CaseObservables = ({ case: providedCase, caseId }) => {
56
17
  const { t } = useTranslation();
57
18
  const { dispatchApi } = useMyApi();
58
19
  const routeCase = useOutletContext();
59
20
  const { case: _case } = useCase({ case: providedCase ?? routeCase, caseId });
60
21
  const [records, setRecords] = useState(null);
61
- const [activeFilters, setActiveFilters] = useState(new Set());
22
+ const [activeFilters, setActiveFilters] = useState([]);
23
+ const [searchQuery, setSearchQuery] = useState('');
24
+ const [originFilters, setOriginFilters] = useState([]);
25
+ const [roleFilters, setRoleFilters] = useState([]);
26
+ const [escalationOptions, setEscalationOptions] = useState([]);
27
+ const [activeEscalations, setActiveEscalations] = useState([]);
28
+ const [escalationMap, setEscalationMap] = useState(new Map());
62
29
  const ids = useMemo(() => (_case?.items ?? [])
63
30
  .filter(item => ['hit', 'event'].includes(item.type))
64
31
  .map(item => item.value)
@@ -70,35 +37,106 @@ const CaseObservables = ({ case: providedCase, caseId }) => {
70
37
  }
71
38
  dispatchApi(api.v2.search.post(['hit', 'event'], {
72
39
  query: `howler.id:(${ids.join(' OR ')})`,
73
- fl: `howler.id,${RELATED_FIELDS}`
74
- })).then(response => setRecords(response.items));
40
+ 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
+ });
75
54
  }, [dispatchApi, ids]);
76
- const allObservables = useMemo(() => (records ? buildObservableEntries(records) : []), [records]);
77
- const observableTypes = useMemo(() => (allObservables ? [...new Set(allObservables.map(a => a.type))].sort() : []), [allObservables]);
55
+ useEffect(() => {
56
+ if (ids.length < 1) {
57
+ setEscalationOptions([]);
58
+ setActiveEscalations([]);
59
+ return;
60
+ }
61
+ dispatchApi(api.v2.search.facet.post(['hit', 'event'], {
62
+ fields: ['howler.escalation'],
63
+ filters: [`howler.id:(${ids.join(' OR ')})`]
64
+ }), { throwError: false }).then(result => {
65
+ if (result) {
66
+ setEscalationOptions(Object.keys(result['howler.escalation'] ?? {}));
67
+ }
68
+ });
69
+ }, [dispatchApi, ids]);
70
+ const allObservables = useMemo(() => {
71
+ if (!records || !_case) {
72
+ return [];
73
+ }
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]);
81
+ const observableTypes = useMemo(() => (allObservables ? uniq(allObservables.map(a => a.type)).sort() : []), [allObservables]);
78
82
  const filteredObservables = useMemo(() => {
79
83
  if (allObservables.length < 1) {
80
84
  return [];
81
85
  }
82
- if (activeFilters.size === 0) {
83
- return allObservables;
86
+ let result = allObservables;
87
+ if (activeFilters.length > 0) {
88
+ result = result.filter(a => activeFilters.includes(a.type));
89
+ }
90
+ if (searchQuery.trim()) {
91
+ const query = searchQuery.trim().toLowerCase();
92
+ result = result.filter(a => a.value.toLowerCase().includes(query) || a.type.toLowerCase().includes(query));
93
+ }
94
+ if (originFilters.length > 0) {
95
+ result = result.filter(a => a.sources?.some(s => originFilters.includes(s.type)));
96
+ }
97
+ if (activeEscalations.length > 0) {
98
+ result = result.filter(a => a.sources?.some(s => s.escalation && activeEscalations.includes(s.escalation)));
99
+ }
100
+ if (roleFilters.length > 0) {
101
+ result = result.filter(a => a.role && roleFilters.includes(a.role));
84
102
  }
85
- return allObservables.filter(a => activeFilters.has(a.type));
86
- }, [allObservables, activeFilters]);
87
- const toggleFilter = (type) => {
88
- setActiveFilters(prev => {
89
- const next = new Set(prev);
90
- if (next.has(type)) {
91
- next.delete(type);
103
+ return result;
104
+ }, [allObservables, activeFilters, searchQuery, originFilters, activeEscalations, roleFilters]);
105
+ const toggleSetItem = (setter, item) => {
106
+ setter(prev => {
107
+ if (prev.includes(item)) {
108
+ return prev.filter(_item => _item !== item);
92
109
  }
93
110
  else {
94
- next.add(type);
111
+ return [...prev, item];
95
112
  }
96
- return next;
97
113
  });
98
114
  };
99
115
  if (!_case) {
100
116
  return null;
101
117
  }
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.observables.filter_by_type') }), records === null ? (_jsx(Skeleton, { width: 240, height: 32 })) : (observableTypes.map(type => (_jsx(Chip, { label: t(`page.cases.observables.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}`)))) : filteredObservables.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.observables.empty') }) })) : (filteredObservables.map(observable => (_jsx(Grid, { item: true, xs: 12, md: 6, xl: 4, children: _jsx(Observable, { asset: observable, case: _case }) }, `${observable.type}:${observable.value}`))))] }));
118
+ return (_jsxs(Grid, { container: true, spacing: 2, px: 2, children: [_jsx(Grid, { item: true, xs: 12, children: _jsxs(Stack, { spacing: 1.5, children: [_jsx(TextField, { size: "small", placeholder: t('page.cases.observables.search'), value: searchQuery, onChange: e => setSearchQuery(e.target.value), sx: { maxWidth: 360 }, id: "observable-search" }), _jsxs(Stack, { direction: "row", spacing: 1, flexWrap: "wrap", children: [_jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: activeFilters.length > 0
119
+ ? `${t('page.cases.observables.filter_by_type')} (${toArray(activeFilters)
120
+ .map(type => t(`page.cases.observables.type.${type}`))
121
+ .sort()
122
+ .join(', ')})`
123
+ : t('page.cases.observables.filter_by_type'), slotProps: {
124
+ chip: { size: 'small', color: activeFilters.length > 0 ? 'primary' : 'default' }
125
+ }, children: records === null ? (_jsx(Skeleton, { width: 200, height: 32 })) : (_jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: observableTypes.map(type => (_jsx(Chip, { label: t(`page.cases.observables.type.${type}`), size: "small", onClick: () => toggleSetItem(setActiveFilters, type), color: activeFilters.includes(type) ? 'primary' : 'default', variant: activeFilters.includes(type) ? 'filled' : 'outlined' }, type))) })) }), _jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: originFilters.length > 0
126
+ ? `${t('page.cases.observables.filter_by_origin')} (${toArray(originFilters).join(', ')})`
127
+ : t('page.cases.observables.filter_by_origin'), slotProps: {
128
+ chip: { size: 'small', color: originFilters.length > 0 ? 'primary' : 'default' }
129
+ }, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: ['hit', 'event'].map(origin => (_jsx(Chip, { label: t(`page.cases.observables.origin.${origin}`), size: "small", onClick: () => toggleSetItem(setOriginFilters, origin), color: originFilters.includes(origin) ? 'primary' : 'default', variant: originFilters.includes(origin) ? 'filled' : 'outlined' }, origin))) }) }), _jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: roleFilters.length > 0
130
+ ? `${t('page.cases.observables.filter_by_role')} (${toArray(roleFilters).join(', ')})`
131
+ : t('page.cases.observables.filter_by_role'), slotProps: {
132
+ chip: { size: 'small', color: roleFilters.length > 0 ? 'primary' : 'default' }
133
+ }, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: ['threat', 'target', 'indicator'].map(role => (_jsx(Chip, { label: t(`page.cases.observables.role.${role}`), size: "small", onClick: () => toggleSetItem(setRoleFilters, role), color: roleFilters.includes(role) ? 'primary' : 'default', variant: roleFilters.includes(role) ? 'filled' : 'outlined' }, role))) }) }), escalationOptions.length > 0 && (_jsx(ChipPopper, { icon: _jsx(FilterList, {}), label: activeEscalations.length > 0
134
+ ? `${t('page.cases.observables.filter_by_escalation')} (${toArray(activeEscalations).join(', ')})`
135
+ : t('page.cases.observables.filter_by_escalation'), slotProps: {
136
+ chip: {
137
+ size: 'small',
138
+ color: activeEscalations.length > 0 ? 'primary' : 'default'
139
+ }
140
+ }, children: _jsx(Stack, { direction: "row", gap: 0.5, flexWrap: "wrap", children: escalationOptions.map(esc => (_jsx(Chip, { label: esc, size: "small", onClick: () => toggleSetItem(setActiveEscalations, esc), color: activeEscalations.includes(esc) ? 'primary' : 'default', variant: activeEscalations.includes(esc) ? 'filled' : 'outlined' }, esc))) }) }))] })] }) }), records === null ? (Array.from({ length: 6 }, (_, i) => (_jsx(Grid, { item: true, xs: 12, children: _jsx(Skeleton, { height: 40 }) }, `skeleton-${i}`)))) : filteredObservables.length === 0 ? (_jsx(Grid, { item: true, xs: 12, children: _jsx(Typography, { color: "text.secondary", children: t('page.cases.observables.empty') }) })) : (_jsx(Grid, { item: true, xs: 12, children: _jsx(ObservableTable, { observables: filteredObservables, case: _case }) }))] }));
103
141
  };
104
142
  export default memo(CaseObservables);
@@ -1,76 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  /// <reference types="vitest" />
3
- import { render, screen } from '@testing-library/react';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
4
  import userEvent from '@testing-library/user-event';
5
5
  import { createElement } from 'react';
6
6
  import { MemoryRouter } from 'react-router-dom';
7
- import { createMockEvent, createMockHit } from '@cccsaurora/howler-ui/tests/utils';
8
7
  import { describe, expect, it, vi } from 'vitest';
9
- import { buildObservableEntries } from './CaseObservables';
10
- // ---------------------------------------------------------------------------
11
- // Pure logic tests — no React needed
12
- // ---------------------------------------------------------------------------
13
- describe('buildObservableEntries', () => {
14
- it('returns an empty array for records with no related field', () => {
15
- expect(buildObservableEntries([createMockHit({ howler: { id: 'h1' } })])).toEqual([]);
16
- });
17
- it('extracts a single IP from a hit', () => {
18
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })]);
19
- expect(result).toHaveLength(1);
20
- expect(result[0]).toEqual({ type: 'ip', value: '1.2.3.4', seenIn: ['h1'] });
21
- });
22
- it('extracts multiple fields from a single record', () => {
23
- const result = buildObservableEntries([
24
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } })
25
- ]);
26
- const types = result.map(a => a.type).sort();
27
- expect(types).toEqual(['ip', 'user']);
28
- });
29
- it('deduplicates the same asset value across multiple records', () => {
30
- const result = buildObservableEntries([
31
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
32
- createMockEvent({ howler: { id: 'obs1' }, related: { ip: ['1.2.3.4'] } })
33
- ]);
34
- expect(result).toHaveLength(1);
35
- expect(result[0].seenIn).toEqual(['h1', 'obs1']);
36
- });
37
- it('keeps distinct asset values as separate entries', () => {
38
- const result = buildObservableEntries([
39
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
40
- createMockHit({ howler: { id: 'h2' }, related: { ip: ['5.6.7.8'] } })
41
- ]);
42
- expect(result).toHaveLength(2);
43
- });
44
- it('does not duplicate seenIn ids when the same record appears twice for the same asset', () => {
45
- const result = buildObservableEntries([
46
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } }),
47
- createMockHit({ howler: { id: 'h1' }, related: { ip: ['1.2.3.4'] } })
48
- ]);
49
- expect(result[0].seenIn).toEqual(['h1']);
50
- });
51
- it('skips records with no howler.id', () => {
52
- const noId = { related: { ip: ['1.2.3.4'] } };
53
- expect(buildObservableEntries([noId])).toEqual([]);
54
- });
55
- it('handles the scalar `id` field on Related', () => {
56
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related: { id: 'some-id' } })]);
57
- expect(result).toHaveLength(1);
58
- expect(result[0]).toEqual({ type: 'id', value: 'some-id', seenIn: ['h1'] });
59
- });
60
- it('handles array fields like hash, hosts, user, ids, uri, signature', () => {
61
- const related = {
62
- hash: ['abc123'],
63
- hosts: ['host.example.com'],
64
- user: ['bob'],
65
- ids: ['guid-1'],
66
- uri: ['https://example.com'],
67
- signature: ['rule-X']
68
- };
69
- const result = buildObservableEntries([createMockHit({ howler: { id: 'h1' }, related })]);
70
- const types = result.map(a => a.type).sort();
71
- expect(types).toEqual(['hash', 'hosts', 'ids', 'signature', 'uri', 'user']);
72
- });
73
- });
74
8
  // ---------------------------------------------------------------------------
75
9
  // Component rendering tests
76
10
  // ---------------------------------------------------------------------------
@@ -81,14 +15,29 @@ vi.mock('react-i18next', () => ({
81
15
  vi.mock('components/hooks/useMyApi', () => ({
82
16
  default: () => ({ dispatchApi: mockDispatchApi })
83
17
  }));
18
+ vi.mock('api', () => ({
19
+ default: {
20
+ v2: {
21
+ search: {
22
+ post: vi.fn(() => Promise.resolve({ items: [] })),
23
+ facet: {
24
+ post: vi.fn(() => Promise.resolve({}))
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }));
84
30
  vi.mock('../hooks/useCase', () => ({
85
31
  default: ({ case: c }) => ({ case: c, updateCase: vi.fn(), loading: false, missing: false })
86
32
  }));
33
+ vi.mock('components/elements/PluginTypography', () => ({
34
+ default: ({ value }) => createElement('span', null, value)
35
+ }));
87
36
  const mockCase = {
88
37
  case_id: 'case-001',
89
38
  items: [
90
- { type: 'hit', value: 'hit-1' },
91
- { type: 'event', value: 'obs-1' }
39
+ { type: 'hit', value: 'hit-1', path: 'alerts/analytic-1 (hit-1)' },
40
+ { type: 'observable', value: 'obs-1', path: 'observables/obs-1' }
92
41
  ]
93
42
  };
94
43
  const Wrapper = ({ children }) => createElement(MemoryRouter, { initialEntries: ['/cases/case-001/observables'] }, children);
@@ -101,7 +50,6 @@ describe('CaseObservables component', () => {
101
50
  it('renders skeletons while records are loading', () => {
102
51
  mockDispatchApi.mockReturnValue(new Promise(() => { })); // never resolves
103
52
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
104
- // 6 skeleton cards
105
53
  const skeletons = document.querySelectorAll('.MuiSkeleton-root');
106
54
  expect(skeletons.length).toBeGreaterThan(0);
107
55
  });
@@ -112,34 +60,39 @@ describe('CaseObservables component', () => {
112
60
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
113
61
  await screen.findByText('page.cases.observables.empty');
114
62
  });
115
- it('renders observable cards for extracted observables', async () => {
63
+ it('renders observables in a table for extracted observables', async () => {
116
64
  mockDispatchApi.mockResolvedValue({
117
65
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
118
66
  });
119
67
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
120
68
  await screen.findByText('1.2.3.4');
121
69
  expect(screen.getByText('alice')).toBeTruthy();
70
+ // Table headers should be present
71
+ expect(screen.getByText('page.cases.observables.columns.type')).toBeTruthy();
72
+ expect(screen.getByText('page.cases.observables.columns.value')).toBeTruthy();
122
73
  });
123
- it('renders a filter chip for each observable type present', async () => {
74
+ it('renders a filter chip for each observable type present when popper is opened', async () => {
124
75
  mockDispatchApi.mockResolvedValue({
125
76
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
126
77
  });
127
78
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
128
- await screen.findAllByText('page.cases.observables.type.ip');
129
- expect(screen.getAllByText('page.cases.observables.type.ip')).toHaveLength(2);
130
- expect(screen.getAllByText('page.cases.observables.type.user')).toHaveLength(2);
79
+ await screen.findByText('1.2.3.4');
80
+ // Open the type filter popper
81
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
82
+ // Filter chips visible in popper + table = at least 2 of each type
83
+ expect(screen.getAllByText('page.cases.observables.type.ip').length).toBeGreaterThanOrEqual(2);
84
+ expect(screen.getAllByText('page.cases.observables.type.user').length).toBeGreaterThanOrEqual(2);
131
85
  });
132
86
  it('filters observables when a type chip is clicked', async () => {
133
87
  mockDispatchApi.mockResolvedValue({
134
88
  items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
135
89
  });
136
90
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
137
- // Wait for both observables to appear
138
91
  await screen.findByText('1.2.3.4');
139
92
  expect(screen.getByText('alice')).toBeTruthy();
140
- // Click the 'ip' filter chip
141
- await userEvent.click(screen.getByRole('button', { name: 'page.cases.observables.type.ip' }));
142
- // Alice (user) should be filtered out
93
+ // Open the type filter popper and click the first 'ip' chip (in the popper)
94
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
95
+ await userEvent.click(screen.getAllByText('page.cases.observables.type.ip')[0]);
143
96
  expect(screen.queryByText('alice')).toBeNull();
144
97
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
145
98
  });
@@ -149,16 +102,68 @@ describe('CaseObservables component', () => {
149
102
  });
150
103
  render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
151
104
  await screen.findByText('1.2.3.4');
152
- const ipChip = screen.getByRole('button', { name: 'page.cases.observables.type.ip' });
105
+ // Open the type filter popper
106
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_type'));
107
+ const ipChip = screen.getAllByText('page.cases.observables.type.ip')[0];
153
108
  await userEvent.click(ipChip);
154
109
  await userEvent.click(ipChip);
155
110
  expect(screen.getByText('1.2.3.4')).toBeTruthy();
156
111
  expect(screen.getByText('alice')).toBeTruthy();
157
112
  });
158
- it('renders nothing when the case has no hit/event items', async () => {
113
+ it('renders nothing when the case has no hit/observable items', async () => {
159
114
  const emptyCase = { case_id: 'case-002', items: [] };
160
115
  render(_jsx(CaseObservables, { case: emptyCase }), { wrapper: Wrapper });
161
116
  await screen.findByText('page.cases.observables.empty');
162
117
  expect(mockDispatchApi).not.toHaveBeenCalled();
163
118
  });
119
+ it('filters observables by search query', async () => {
120
+ mockDispatchApi.mockResolvedValue({
121
+ items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'], user: ['alice'] } }]
122
+ });
123
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
124
+ await screen.findByText('1.2.3.4');
125
+ const searchInput = screen.getByPlaceholderText('page.cases.observables.search');
126
+ await userEvent.type(searchInput, 'alice');
127
+ expect(screen.queryByText('1.2.3.4')).toBeNull();
128
+ expect(screen.getByText('alice')).toBeTruthy();
129
+ });
130
+ it('renders origin filter chips inside popper', async () => {
131
+ mockDispatchApi.mockResolvedValue({
132
+ items: [{ howler: { id: 'hit-1' }, related: { ip: ['1.2.3.4'] } }]
133
+ });
134
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
135
+ await screen.findByText('1.2.3.4');
136
+ // Open the origin filter popper
137
+ await userEvent.click(screen.getByText('page.cases.observables.filter_by_origin'));
138
+ expect(screen.getByText('page.cases.observables.origin.hit')).toBeTruthy();
139
+ expect(screen.getByText('page.cases.observables.origin.event')).toBeTruthy();
140
+ });
141
+ it('renders escalation filter chips from facet response', async () => {
142
+ mockDispatchApi
143
+ .mockResolvedValueOnce({
144
+ items: [{ howler: { id: 'hit-1', escalation: 'evidence' }, related: { ip: ['1.2.3.4'] } }]
145
+ })
146
+ .mockResolvedValueOnce({
147
+ 'howler.escalation': { evidence: 1 }
148
+ });
149
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
150
+ 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
+ });
155
+ });
156
+ it('classifies roles and renders role chips in the table', async () => {
157
+ mockDispatchApi.mockResolvedValue({
158
+ items: [
159
+ {
160
+ howler: { id: 'hit-1', outline: { threat: 'malware.exe' } },
161
+ related: { hosts: ['malware.exe'] }
162
+ }
163
+ ]
164
+ });
165
+ render(_jsx(CaseObservables, { case: mockCase }), { wrapper: Wrapper });
166
+ await screen.findByText('malware.exe');
167
+ expect(screen.getByText('page.cases.observables.role.threat')).toBeTruthy();
168
+ });
164
169
  });
@@ -5,7 +5,6 @@ import api from '@cccsaurora/howler-ui/api';
5
5
  import { ModalContext } from '@cccsaurora/howler-ui/components/app/providers/ModalProvider';
6
6
  import ConfirmDeleteModal from '@cccsaurora/howler-ui/components/elements/display/modals/ConfirmDeleteModal';
7
7
  import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
8
- import dayjs from 'dayjs';
9
8
  import { useCallback, useContext, useState } from 'react';
10
9
  import { useTranslation } from 'react-i18next';
11
10
  import { useOutletContext } from 'react-router-dom';
@@ -47,11 +46,10 @@ const CaseRules = ({ case: providedCase, caseId }) => {
47
46
  return null;
48
47
  }
49
48
  const rules = _case.rules ?? [];
50
- return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => {
51
- const _timeframe = rule.timeframe ? dayjs(rule.timeframe) : null;
52
- return (_jsxs(TableRow, { id: `rule-row-${rule.rule_id}`, children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: { fontFamily: 'monospace' }, children: rule.destination }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: {
53
- fontFamily: 'monospace'
54
- }, children: rule.query }) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", spacing: 0.5, children: (rule.indexes ?? ['hit']).map(idx => (_jsx(Chip, { size: "small", label: idx, variant: "outlined" }, idx))) }) }), _jsx(TableCell, { children: _timeframe ? (_jsx(Chip, { size: "small", label: _timeframe.format('YYYY-MM-DD HH:mm'), color: _timeframe.isBefore(dayjs()) ? 'error' : 'default' })) : (_jsx(Chip, { size: "small", label: t('page.cases.rules.no_expiry'), color: "success" })) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", children: rule.author }) }), _jsx(TableCell, { align: "center", children: _jsx(Switch, { id: `rule-toggle-${rule.rule_id}`, checked: rule.enabled ?? true, onChange: (_e, checked) => handleToggleEnabled(rule.rule_id, checked), size: "small" }) }), _jsx(TableCell, { align: "right", children: _jsx(Tooltip, { title: t('delete'), children: _jsx(IconButton, { id: `rule-delete-${rule.rule_id}`, size: "small", onClick: () => handleDeleteRule(rule.rule_id), children: _jsx(Delete, { fontSize: "small" }) }) }) })] }, rule.rule_id));
55
- }) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
49
+ return (_jsxs(Stack, { spacing: 2, sx: { p: 2, height: '100%', overflow: 'auto' }, children: [_jsx(Button, { id: "create-rule-button", variant: "outlined", startIcon: _jsx(Add, {}), onClick: () => setDialogOpen(true), sx: { alignSelf: 'end' }, children: t('page.cases.rules.create') }), _jsx(Card, { children: rules.length === 0 ? (_jsx(Typography, { m: 2, color: "textSecondary", textAlign: "center", children: t('page.cases.rules.empty') })) : (_jsx(TableContainer, { children: _jsxs(Table, { id: "rules-table", size: "small", children: [_jsx(TableHead, { children: _jsxs(TableRow, { children: [_jsx(TableCell, { children: t('page.cases.rules.destination') }), _jsx(TableCell, { children: t('page.cases.rules.query') }), _jsx(TableCell, { children: t('hit.search.index') }), _jsx(TableCell, { children: t('page.cases.rules.timeframe') }), _jsx(TableCell, { children: t('page.cases.rules.author') }), _jsx(TableCell, { align: "center", children: t('enabled') }), _jsx(TableCell, { align: "right" })] }) }), _jsx(TableBody, { children: rules.map(rule => (_jsxs(TableRow, { id: `rule-row-${rule.rule_id}`, children: [_jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: { fontFamily: 'monospace' }, children: rule.destination }) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", sx: {
50
+ fontFamily: 'monospace'
51
+ }, children: rule.query }) }), _jsx(TableCell, { children: _jsx(Stack, { direction: "row", spacing: 0.5, children: (rule.indexes ?? ['hit']).map(idx => (_jsx(Chip, { size: "small", label: idx, variant: "outlined" }, idx))) }) }), _jsx(TableCell, { children: rule.timeframe != null ? (_jsx(Chip, { size: "small", label: rule.expire_after_resolved
52
+ ? t('page.cases.rules.timeframe.after_resolved', { days: rule.timeframe })
53
+ : t('page.cases.rules.timeframe.days', { days: rule.timeframe }), color: rule.expire_after_resolved ? 'info' : 'default' })) : (_jsx(Chip, { size: "small", label: t('page.cases.rules.no_expiry'), color: "success" })) }), _jsx(TableCell, { children: _jsx(Typography, { variant: "body2", children: rule.author }) }), _jsx(TableCell, { align: "center", children: _jsx(Switch, { id: `rule-toggle-${rule.rule_id}`, checked: rule.enabled ?? true, onChange: (_e, checked) => handleToggleEnabled(rule.rule_id, checked), size: "small" }) }), _jsx(TableCell, { align: "right", children: _jsx(Tooltip, { title: t('delete'), children: _jsx(IconButton, { id: `rule-delete-${rule.rule_id}`, size: "small", onClick: () => handleDeleteRule(rule.rule_id), children: _jsx(Delete, { fontSize: "small" }) }) }) })] }, rule.rule_id))) })] }) })) }), _jsx(CreateRuleDialog, { open: dialogOpen, onClose: () => setDialogOpen(false), onSubmit: handleCreateRule })] }));
56
54
  };
57
55
  export default CaseRules;
@@ -68,7 +68,8 @@ const makeRule = (overrides) => ({
68
68
  destination: 'alerts/{{howler.analytic}}',
69
69
  author: 'analyst1',
70
70
  enabled: true,
71
- timeframe: '2026-06-01T00:00:00.000Z',
71
+ timeframe: 14,
72
+ expire_after_resolved: false,
72
73
  indexes: ['hit'],
73
74
  ...overrides
74
75
  });
@@ -104,6 +105,22 @@ describe('CaseRules', () => {
104
105
  render(_jsx(CaseRules, {}));
105
106
  expect(screen.getByText('page.cases.rules.no_expiry')).toBeInTheDocument();
106
107
  });
108
+ it('shows after-resolved chip when expire_after_resolved is true', () => {
109
+ mockCase.current = createMockCase({
110
+ case_id: 'case-001',
111
+ rules: [makeRule({ timeframe: 7, expire_after_resolved: true })]
112
+ });
113
+ render(_jsx(CaseRules, {}));
114
+ expect(screen.getByText('page.cases.rules.timeframe.after_resolved')).toBeInTheDocument();
115
+ });
116
+ it('shows days chip when expire_after_resolved is false', () => {
117
+ mockCase.current = createMockCase({
118
+ case_id: 'case-001',
119
+ rules: [makeRule({ timeframe: 14, expire_after_resolved: false })]
120
+ });
121
+ render(_jsx(CaseRules, {}));
122
+ expect(screen.getByText('page.cases.rules.timeframe.days')).toBeInTheDocument();
123
+ });
107
124
  it('renders enabled toggle for each rule', () => {
108
125
  mockCase.current = createMockCase({
109
126
  case_id: 'case-001',