[Cases] Copy file hash from within the files table (#172450)

## Summary

The files table allows copying a file's hash(MD5, SHA1, or SHA256) when
available.

We only recently opted in for the hashing of uploaded files so
previously uploaded files will not display the Copy to Clipboard button.

The activity feed in a case's detail view will not display this action.


4bb2ce33-f999-4d7f-b2c7-f224bb42a162

## Release Notes

Users can copy to the clipboard the hashes of files uploaded to cases.
This commit is contained in:
Antonio 2023-12-05 08:58:14 +01:00 committed by GitHub
parent 72142bc978
commit edf4f35152
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 538 additions and 53 deletions

View file

@ -0,0 +1,275 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { AppMockRenderer } from '../../common/mock';
import {
buildCasesPermissions,
createAppMockRenderer,
mockedTestProvidersOwner,
} from '../../common/mock';
import { constructFileKindIdByOwner } from '../../../common/files';
import { basicCaseId, basicFileMock } from '../../containers/mock';
import { FileActionsPopoverButton } from './file_actions_popover_button';
import { useDeleteFileAttachment } from '../../containers/use_delete_file_attachment';
jest.mock('../../containers/use_delete_file_attachment');
const useDeleteFileAttachmentMock = useDeleteFileAttachment as jest.Mock;
describe('FileActionsPopoverButton', () => {
let appMockRender: AppMockRenderer;
const mutate = jest.fn();
useDeleteFileAttachmentMock.mockReturnValue({ isLoading: false, mutate });
beforeEach(() => {
jest.clearAllMocks();
appMockRender = createAppMockRenderer();
});
it('renders file actions popover button correctly', async () => {
appMockRender.render(<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />);
expect(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
).toBeInTheDocument();
});
it('clicking the button opens the popover', async () => {
appMockRender.render(<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
expect(
await screen.findByTestId(`cases-files-popover-${basicFileMock.id}`)
).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-copy-hash-button')).toBeInTheDocument();
});
it('does not render the copy hash button if the file has no hashes', async () => {
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={{ ...basicFileMock, hash: {} }} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
expect(
await screen.findByTestId(`cases-files-popover-${basicFileMock.id}`)
).toBeInTheDocument();
expect(await screen.queryByTestId('cases-files-copy-hash-button')).not.toBeInTheDocument();
});
it('clicking the copy file hash button rerenders the popover correctly', async () => {
appMockRender.render(<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />);
const popoverButton = await screen.findByTestId(
`cases-files-actions-popover-button-${basicFileMock.id}`
);
expect(popoverButton).toBeInTheDocument();
userEvent.click(popoverButton);
expect(
await screen.findByTestId(`cases-files-popover-${basicFileMock.id}`)
).toBeInTheDocument();
const copyFileHashButton = await screen.findByTestId('cases-files-copy-hash-button');
expect(copyFileHashButton).toBeInTheDocument();
userEvent.click(copyFileHashButton);
expect(await screen.findByTestId('cases-files-copy-md5-hash-button')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-copy-sha1-hash-button')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-copy-sha256-hash-button')).toBeInTheDocument();
expect(
(
await within(await screen.findByTestId('cases-files-popover-context-menu')).findAllByRole(
'button'
)
).length
).toBe(7);
});
describe('copy file hashes', () => {
const originalClipboard = global.window.navigator.clipboard;
beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
},
writable: true,
});
});
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
});
});
it('clicking copy md5 file hash copies the hash to the clipboard', async () => {
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
userEvent.click(await screen.findByTestId('cases-files-copy-hash-button'), undefined, {
skipPointerEventsCheck: true,
});
userEvent.click(await screen.findByTestId('cases-files-copy-md5-hash-button'), undefined, {
skipPointerEventsCheck: true,
});
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(basicFileMock.hash?.md5);
});
});
it('clicking copy SHA1 file hash copies the hash to the clipboard', async () => {
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
userEvent.click(await screen.findByTestId('cases-files-copy-hash-button'), undefined, {
skipPointerEventsCheck: true,
});
userEvent.click(await screen.findByTestId('cases-files-copy-sha1-hash-button'), undefined, {
skipPointerEventsCheck: true,
});
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(basicFileMock.hash?.sha1);
});
});
it('clicking copy SHA256 file hash copies the hash to the clipboard', async () => {
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
userEvent.click(await screen.findByTestId('cases-files-copy-hash-button'), undefined, {
skipPointerEventsCheck: true,
});
userEvent.click(await screen.findByTestId('cases-files-copy-sha256-hash-button'), undefined, {
skipPointerEventsCheck: true,
});
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(basicFileMock.hash?.sha256);
});
});
});
describe('delete button', () => {
it('clicking delete button opens the confirmation modal', async () => {
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
userEvent.click(await screen.findByTestId('cases-files-delete-button'), undefined, {
skipPointerEventsCheck: true,
});
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
});
it('clicking delete button in the confirmation modal calls deleteFileAttachment with proper params', async () => {
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
userEvent.click(await screen.findByTestId('cases-files-delete-button'), undefined, {
skipPointerEventsCheck: true,
});
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
userEvent.click(await screen.findByTestId('confirmModalConfirmButton'));
await waitFor(() => {
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate).toHaveBeenCalledWith({
caseId: basicCaseId,
fileId: basicFileMock.id,
});
});
});
it('delete button is not rendered if user has no delete permission', async () => {
appMockRender = createAppMockRenderer({
permissions: buildCasesPermissions({ delete: false }),
});
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
expect(screen.queryByTestId('cases-files-delete-button')).not.toBeInTheDocument();
});
});
describe('download button', () => {
it('renders download button with correct href', async () => {
appMockRender.render(
<FileActionsPopoverButton caseId={basicCaseId} theFile={basicFileMock} />
);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument();
await waitFor(() => {
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalled();
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
id: basicFileMock.id,
});
});
});
});
});

