Skip to content

Commit 8a940bf

Browse files
christinewengelasticmachine
authored andcommitted
[Cases] Add attachment type and author filter (elastic#272759)
## Summary This PR adds attachment type filter and author (created by) filter in the attachment tab. Note they are not added to Activity because it is going through a redesign, but the component is reusable. Also moved a type conversion from server to common so it can be used for both client and server. Attachment types without table (tabViewObject) is excluded from the type filter, this includes comments, osquery, endpoint etc. <img width="1293" height="611" alt="image" src="https://github.com/user-attachments/assets/70b58e0e-adc8-4a24-963e-89ab16cb2f1e" /> <img width="1298" height="684" alt="image" src="https://github.com/user-attachments/assets/55dacff6-0e57-4d55-a21b-2004403c6822" /> ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) - [x] Review the [backport guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing) and apply applicable `backport:*` labels. --------- Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
1 parent 923c584 commit 8a940bf

22 files changed

Lines changed: 1695 additions & 175 deletions

x-pack/platform/plugins/shared/cases/common/utils/attachments/migration_utils.test.ts

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,56 @@ import {
1616
SECURITY_ALERT_ATTACHMENT_TYPE,
1717
SECURITY_TIMELINE_ATTACHMENT_TYPE,
1818
} from '../../constants/attachments';
19-
import { AttachmentType } from '../../types/domain';
19+
import { AttachmentType, ExternalReferenceStorageType } from '../../types/domain';
2020
import { SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, GENERAL_CASES_OWNER } from '../../constants';
21+
import type { AttachmentRequestV2 } from '../../types/api';
2122
import {
23+
getAttachmentTypeFromAttributes,
2224
isMigratedAttachmentType,
2325
isPersistableType,
26+
resolveUnifiedAttachmentType,
2427
isUnifiedOnlyAttachmentType,
2528
toLegacyAttachmentType,
2629
toUnifiedAttachmentType,
2730
} from './migration_utils';
2831

32+
const makeExternalReference = (externalReferenceAttachmentTypeId: string): AttachmentRequestV2 => ({
33+
type: AttachmentType.externalReference,
34+
externalReferenceAttachmentTypeId,
35+
externalReferenceId: 'ref-id',
36+
externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc },
37+
externalReferenceMetadata: null,
38+
owner,
39+
});
40+
41+
const makePersistableState = (persistableStateAttachmentTypeId: string): AttachmentRequestV2 => ({
42+
type: AttachmentType.persistableState,
43+
persistableStateAttachmentTypeId,
44+
persistableStateAttachmentState: {},
45+
owner,
46+
});
47+
48+
const makeUnifiedRef = (type: string): AttachmentRequestV2 => ({
49+
type,
50+
attachmentId: 'att-id',
51+
owner,
52+
});
53+
54+
const makeAlert = (): AttachmentRequestV2 => ({
55+
type: AttachmentType.alert,
56+
alertId: 'alert-id',
57+
index: 'idx',
58+
rule: { id: 'rule-id', name: 'rule' },
59+
owner,
60+
});
61+
62+
const makeEvent = (): AttachmentRequestV2 => ({
63+
type: AttachmentType.event,
64+
eventId: 'evt-id',
65+
index: 'idx',
66+
owner,
67+
});
68+
2969
const owner = SECURITY_SOLUTION_OWNER;
3070

3171
describe('migration_utils', () => {
@@ -122,6 +162,126 @@ describe('migration_utils', () => {
122162
});
123163
});
124164

