Skip to content

Commit

Permalink
[Cases] Notify assignees when assigned to a case (#144391)
Browse files Browse the repository at this point in the history
## Summary

The PR adds the ability to notify users by email when assigned to a
case. A user is:
- Not notified if he/she assigns themselves
- Notified if added as an assignee to a case
- Not notified if removed from a case

I did not add integration tests due to the complexity of simulating an
email server. I added unit test coverage. If integration test coverage
is needed we can add the tests on another PR.

Depends on: #143303
Fixes: #142307

## Email screenshot

<img width="361" alt="Screenshot 2022-11-07 at 1 27 13 PM"
src="https://user-images.githubusercontent.com/7871006/200299356-52c08515-4d43-49d6-bd47-3797b52f97e5.png">

@shanisagiv1 @lcawl What do you think about the content of the email
(see screenshot)?

## Testing

1. Put the following in your `kibana.yml`:

```
notifications.connectors.default.email: 'mail-dev'

xpack.actions.preconfigured:
  mail-dev:
    name: preconfigured-email-notification-maildev
    actionTypeId: .email
    config:
      service: other
      from: mlr-test-sink@elastic.co
      host: localhost
      port: 1025
      secure: false
      hasAuth: false
```

2. Install [`maildev`](https://www.npmjs.com/package/maildev): `npm
install -g maildev`
3. Run `maildev`: `maildev`
4. Open MailDev's web interface (http://0.0.0.0:1080/)
5. Create a case and assign users. You should see the emails in the
MailDev inbox.
6. Update the assignees of a case. You should see the emails in the
MailDev inbox.

Note: If you assign yourself you should not see an email. If you delete
an assignee you should not see an email.

### Checklist

Delete any items that are not applicable to this PR.

- [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/packages/kbn-i18n/README.md)
- [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
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

## Release notes

Notify users by email when assigned to a case
  • Loading branch information
cnasikas authored Nov 8, 2022
1 parent 5dddba5 commit 82083c4
Show file tree
Hide file tree
Showing 33 changed files with 1,095 additions and 112 deletions.
3 changes: 2 additions & 1 deletion x-pack/plugins/cases/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"triggersActionsUi",
"management",
"spaces",
"security"
"security",
"notifications"
],
"requiredBundles": [
"savedObjects"
Expand Down
21 changes: 19 additions & 2 deletions x-pack/plugins/cases/server/attachment_framework/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@ import type {
CommentRequestPersistableStateType,
} from '../../common/api';
import { ExternalReferenceStorageType } from '../../common/api';
import { ExternalReferenceAttachmentTypeRegistry } from './external_reference_registry';
import { PersistableStateAttachmentTypeRegistry } from './persistable_state_registry';
import type { PersistableStateAttachmentTypeSetup, PersistableStateAttachmentState } from './types';
import type {
PersistableStateAttachmentTypeSetup,
PersistableStateAttachmentState,
ExternalReferenceAttachmentType,
} from './types';

export const getPersistableAttachment = (): PersistableStateAttachmentTypeSetup => ({
id: '.test',
Expand All @@ -42,6 +47,10 @@ export const getPersistableAttachment = (): PersistableStateAttachmentTypeSetup
}),
});

export const getExternalReferenceAttachment = (): ExternalReferenceAttachmentType => ({
id: '.test',
});

export const externalReferenceAttachmentSO = {
type: CommentType.externalReference as const,
externalReferenceId: 'my-id',
Expand Down Expand Up @@ -130,10 +139,18 @@ export const externalReferenceAttachmentSOAttributesWithoutRefs = omit(
'externalReferenceId'
);

export const getPersistableStateAttachmentTypeRegistry =
export const createPersistableStateAttachmentTypeRegistryMock =
(): PersistableStateAttachmentTypeRegistry => {
const persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry();
persistableStateAttachmentTypeRegistry.register(getPersistableAttachment());

return persistableStateAttachmentTypeRegistry;
};

export const createExternalReferenceAttachmentTypeRegistryMock =
(): ExternalReferenceAttachmentTypeRegistry => {
const externalReferenceAttachmentTypeRegistry = new ExternalReferenceAttachmentTypeRegistry();
externalReferenceAttachmentTypeRegistry.register(getExternalReferenceAttachment());

return externalReferenceAttachmentTypeRegistry;
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { CommentType, SECURITY_SOLUTION_OWNER } from '../../common';
import {
getPersistableStateAttachmentTypeRegistry,
createPersistableStateAttachmentTypeRegistryMock,
persistableStateAttachment,
persistableStateAttachmentAttributes,
} from './mocks';
Expand All @@ -19,7 +19,7 @@ import {
} from './so_references';

describe('Persistable state SO references', () => {
const persistableStateAttachmentTypeRegistry = getPersistableStateAttachmentTypeRegistry();
const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock();
const references = [
{
id: 'testRef',
Expand Down
80 changes: 80 additions & 0 deletions x-pack/plugins/cases/server/client/cases/create.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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 { SECURITY_SOLUTION_OWNER } from '../../../common';
import { CaseSeverity, ConnectorTypes } from '../../../common/api';
import { mockCases } from '../../mocks';
import { createCasesClientMockArgs } from '../mocks';
import { create } from './create';

describe('create', () => {
const theCase = {
title: 'My Case',
tags: [],
description: 'testing sir',
connector: {
id: '.none',
name: 'None',
type: ConnectorTypes.none,
fields: null,
},
settings: { syncAlerts: true },
severity: CaseSeverity.LOW,
owner: SECURITY_SOLUTION_OWNER,
assignees: [{ uid: '1' }],
};

const caseSO = mockCases[0];

describe('Assignees', () => {
const clientArgs = createCasesClientMockArgs();
clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO);

beforeEach(() => {
jest.clearAllMocks();
});

it('notifies single assignees', async () => {
await create(theCase, clientArgs);

expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({
assignees: theCase.assignees,
theCase: caseSO,
});
});

it('notifies multiple assignees', async () => {
await create({ ...theCase, assignees: [{ uid: '1' }, { uid: '2' }] }, clientArgs);

expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({
assignees: [{ uid: '1' }, { uid: '2' }],
theCase: caseSO,
});
});

it('does not notify when there are no assignees', async () => {
await create({ ...theCase, assignees: [] }, clientArgs);

expect(clientArgs.services.notificationService.notifyAssignees).not.toHaveBeenCalled();
});

it('does not notify the current user', async () => {
await create(
{
...theCase,
assignees: [{ uid: '1' }, { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }],
},
clientArgs
);

expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({
assignees: [{ uid: '1' }],
theCase: caseSO,
});
});
});
});
23 changes: 17 additions & 6 deletions x-pack/plugins/cases/server/client/cases/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const create = async (
): Promise<CaseResponse> => {
const {
unsecuredSavedObjectsClient,
services: { caseService, userActionService, licensingService },
services: { caseService, userActionService, licensingService, notificationService },
user,
logger,
authorization: auth,
Expand Down Expand Up @@ -116,11 +116,22 @@ export const create = async (
owner: newCase.attributes.owner,
});

return CaseResponseRt.encode(
flattenCaseSavedObject({
savedObject: newCase,
})
);
const flattenedCase = flattenCaseSavedObject({
savedObject: newCase,
});

if (query.assignees && query.assignees.length !== 0) {
const assigneesWithoutCurrentUser = query.assignees.filter(
(assignee) => assignee.uid !== user.profile_uid
);

await notificationService.notifyAssignees({
assignees: assigneesWithoutCurrentUser,
theCase: newCase,
});
}

return CaseResponseRt.encode(flattenedCase);
} catch (error) {
throw createCaseError({ message: `Failed to create case: ${error}`, error, logger });
}
Expand Down
5 changes: 3 additions & 2 deletions x-pack/plugins/cases/server/client/cases/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';

import type { SavedObject, SavedObjectsResolveResponse } from '@kbn/core/server';
import type { SavedObjectsResolveResponse } from '@kbn/core/server';
import type {
CaseResponse,
CaseResolveResponse,
Expand Down Expand Up @@ -37,6 +37,7 @@ import type { CasesClientArgs } from '..';
import { Operations } from '../../authorization';
import { combineAuthorizedAndOwnerFilter } from '../utils';
import { CasesService } from '../../services';
import type { CaseSavedObject } from '../../common/types';

/**
* Parameters for finding cases IDs using an alert ID
Expand Down Expand Up @@ -182,7 +183,7 @@ export const get = async (
} = clientArgs;

try {
const theCase: SavedObject<CaseAttributes> = await caseService.getCase({
const theCase: CaseSavedObject = await caseService.getCase({
id,
});

Expand Down
Loading

0 comments on commit 82083c4

Please sign in to comment.