View file

@ -0,0 +1,187 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import type {
EuiContextMenuPanelDescriptor,
EuiContextMenuPanelItemDescriptor,
} from '@elastic/eui';
import { EuiButtonIcon, EuiPopover, EuiContextMenu, EuiIcon, EuiTextColor } from '@elastic/eui';
import type { FileJSON } from '@kbn/shared-ux-file-types';
import { useFilesContext } from '@kbn/shared-ux-file-context';
import type { Owner } from '../../../common/constants/types';
import * as i18n from './translations';
import { constructFileKindIdByOwner } from '../../../common/files';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useCasesToast } from '../../common/use_cases_toast';
import { DeleteAttachmentConfirmationModal } from '../user_actions/delete_attachment_confirmation_modal';
import { useDeleteFileAttachment } from '../../containers/use_delete_file_attachment';
import { useDeletePropertyAction } from '../user_actions/property_actions/use_delete_property_action';
export const FileActionsPopoverButton: React.FC<{ caseId: string; theFile: FileJSON }> = ({
caseId,
theFile,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const { owner, permissions } = useCasesContext();
const { client: filesClient } = useFilesContext();
const { showSuccessToast } = useCasesToast();
const { isLoading, mutate: deleteFileAttachment } = useDeleteFileAttachment();
const { showDeletionModal, onModalOpen, onConfirm, onCancel } = useDeletePropertyAction({
onDelete: () => deleteFileAttachment({ caseId, fileId: theFile.id }),
});
const tooglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const panels = useMemo((): EuiContextMenuPanelDescriptor[] => {
const fileHashesAvailable = theFile.hash?.md5 || theFile.hash?.sha1 || theFile.hash?.sha256;
const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = [
{
name: i18n.DOWNLOAD_FILE,
icon: 'download',
href: filesClient.getDownloadHref({
fileKind: constructFileKindIdByOwner(owner[0] as Owner),
id: theFile.id,
}),
onClick: closePopover,
'data-test-subj': 'cases-files-download-button',
},
];
const panelsToBuild = [
{
id: 0,
title: i18n.ACTIONS,
items: mainPanelItems,
},
{
id: 1,
title: i18n.COPY_FILE_HASH,
items: [
{
name: 'MD5',
icon: 'copyClipboard',
disabled: !theFile.hash?.md5,
onClick: () => {
if (theFile.hash?.md5) {
navigator.clipboard.writeText(theFile.hash.md5).then(() => {
closePopover();
showSuccessToast(i18n.COPY_FILE_HASH_SUCCESS('md5'));
});
}
},
'data-test-subj': 'cases-files-copy-md5-hash-button',
},
{
name: 'SHA1',
icon: 'copyClipboard',
disabled: !theFile.hash?.sha1,
onClick: () => {
if (theFile.hash?.sha1) {
navigator.clipboard.writeText(theFile.hash.sha1).then(() => {
closePopover();
showSuccessToast(i18n.COPY_FILE_HASH_SUCCESS('sha1'));
});
}
},
'data-test-subj': 'cases-files-copy-sha1-hash-button',
},
{
name: 'SHA256',
icon: 'copyClipboard',
disabled: !theFile.hash?.sha256,
onClick: () => {
if (theFile.hash?.sha256) {
navigator.clipboard.writeText(theFile.hash.sha256).then(() => {
closePopover();
showSuccessToast(i18n.COPY_FILE_HASH_SUCCESS('sha256'));
});
}
},
'data-test-subj': 'cases-files-copy-sha256-hash-button',
},
],
},
];
if (fileHashesAvailable) {
mainPanelItems.push({
name: i18n.COPY_FILE_HASH,
icon: 'copyClipboard',
panel: 1,
'data-test-subj': 'cases-files-copy-hash-button',
});
}
if (permissions.delete) {
mainPanelItems.push({
name: <EuiTextColor color={'danger'}>{i18n.DELETE_FILE}</EuiTextColor>,
icon: <EuiIcon type="trash" size="m" color={'danger'} />,
onClick: () => {
closePopover();
onModalOpen();
},
disabled: isLoading,
'data-test-subj': 'cases-files-delete-button',
});
}
return panelsToBuild;
}, [
closePopover,
filesClient,
isLoading,
onModalOpen,
owner,
permissions,
showSuccessToast,
theFile,
]);
return (
<>
<EuiPopover
id={`cases-files-popover-${theFile.id}`}
key={`cases-files-popover-${theFile.id}`}
data-test-subj={`cases-files-popover-${theFile.id}`}
button={
<EuiButtonIcon
onClick={tooglePopover}
iconType="boxesHorizontal"
aria-label={i18n.ACTIONS}
color="text"
key={`cases-files-actions-popover-button-${theFile.id}`}
data-test-subj={`cases-files-actions-popover-button-${theFile.id}`}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenu
initialPanelId={0}
panels={panels}
data-test-subj={'cases-files-popover-context-menu'}
/>
</EuiPopover>
{showDeletionModal && (
<DeleteAttachmentConfirmationModal
title={i18n.DELETE_FILE_TITLE}
confirmButtonText={i18n.DELETE}
onCancel={onCancel}
onConfirm={onConfirm}
/>
)}
</>
);
};
FileActionsPopoverButton.displayName = 'FileActionsPopoverButton';

View file

@ -40,8 +40,9 @@ describe('FilesTable', () => {
expect(await screen.findByTestId('cases-files-table-filename')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-table-filetype')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-table-date-added')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-download-button')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
expect(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
).toBeInTheDocument();
});
it('renders loading state', async () => {
@ -132,8 +133,12 @@ describe('FilesTable', () => {
it('download button renders correctly', async () => {
appMockRender.render(<FilesTable {...defaultProps} />);
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
await waitFor(() => {
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalled();
});
await waitFor(() => {
@ -149,16 +154,9 @@ describe('FilesTable', () => {
it('delete button renders correctly', async () => {
appMockRender.render(<FilesTable {...defaultProps} />);
await waitFor(() => {
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
});
await waitFor(() => {
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
id: basicFileMock.id,
});
});
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
expect(await screen.findByTestId('cases-files-delete-button')).toBeInTheDocument();
});
@ -166,26 +164,40 @@ describe('FilesTable', () => {
it('clicking delete button opens deletion modal', async () => {
appMockRender.render(<FilesTable {...defaultProps} />);
await waitFor(() => {
expect(appMockRender.getFilesClient().getDownloadHref).toBeCalledTimes(1);
});
userEvent.click(
await screen.findByTestId(`cases-files-actions-popover-button-${basicFileMock.id}`)
);
await waitFor(() => {
expect(appMockRender.getFilesClient().getDownloadHref).toHaveBeenCalledWith({
fileKind: constructFileKindIdByOwner(mockedTestProvidersOwner[0]),
id: basicFileMock.id,
});
});
const deleteButton = await screen.findByTestId('cases-files-delete-button');
expect(deleteButton).toBeInTheDocument();
userEvent.click(deleteButton);
userEvent.click(await screen.findByTestId('cases-files-delete-button'));
expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument();
});
it('clicking the copy file hash button rerenders the popover correctly', async () => {
appMockRender.render(<FilesTable {...defaultProps} />);
const popoverButton = await screen.findByTestId(
`cases-files-actions-popover-button-${basicFileMock.id}`
);
expect(popoverButton).toBeInTheDocument();
userEvent.click(popoverButton);
expect(
await screen.findByTestId(`cases-files-popover-${basicFileMock.id}`)
).toBeInTheDocument();
const copyFileHashButton = await screen.findByTestId('cases-files-copy-hash-button');
expect(copyFileHashButton).toBeInTheDocument();
userEvent.click(copyFileHashButton);
expect(await screen.findByTestId('cases-files-copy-md5-hash-button')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-copy-sha1-hash-button')).toBeInTheDocument();
expect(await screen.findByTestId('cases-files-copy-sha256-hash-button')).toBeInTheDocument();
});
it('go to next page calls onTableChange with correct values', async () => {
const mockPagination = { pageIndex: 0, pageSize: 1, totalItemCount: 2 };

View file

@ -31,6 +31,16 @@ export const DOWNLOAD_FILE = i18n.translate('xpack.cases.caseView.files.download
defaultMessage: 'Download file',
});
export const COPY_FILE_HASH = i18n.translate('xpack.cases.caseView.files.copyFileHash', {
defaultMessage: 'Copy file hash',
});
export const COPY_FILE_HASH_SUCCESS = (hashName: string) =>
i18n.translate('xpack.cases.caseView.files.copyFileHashSuccess', {
values: { hashName },
defaultMessage: `Copied {hashName} file hash successfully`,
});
export const FILES_TABLE = i18n.translate('xpack.cases.caseView.files.filesTable', {
defaultMessage: 'Files table',
});

View file

@ -52,15 +52,7 @@ describe('useFilesTableColumns', () => {
Object {
"actions": Array [
Object {
"description": "Download file",
"isPrimary": true,
"name": "Download",
"render": [Function],
},
Object {
"description": "Delete file",
"isPrimary": true,
"name": "Delete",
"name": "Actions",
"render": [Function],
},
],

View file

@ -13,8 +13,7 @@ import type { FileJSON } from '@kbn/shared-ux-file-types';
import * as i18n from './translations';
import { parseMimeType } from './utils';
import { FileNameLink } from './file_name_link';
import { FileDownloadButton } from './file_download_button';
import { FileDeleteButton } from './file_delete_button';
import { FileActionsPopoverButton } from './file_actions_popover_button';
export interface FilesTableColumnsProps {
caseId: string;
@ -52,17 +51,9 @@ export const useFilesTableColumns = ({
width: '120px',
actions: [
{
name: 'Download',
isPrimary: true,
description: i18n.DOWNLOAD_FILE,
render: (file: FileJSON) => <FileDownloadButton fileId={file.id} isIcon={true} />,
},
{
name: 'Delete',
isPrimary: true,
description: i18n.DELETE_FILE,
render: (file: FileJSON) => (
<FileDeleteButton caseId={caseId} fileId={file.id} isIcon={true} />
name: i18n.ACTIONS,
render: (theFile: FileJSON) => (
<FileActionsPopoverButton caseId={caseId} theFile={theFile} />
),
},
],

View file

@ -265,6 +265,11 @@ export const basicFileMock: FileJSON = {
fileKind: '',
status: 'READY',
extension: 'png',
hash: {
md5: 'md5',
sha1: 'sha1',
sha256: 'sha256',
},
};
export const caseWithAlerts = {

View file

@ -39,10 +39,23 @@ export function CasesFilesTableServiceProvider({ getService, getPageObject }: Ft
await searchField.pressKeys(browser.keys.ENTER);
},
async deleteFile(index: number = 0) {
const row = await this.getFileByIndex(index);
async openActionsPopover(index: number = 0) {
const popoverButtons = await find.allByCssSelector(
'[data-test-subj*="cases-files-actions-popover-button-"',
100
);
(await row.findByCssSelector('[data-test-subj="cases-files-delete-button"]')).click();
assertFileExists(index, popoverButtons.length);
popoverButtons[index].click();
await testSubjects.existOrFail('contextMenuPanelTitle');
},
async deleteFile(index: number = 0) {
await this.openActionsPopover(index);
(await testSubjects.find('cases-files-delete-button', 1000)).click();
await testSubjects.click('confirmModalConfirmButton');
},