165+
describe('getAttachmentTypeFromAttributes', () => {
166+
it('throws for null', () => {
167+
expect(() => getAttachmentTypeFromAttributes(null)).toThrow(
168+
'Invalid attributes: expected non-null object'
169+
);
170+
});
171+
172+
it('throws for non-object', () => {
173+
expect(() => getAttachmentTypeFromAttributes('string')).toThrow(
174+
'Invalid attributes: expected non-null object'
175+
);
176+
expect(() => getAttachmentTypeFromAttributes(42)).toThrow(
177+
'Invalid attributes: expected non-null object'
178+
);
179+
});
180+
181+
it('throws when attributes have no recognizable attachment type', () => {
182+
expect(() => getAttachmentTypeFromAttributes({ foo: 'bar' })).toThrow(
183+
'Invalid attributes: missing attachment type'
184+
);
185+
});
186+
187+
it('throws when type is not a string', () => {
188+
expect(() => getAttachmentTypeFromAttributes({ type: 1 })).toThrow(
189+
'Invalid attributes: missing attachment type'
190+
);
191+
expect(() =>
192+
getAttachmentTypeFromAttributes({
193+
pushed_at: '2020-01-01T00:00:00.000Z',
194+
pushed_by: { username: 'elastic', full_name: null, email: null },
195+
})
196+
).toThrow('Invalid attributes: missing attachment type');
197+
});
198+
199+
it('returns the top-level type for plain attachments', () => {
200+
expect(getAttachmentTypeFromAttributes({ type: 'user' })).toBe('user');
201+
expect(getAttachmentTypeFromAttributes({ type: AttachmentType.alert })).toBe(
202+
AttachmentType.alert
203+
);
204+
});
205+
206+
it('resolves migrated external reference subtypes to unified type names', () => {
207+
expect(
208+
getAttachmentTypeFromAttributes({
209+
type: AttachmentType.externalReference,
210+
externalReferenceAttachmentTypeId: 'endpoint',
211+
})
212+
).toBe(SECURITY_ENDPOINT_ATTACHMENT_TYPE);
213+
});
214+
215+
it('returns the top-level type for unmigrated external reference subtypes', () => {
216+
expect(
217+
getAttachmentTypeFromAttributes({
218+
type: AttachmentType.externalReference,
219+
externalReferenceAttachmentTypeId: 'some-unknown-type',
220+
})
221+
).toBe(AttachmentType.externalReference);
222+
});
223+
224+
it('returns the top-level type for external references without externalReferenceAttachmentTypeId', () => {
225+
expect(
226+
getAttachmentTypeFromAttributes({
227+
type: AttachmentType.externalReference,
228+
})
229+
).toBe(AttachmentType.externalReference);
230+
});
231+
232+
it('returns persistableStateAttachmentTypeId for persistable state attachments', () => {
233+
expect(
234+
getAttachmentTypeFromAttributes({
235+
type: AttachmentType.persistableState,
236+
persistableStateAttachmentTypeId: LEGACY_LENS_ATTACHMENT_TYPE,
237+
})
238+
).toBe(LEGACY_LENS_ATTACHMENT_TYPE);
239+
});
240+
});
241+
242+
describe('resolveUnifiedAttachmentType', () => {
243+
it('passes through unified types unchanged', () => {
244+
expect(resolveUnifiedAttachmentType(makeUnifiedRef(LENS_ATTACHMENT_TYPE), owner)).toBe(
245+
LENS_ATTACHMENT_TYPE
246+
);
247+
expect(resolveUnifiedAttachmentType(makeUnifiedRef(FILE_ATTACHMENT_TYPE), owner)).toBe(
248+
FILE_ATTACHMENT_TYPE
249+
);
250+
});
251+
252+
it('maps legacy alert/event using owner prefix', () => {
253+
expect(resolveUnifiedAttachmentType(makeAlert(), owner)).toBe('security.alert');
254+
expect(resolveUnifiedAttachmentType(makeEvent(), owner)).toBe('security.event');
255+
});
256+
257+
it('resolves legacy externalReference + typeId to the unified type', () => {
258+
expect(resolveUnifiedAttachmentType(makeExternalReference('.files'), owner)).toBe(
259+
FILE_ATTACHMENT_TYPE
260+
);
261+
expect(resolveUnifiedAttachmentType(makeExternalReference('endpoint'), owner)).toBe(
262+
SECURITY_ENDPOINT_ATTACHMENT_TYPE
263+
);
264+
expect(
265+
resolveUnifiedAttachmentType(makeExternalReference(OSQUERY_ATTACHMENT_TYPE), owner)
266+
).toBe(OSQUERY_ATTACHMENT_TYPE);
267+
expect(resolveUnifiedAttachmentType(makeExternalReference('indicator'), owner)).toBe(
268+
INDICATOR_ATTACHMENT_TYPE
269+
);
270+
});
271+
272+
it('falls back to the top-level type for unknown externalReference subtypes', () => {
273+
expect(resolveUnifiedAttachmentType(makeExternalReference('unknownSubtype'), owner)).toBe(
274+
AttachmentType.externalReference
275+
);
276+
});
277+
278+
it('resolves legacy persistableState + typeId to the unified persistable type', () => {
279+
expect(
280+
resolveUnifiedAttachmentType(makePersistableState(LEGACY_LENS_ATTACHMENT_TYPE), owner)
281+
).toBe(LENS_ATTACHMENT_TYPE);
282+
});
283+
});
284+
125285
describe('isUnifiedOnlyAttachmentType', () => {
126286
it('is true for unified types with no legacy equivalent', () => {
127287
expect(isUnifiedOnlyAttachmentType(SECURITY_TIMELINE_ATTACHMENT_TYPE)).toBe(true);

x-pack/platform/plugins/shared/cases/common/utils/attachments/migration_utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
* 2.0.
66
*/
77

8+
import { isPlainObject } from 'lodash';
89
import {
10+
EXTERNAL_REFERENCE_TYPE_MAP,
911
LEGACY_TO_UNIFIED_MAP,
1012
MIGRATED_ATTACHMENT_TYPES,
1113
PERSISTABLE_STATE_LEGACY_TO_UNIFIED_MAP,
@@ -17,6 +19,8 @@ import {
1719
LEGACY_EVENT_TYPE,
1820
LEGACY_ALERT_TYPE,
1921
} from '../../constants/attachments';
22+
import { AttachmentType } from '../../types/domain';
23+
import type { AttachmentRequestV2 } from '../../types/api';
2024

2125
export const isMigratedAttachmentType = (type: string, owner: string): boolean => {
2226
return (
@@ -75,3 +79,49 @@ export const toUnifiedPersistableStateAttachmentType = (type: string): string =>
7579
export const toLegacyPersistableStateAttachmentType = (type: string): string => {
7680
return PERSISTABLE_STATE_UNIFIED_TO_LEGACY_MAP[type] ?? type;
7781
};
82+
83+
/**
84+
* Returns a routing key derived from raw attachment attributes — useful when working
85+
* with persisted SO data of unknown shape.
86+
*
87+
* Not a fully-normalized unified type — for that compose with
88+
* {@link toUnifiedAttachmentType} / {@link toUnifiedPersistableStateAttachmentType}
89+
* (or use {@link resolveUnifiedAttachmentType}).
90+
*
91+
* @throws Error if attributes is null or not an object, or if `type` is missing.
92+
*/
93+
export const getAttachmentTypeFromAttributes = (attributes: unknown): string => {
94+
if (!isPlainObject(attributes)) {
95+
throw new Error('Invalid attributes: expected non-null object');
96+
}
97+
const { type, persistableStateAttachmentTypeId, externalReferenceAttachmentTypeId } =
98+
attributes as Record<string, unknown>;
99+
if (typeof type !== 'string') {
100+
throw new Error('Invalid attributes: missing attachment type');
101+
}
102+
if (
103+
type === AttachmentType.persistableState &&
104+
typeof persistableStateAttachmentTypeId === 'string'
105+
) {
106+
return persistableStateAttachmentTypeId;
107+
}
108+
if (
109+
type === AttachmentType.externalReference &&
110+
typeof externalReferenceAttachmentTypeId === 'string'
111+
) {
112+
return EXTERNAL_REFERENCE_TYPE_MAP[externalReferenceAttachmentTypeId] ?? type;
113+
}
114+
return type;
115+
};
116+
117+
/**
118+
* Resolves a typed V2 attachment to its fully-normalized unified type
119+
* (`security.alert`, `lens`, `file`, …).
120+
*/
121+
export const resolveUnifiedAttachmentType = (
122+
attachment: AttachmentRequestV2,
123+
owner: string
124+
): string => {
125+
const routingKey = getAttachmentTypeFromAttributes(attachment);
126+
return toUnifiedAttachmentType(toUnifiedPersistableStateAttachmentType(routingKey), owner);
127+
};

x-pack/platform/plugins/shared/cases/public/components/attachments/file/case_view_files.test.tsx

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ import React from 'react';
99
import { screen, waitFor } from '@testing-library/react';
1010
import userEvent from '@testing-library/user-event';
1111

12+
import type { FileJSON } from '@kbn/shared-ux-file-types';
1213
import type { CaseUI } from '../../../../common';
14+
import { AttachmentType, ExternalReferenceStorageType } from '../../../../common/types/domain';
15+
import type { AttachmentUIV2 } from '../../../../common/ui/types';
1316

14-
import { alertCommentWithIndices, basicCase } from '../../../containers/mock';
17+
import { alertCommentWithIndices, basicCase, elasticUser } from '../../../containers/mock';
1518
import { useGetCaseFiles } from '../../../containers/use_get_case_files';
1619
import { CaseViewFiles, DEFAULT_CASE_FILES_FILTERING_OPTIONS } from './case_view_files';
1720
import { renderWithTestingProviders } from '../../../common/mock';
@@ -20,9 +23,45 @@ jest.mock('../../../containers/use_get_case_files');
2023

2124
const useGetCaseFilesMock = useGetCaseFiles as jest.Mock;
2225

26+
export const makeFileComment = (
27+
id: string,
28+
attachmentId: string | string[],
29+
owner: string
30+
): AttachmentUIV2 =>
31+
({
32+
type: AttachmentType.externalReference,
33+
id,
34+
externalReferenceId: 'ext',
35+
externalReferenceStorage: { type: ExternalReferenceStorageType.elasticSearchDoc },
36+
externalReferenceAttachmentTypeId: '.files',
37+
externalReferenceMetadata: { files: [] },
38+
attachmentId,
39+
createdAt: '2024-01-01T00:00:00.000Z',
40+
createdBy: elasticUser,
41+
owner,
42+
pushedAt: null,
43+
pushedBy: null,
44+
updatedAt: null,
45+
updatedBy: null,
46+
version: 'v',
47+
} as unknown as AttachmentUIV2);
48+
49+
const makeFile = (id: string, name = id): Partial<FileJSON> => ({
50+
id,
51+
name,
52+
fileKind: 'cases',
53+
mimeType: 'text/plain',
54+
status: 'READY',
55+
created: '2024-01-01T00:00:00.000Z',
56+
updated: '2024-01-01T00:00:00.000Z',
57+
});
58+
59+
const fileIds = Array.from({ length: 11 }, (_, i) => `file-${i}`);
60+
const fileComments = fileIds.map((id) => makeFileComment(`c-${id}`, id, basicCase.owner));
61+
2362
const caseData: CaseUI = {
2463
...basicCase,
25-
comments: [...basicCase.comments, alertCommentWithIndices],
64+
comments: [...basicCase.comments, ...fileComments, alertCommentWithIndices],
2665
};
2766

2867
describe('Case View Page files tab', () => {
@@ -100,4 +139,30 @@ describe('Case View Page files tab', () => {
100139
})
101140
);
102141
});
142+
143+
describe('intersect with caseData.comments', () => {
144+
it('only renders files whose ids are referenced by the (possibly filtered) comments', async () => {
145+
// Server returns three files but the (author-filtered) comments only
146+
// reference one of them. Only the referenced file should render.
147+
useGetCaseFilesMock.mockReturnValue({
148+
data: {
149+
files: [makeFile('file-a'), makeFile('file-b'), makeFile('file-c')],
150+
total: 3,
151+
},
152+
isLoading: false,
153+
});
154+
155+
const filteredCaseData: CaseUI = {
156+
...basicCase,
157+
comments: [makeFileComment('c-a', 'file-a', basicCase.owner)],
158+
};
159+
160+
renderWithTestingProviders(<CaseViewFiles caseData={filteredCaseData} />);
161+
162+
expect(await screen.findByTestId('cases-files-table')).toBeInTheDocument();
163+
expect(await screen.findByTestId('cases-files-table-row-file-a')).toBeInTheDocument();
164+
expect(screen.queryByTestId('cases-files-table-row-file-b')).not.toBeInTheDocument();
165+
expect(screen.queryByTestId('cases-files-table-row-file-c')).not.toBeInTheDocument();
166+
});
167+
});
103168
});

0 commit comments

Comments
 (0)