[Cases] Trim title, category, description and tags in UI and in API (#163933)

## Summary

This PR trims `title, category, description` and `tags` fields of cases
form in UI as well as API before saving to ES


### Checklist

- [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

Flaky test runner:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2887

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Janki Salvi 2023-08-16 12:16:49 +02:00 committed by GitHub
parent 6f99f9d5fe
commit cfe7cabf86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 434 additions and 39 deletions

View file

@ -134,6 +134,26 @@ describe('EditCategory ', () => {
await waitFor(() => expect(onSubmit).toBeCalledWith('new'));
});
it('should trim category', async () => {
appMockRender.render(<EditCategory {...defaultProps} />);
userEvent.click(screen.getByTestId('category-edit-button'));
await waitFor(() => {
expect(screen.getByTestId('categories-list')).toBeInTheDocument();
});
userEvent.type(screen.getByRole('combobox'), 'category-with-space {enter}');
await waitFor(() => {
expect(screen.getByTestId('edit-category-submit')).not.toBeDisabled();
});
userEvent.click(screen.getByTestId('edit-category-submit'));
await waitFor(() => expect(onSubmit).toBeCalledWith('category-with-space'));
});
it('should not save category on cancel click', async () => {
appMockRender.render(<EditCategory {...defaultProps} />);

View file

@ -95,9 +95,7 @@ export const EditCategory = React.memo(({ isLoading, onSubmit, category }: EditC
const { isValid, data } = await formState.submit();
if (isValid) {
const newCategory = data.category != null ? data.category : null;
onSubmit(newCategory);
onSubmit(data.category?.trim() ?? null);
}
setIsEditCategory(false);

View file

@ -82,6 +82,22 @@ describe('EditTags ', () => {
await waitFor(() => expect(onSubmit).toBeCalledWith(['dude']));
});
it('trims the tags on submit', async () => {
appMockRender.render(<EditTags {...defaultProps} />);
userEvent.click(screen.getByTestId('tag-list-edit-button'));
await waitFor(() => {
expect(screen.getByTestId('edit-tags')).toBeInTheDocument();
});
userEvent.type(screen.getByRole('combobox'), 'dude {enter}');
userEvent.click(screen.getByTestId('edit-tags-submit'));
await waitFor(() => expect(onSubmit).toBeCalledWith(['dude']));
});
it('cancels on cancel', async () => {
appMockRender.render(<EditTags {...defaultProps} />);

View file

@ -79,7 +79,9 @@ export const EditTags = React.memo(({ isLoading, onSubmit, tags }: EditTagsProps
const onSubmitTags = useCallback(async () => {
const { isValid, data: newData } = await submit();
if (isValid && newData.tags) {
onSubmit(newData.tags);
const trimmedTags = newData.tags.map((tag: string) => tag.trim());
onSubmit(trimmedTags);
form.reset({ defaultValue: newData });
setIsEditTags(false);
}

View file

@ -306,34 +306,6 @@ describe('Create case', () => {
});
});
it('does not submits the title when the length is longer than 160 characters', async () => {
const longTitle = 'a'.repeat(161);
appMockRender.render(
<FormContext onSuccess={onFormSubmitSuccess}>
<CreateCaseFormFields {...defaultCreateCaseForm} />
<SubmitCaseButton />
</FormContext>
);
await waitForFormToRender(screen);
const titleInput = within(screen.getByTestId('caseTitle')).getByTestId('input');
userEvent.paste(titleInput, longTitle);
userEvent.click(screen.getByTestId('create-case-submit'));
await waitFor(() => {
expect(
screen.getByText(
'The length of the name is too long. The maximum length is 160 characters.'
)
).toBeInTheDocument();
});
expect(postCase).not.toHaveBeenCalled();
});
it('should toggle sync settings', async () => {
useGetConnectorsMock.mockReturnValue({
...sampleConnectorData,

View file

@ -71,6 +71,24 @@ export const FormContext: React.FC<Props> = ({
const { startTransaction } = useCreateCaseWithAttachmentsTransaction();
const availableOwners = useAvailableCasesOwners();
const trimUserFormData = (userFormData: CaseUI) => {
let formData = {
...userFormData,
title: userFormData.title.trim(),
description: userFormData.description.trim(),
};
if (userFormData.category) {
formData = { ...formData, category: userFormData.category.trim() };
}
if (userFormData.tags) {
formData = { ...formData, tags: userFormData.tags.map((tag: string) => tag.trim()) };
}
return formData;
};
const submitCase = useCallback(
async (
{
@ -92,9 +110,11 @@ export const FormContext: React.FC<Props> = ({
? normalizeActionConnector(caseConnector, fields)
: getNoneConnector();
const trimmedData = trimUserFormData(userFormData);
const theCase = await postCase({
request: {
...userFormData,
...trimmedData,
connector: connectorToUpdate,
settings: { syncAlerts },
owner: selectedOwner ?? defaultOwner,

View file

@ -91,6 +91,27 @@ describe('Description', () => {
});
});
it('trims the description correctly when saved', async () => {
const descriptionWithSpaces = 'New updated description ';
const res = appMockRender.render(
<Description {...defaultProps} onUpdateField={onUpdateField} />
);
userEvent.click(res.getByTestId('description-edit-icon'));
userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea'));
userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), descriptionWithSpaces);
userEvent.click(screen.getByTestId('editable-save-markdown'));
await waitFor(() => {
expect(onUpdateField).toHaveBeenCalledWith({
key: 'description',
value: descriptionWithSpaces.trim(),
});
});
});
it('keeps the old description correctly when canceled', async () => {
const editedDescription = 'New updated description';
const res = appMockRender.render(

View file

@ -113,7 +113,7 @@ export const Description = ({
const handleOnSave = useCallback(
(content: string) => {
onUpdateField({ key: DESCRIPTION_ID, value: content });
onUpdateField({ key: DESCRIPTION_ID, value: content.trim() });
setIsEditable(false);
},
[onUpdateField, setIsEditable]

View file

@ -185,6 +185,33 @@ describe('EditableTitle', () => {
).toBe(true);
});
it('trims the title before submit', () => {
const newTitle = 'new test title with spaces ';
const wrapper = mount(
<TestProviders>
<EditableTitle {...defaultProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="editable-title-header-value"]').simulate('click');
wrapper.update();
wrapper
.find('input[data-test-subj="editable-title-input-field"]')
.last()
.simulate('change', { target: { value: newTitle } });
wrapper.find('button[data-test-subj="editable-title-submit-btn"]').simulate('click');
wrapper.update();
expect(submitTitle).toHaveBeenCalled();
expect(submitTitle.mock.calls[0][0]).toEqual(newTitle.trim());
expect(
wrapper.find('button[data-test-subj="editable-title-header-value"]').first().exists()
).toBe(true);
});
it('does not submit the title when the length is longer than 160 characters', () => {
const longTitle = 'a'.repeat(161);

View file

@ -16,7 +16,7 @@ import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { mockCases } from '../../mocks';
import { createCasesClientMockArgs } from '../mocks';
import { create } from './create';
import { CaseSeverity, ConnectorTypes } from '../../../common/types/domain';
import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/types/domain';
describe('create', () => {
const theCase = {
@ -151,6 +151,31 @@ describe('create', () => {
'Failed to create case: Error: The title field cannot be an empty string.'
);
});
it('should trim title', async () => {
await create({ ...theCase, title: 'title with spaces ' }, clientArgs);
expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
title: 'title with spaces',
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
category: null,
},
id: expect.any(String),
refresh: false,
})
);
});
});
describe('description', () => {
@ -188,6 +213,34 @@ describe('create', () => {
'Failed to create case: Error: The description field cannot be an empty string.'
);
});
it('should trim description', async () => {
await create(
{ ...theCase, description: 'this is a description with spaces!! ' },
clientArgs
);
expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
description: 'this is a description with spaces!!',
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
category: null,
},
id: expect.any(String),
refresh: false,
})
);
});
});
describe('tags', () => {
@ -235,6 +288,31 @@ describe('create', () => {
`Failed to create case: Error: The length of the tag is too long. The maximum length is ${MAX_LENGTH_PER_TAG}.`
);
});
it('should trim tags', async () => {
await create({ ...theCase, tags: ['pepsi ', 'coke'] }, clientArgs);
expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
tags: ['pepsi', 'coke'],
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
category: null,
},
id: expect.any(String),
refresh: false,
})
);
});
});
describe('Category', () => {
@ -269,5 +347,29 @@ describe('create', () => {
'Failed to create case: Error: The category field cannot be an empty string.,Invalid value " " supplied to "category"'
);
});
it('should trim category', async () => {
await create({ ...theCase, category: 'reporting ' }, clientArgs);
expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
category: 'reporting',
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
},
id: expect.any(String),
refresh: false,
})
);
});
});
});

View file

@ -61,10 +61,22 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs)
licensingService.notifyUsage(LICENSING_CASE_ASSIGNMENT_FEATURE);
}
/**
* Trim title, category, description and tags before saving to ES
*/
const trimmedQuery = {
...query,
title: query.title.trim(),
description: query.description.trim(),
category: query.category?.trim() ?? null,
tags: query.tags?.map((tag) => tag.trim()) ?? [],
};
const newCase = await caseService.postNewCase({
attributes: transformNewCase({
user,
newCase: query,
newCase: trimmedQuery,
}),
id: savedObjectID,
refresh: false,

View file

@ -394,6 +394,41 @@ describe('update', () => {
'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The category field cannot be an empty string.,Invalid value " " supplied to "cases,category"'
);
});
it('should trim category', async () => {
await update(
{
cases: [
{
id: mockCases[0].id,
version: mockCases[0].version ?? '',
category: 'security ',
},
],
},
clientArgs
);
expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith(
expect.objectContaining({
cases: [
{
caseId: mockCases[0].id,
version: mockCases[0].version,
originalCase: {
...mockCases[0],
},
updatedAttributes: {
category: 'security',
updated_at: expect.any(String),
updated_by: expect.any(Object),
},
},
],
refresh: false,
})
);
});
});
describe('Title', () => {
@ -488,6 +523,41 @@ describe('update', () => {
'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The title field cannot be an empty string.'
);
});
it('should trim title', async () => {
await update(
{
cases: [
{
id: mockCases[0].id,
version: mockCases[0].version ?? '',
title: 'title with spaces ',
},
],
},
clientArgs
);
expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith(
expect.objectContaining({
cases: [
{
caseId: mockCases[0].id,
version: mockCases[0].version,
originalCase: {
...mockCases[0],
},
updatedAttributes: {
title: 'title with spaces',
updated_at: expect.any(String),
updated_by: expect.any(Object),
},
},
],
refresh: false,
})
);
});
});
describe('Description', () => {
@ -585,6 +655,41 @@ describe('update', () => {
'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The description field cannot be an empty string.'
);
});
it('should trim description', async () => {
await update(
{
cases: [
{
id: mockCases[0].id,
version: mockCases[0].version ?? '',
description: 'This is a description with spaces!! ',
},
],
},
clientArgs
);
expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith(
expect.objectContaining({
cases: [
{
caseId: mockCases[0].id,
version: mockCases[0].version,
originalCase: {
...mockCases[0],
},
updatedAttributes: {
description: 'This is a description with spaces!!',
updated_at: expect.any(String),
updated_by: expect.any(Object),
},
},
],
refresh: false,
})
);
});
});
describe('Tags', () => {
@ -724,6 +829,41 @@ describe('update', () => {
'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The tag field cannot be an empty string.'
);
});
it('should trim tags', async () => {
await update(
{
cases: [
{
id: mockCases[0].id,
version: mockCases[0].version ?? '',
tags: ['coke ', 'pepsi'],
},
],
},
clientArgs
);
expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith(
expect.objectContaining({
cases: [
{
caseId: mockCases[0].id,
version: mockCases[0].version,
originalCase: {
...mockCases[0],
},
updatedAttributes: {
tags: ['coke', 'pepsi'],
updated_at: expect.any(String),
updated_by: expect.any(Object),
},
},
],
refresh: false,
})
);
});
});
describe('Validation', () => {

View file

@ -462,6 +462,36 @@ export const update = async (
}
};
const trimCaseAttributes = (
updateCaseAttributes: Omit<CasePatchRequest, 'id' | 'version' | 'owner' | 'assignees'>
) => {
let trimmedAttributes = { ...updateCaseAttributes };
if (updateCaseAttributes.title) {
trimmedAttributes = { ...trimmedAttributes, title: updateCaseAttributes.title.trim() };
}
if (updateCaseAttributes.description) {
trimmedAttributes = {
...trimmedAttributes,
description: updateCaseAttributes.description.trim(),
};
}
if (updateCaseAttributes.category) {
trimmedAttributes = { ...trimmedAttributes, category: updateCaseAttributes.category.trim() };
}
if (updateCaseAttributes.tags) {
trimmedAttributes = {
...trimmedAttributes,
tags: updateCaseAttributes.tags.map((tag: string) => tag.trim()),
};
}
return trimmedAttributes;
};
const createPatchCasesPayload = ({
casesToUpdate,
user,
@ -478,19 +508,21 @@ const createPatchCasesPayload = ({
const dedupedAssignees = dedupAssignees(assignees);
const trimmedCaseAttributes = trimCaseAttributes(updateCaseAttributes);
return {
caseId,
originalCase,
updatedAttributes: {
...updateCaseAttributes,
...trimmedCaseAttributes,
...(dedupedAssignees && { assignees: dedupedAssignees }),
...getClosedInfoForUpdate({
user,
closedDate: updatedDt,
status: updateCaseAttributes.status,
status: trimmedCaseAttributes.status,
}),
...getDurationForUpdate({
status: updateCaseAttributes.status,
status: trimmedCaseAttributes.status,
closedAt: updatedDt,
createdAt: originalCase.attributes.created_at,
}),

View file

@ -99,6 +99,39 @@ export default ({ getService, getPageObject }: FtrProviderContext) => {
);
});
it('trims fields correctly while creating a case', async () => {
const titleWithSpace = 'This is a title with spaces ';
const descriptionWithSpace =
'This is a case description with empty spaces at the end!! ';
const categoryWithSpace = 'security ';
const tagWithSpace = 'coke ';
await cases.create.openCreateCasePage();
await cases.create.createCase({
title: titleWithSpace,
description: descriptionWithSpace,
tag: tagWithSpace,
severity: CaseSeverity.HIGH,
category: categoryWithSpace,
});
// validate title is trimmed
const title = await find.byCssSelector('[data-test-subj="editable-title-header-value"]');
expect(await title.getVisibleText()).equal(titleWithSpace.trim());
// validate description is trimmed
const description = await testSubjects.find('scrollable-markdown');
expect(await description.getVisibleText()).equal(descriptionWithSpace.trim());
// validate tag exists and is trimmed
const tag = await testSubjects.find(`tag-${tagWithSpace.trim()}`);
expect(await tag.getVisibleText()).equal(tagWithSpace.trim());
// validate category exists and is trimmed
const category = await testSubjects.find(`category-viewer-${categoryWithSpace.trim()}`);
expect(await category.getVisibleText()).equal(categoryWithSpace.trim());
});
describe('Assignees', function () {
before(async () => {
await createUsersAndRoles(getService, users, roles);