From fed32978d931a65726be11b420ec1d664aa3a54b Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 4 Aug 2021 10:39:21 -0400 Subject: [PATCH] [Cases] Migrate connector ID to references (#104221) * Starting configure migration * Initial refactor of configuration connector id * Additional clean up and tests * Adding some tests * Finishing configure tests * Starting case attributes transformation refactor * adding more tests for the cases service * Adding more functionality and tests for cases migration * Finished unit tests for cases transition * Finished tests and moved types * Cleaning up type names * Fixing types and renaming * Adding more tests directly for the transformations * Fixing tests and renaming some functions * Adding transformation helper tests * Adding migration utility tests and some clean up * Begining logic to remove references when it is the none connector * Fixing merge reference bug * Addressing feedback * Changing test name and creating constants file --- x-pack/plugins/cases/common/api/cases/case.ts | 24 +- .../cases/common/api/cases/configure.ts | 6 +- .../cases/common/api/connectors/index.ts | 15 +- .../components/user_action_tree/helpers.tsx | 5 +- .../cases/server/client/cases/create.ts | 15 +- .../plugins/cases/server/client/cases/get.ts | 4 +- .../plugins/cases/server/client/cases/push.ts | 9 +- .../cases/server/client/cases/update.ts | 163 ++- .../cases/server/client/configure/client.ts | 33 +- .../cases/server/client/sub_cases/update.ts | 6 +- .../plugins/cases/server/client/utils.test.ts | 172 +-- x-pack/plugins/cases/server/client/utils.ts | 29 - .../plugins/cases/server/common/constants.ts | 17 + x-pack/plugins/cases/server/common/index.ts | 1 + .../server/common/models/commentable_case.ts | 15 +- .../plugins/cases/server/common/utils.test.ts | 15 +- x-pack/plugins/cases/server/common/utils.ts | 66 +- .../api/__fixtures__/mock_saved_objects.ts | 85 +- .../cases/server/saved_object_types/cases.ts | 6 - .../server/saved_object_types/configure.ts | 3 - .../migrations/cases.test.ts | 351 +++++ .../saved_object_types/migrations/cases.ts | 135 ++ .../migrations/configuration.test.ts | 126 ++ .../migrations/configuration.ts | 78 ++ .../{migrations.ts => migrations/index.ts} | 139 +- .../migrations/utils.test.ts | 229 ++++ .../saved_object_types/migrations/utils.ts | 73 ++ .../cases/server/services/cases/index.test.ts | 1167 +++++++++++++++++ .../cases/server/services/cases/index.ts | 93 +- .../server/services/cases/transform.test.ts | 414 ++++++ .../cases/server/services/cases/transform.ts | 208 +++ .../cases/server/services/cases/types.ts | 32 + .../server/services/configure/index.test.ts | 722 ++++++++++ .../cases/server/services/configure/index.ts | 176 ++- .../cases/server/services/configure/types.ts | 17 + .../connector_reference_handler.test.ts | 148 +++ .../services/connector_reference_handler.ts | 57 + x-pack/plugins/cases/server/services/index.ts | 12 + .../cases/server/services/test_utils.ts | 200 +++ .../cases/server/services/transform.test.ts | 211 +++ .../cases/server/services/transform.ts | 100 ++ .../server/services/user_actions/helpers.ts | 35 +- .../server/services/user_actions/index.ts | 1 + .../case_api_integration/common/lib/utils.ts | 45 + .../tests/common/cases/migrations.ts | 89 +- .../tests/common/configure/migrations.ts | 66 +- .../tests/trial/cases/push_case.ts | 67 + .../cases/migrations/7.13.2/data.json.gz | Bin 1351 -> 1340 bytes 48 files changed, 4957 insertions(+), 723 deletions(-) create mode 100644 x-pack/plugins/cases/server/common/constants.ts create mode 100644 x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts create mode 100644 x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts create mode 100644 x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts create mode 100644 x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts rename x-pack/plugins/cases/server/saved_object_types/{migrations.ts => migrations/index.ts} (53%) create mode 100644 x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts create mode 100644 x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts create mode 100644 x-pack/plugins/cases/server/services/cases/index.test.ts create mode 100644 x-pack/plugins/cases/server/services/cases/transform.test.ts create mode 100644 x-pack/plugins/cases/server/services/cases/transform.ts create mode 100644 x-pack/plugins/cases/server/services/cases/types.ts create mode 100644 x-pack/plugins/cases/server/services/configure/index.test.ts create mode 100644 x-pack/plugins/cases/server/services/configure/types.ts create mode 100644 x-pack/plugins/cases/server/services/connector_reference_handler.test.ts create mode 100644 x-pack/plugins/cases/server/services/connector_reference_handler.ts create mode 100644 x-pack/plugins/cases/server/services/test_utils.ts create mode 100644 x-pack/plugins/cases/server/services/transform.test.ts create mode 100644 x-pack/plugins/cases/server/services/transform.ts diff --git a/x-pack/plugins/cases/common/api/cases/case.ts b/x-pack/plugins/cases/common/api/cases/case.ts index a72eda5bb1207..37a491cdad4c0 100644 --- a/x-pack/plugins/cases/common/api/cases/case.ts +++ b/x-pack/plugins/cases/common/api/cases/case.ts @@ -11,7 +11,7 @@ import { NumberFromString } from '../saved_object'; import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt, CaseStatusRt } from './status'; -import { CaseConnectorRt, ESCaseConnector } from '../connectors'; +import { CaseConnectorRt } from '../connectors'; import { SubCaseResponseRt } from './sub_case'; const BucketsAggs = rt.array( @@ -87,24 +87,17 @@ const CaseBasicRt = rt.type({ owner: rt.string, }); -const CaseExternalServiceBasicRt = rt.type({ - connector_id: rt.string, +export const CaseExternalServiceBasicRt = rt.type({ + connector_id: rt.union([rt.string, rt.null]), connector_name: rt.string, external_id: rt.string, external_title: rt.string, external_url: rt.string, + pushed_at: rt.string, + pushed_by: UserRT, }); -const CaseFullExternalServiceRt = rt.union([ - rt.intersection([ - CaseExternalServiceBasicRt, - rt.type({ - pushed_at: rt.string, - pushed_by: UserRT, - }), - ]), - rt.null, -]); +const CaseFullExternalServiceRt = rt.union([CaseExternalServiceBasicRt, rt.null]); export const CaseAttributesRt = rt.intersection([ CaseBasicRt, @@ -326,11 +319,6 @@ export type CaseFullExternalService = rt.TypeOf; export type ExternalServiceResponse = rt.TypeOf; -export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; -export type ESCasePatchRequest = Omit & { - connector?: ESCaseConnector; -}; - export type AllTagsFindRequest = rt.TypeOf; export type AllReportersFindRequest = AllTagsFindRequest; diff --git a/x-pack/plugins/cases/common/api/cases/configure.ts b/x-pack/plugins/cases/common/api/cases/configure.ts index 6c92702c523b4..bf67624df8508 100644 --- a/x-pack/plugins/cases/common/api/cases/configure.ts +++ b/x-pack/plugins/cases/common/api/cases/configure.ts @@ -8,7 +8,7 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; -import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; +import { CaseConnectorRt, ConnectorMappingsRt } from '../connectors'; // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); @@ -83,8 +83,4 @@ export type CasesConfigureAttributes = rt.TypeOf; export type CasesConfigurationsResponse = rt.TypeOf; -export type ESCasesConfigureAttributes = Omit & { - connector: ESCaseConnector; -}; - export type GetConfigureFindRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/api/connectors/index.ts b/x-pack/plugins/cases/common/api/connectors/index.ts index cee432b17933b..77af90b5d08cb 100644 --- a/x-pack/plugins/cases/common/api/connectors/index.ts +++ b/x-pack/plugins/cases/common/api/connectors/index.ts @@ -73,6 +73,8 @@ const ConnectorNoneTypeFieldsRt = rt.type({ fields: rt.null, }); +export const noneConnectorId: string = 'none'; + export const ConnectorTypeFieldsRt = rt.union([ ConnectorJiraTypeFieldsRt, ConnectorNoneTypeFieldsRt, @@ -102,16 +104,3 @@ export type ConnectorServiceNowSIRTypeFields = rt.TypeOf; - -export type ESConnectorFields = Array<{ - key: string; - value: unknown; -}>; - -export type ESCaseConnectorTypes = ConnectorTypes; -export interface ESCaseConnector { - id: string; - name: string; - type: ESCaseConnectorTypes; - fields: ESConnectorFields | null; -} diff --git a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx index 609183aa5c4ef..744b14926b358 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/helpers.tsx @@ -157,10 +157,11 @@ export const getPushedServiceLabelTitle = (action: CaseUserActions, firstPush: b export const getPushInfo = ( caseServices: CaseServices, - parsedValue: { connector_id: string; connector_name: string }, + // a JSON parse failure will result in null for parsedValue + parsedValue: { connector_id: string | null; connector_name: string } | null, index: number ) => - parsedValue != null + parsedValue != null && parsedValue.connector_id != null ? { firstPush: caseServices[parsedValue.connector_id]?.firstPushIndex === index, parsedConnectorId: parsedValue.connector_id, diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index 03ea76ede5c2e..887990fef8938 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -25,15 +25,9 @@ import { MAX_TITLE_LENGTH, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; -import { getConnectorFromConfiguration } from '../utils'; import { Operations } from '../../authorization'; -import { - createCaseError, - flattenCaseSavedObject, - transformCaseConnectorToEsConnector, - transformNewCase, -} from '../../common'; +import { createCaseError, flattenCaseSavedObject, transformNewCase } from '../../common'; import { CasesClientArgs } from '..'; /** @@ -48,7 +42,6 @@ export const create = async ( const { unsecuredSavedObjectsClient, caseService, - caseConfigureService, userActionService, user, logger, @@ -90,10 +83,6 @@ export const create = async ( // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = user; const createdDate = new Date().toISOString(); - const myCaseConfigure = await caseConfigureService.find({ - unsecuredSavedObjectsClient, - }); - const caseConfigureConnector = getConnectorFromConfiguration(myCaseConfigure); const newCase = await caseService.postNewCase({ unsecuredSavedObjectsClient, @@ -103,7 +92,7 @@ export const create = async ( username, full_name, email, - connector: transformCaseConnectorToEsConnector(query.connector ?? caseConfigureConnector), + connector: query.connector, }), id: savedObjectID, }); diff --git a/x-pack/plugins/cases/server/client/cases/get.ts b/x-pack/plugins/cases/server/client/cases/get.ts index 4f8713704361b..d440cecd27e9b 100644 --- a/x-pack/plugins/cases/server/client/cases/get.ts +++ b/x-pack/plugins/cases/server/client/cases/get.ts @@ -13,7 +13,6 @@ import { SavedObject } from 'kibana/server'; import { CaseResponseRt, CaseResponse, - ESCaseAttributes, User, UsersRt, AllTagsFindRequest, @@ -27,6 +26,7 @@ import { ENABLE_CASE_CONNECTOR, CasesByAlertId, CasesByAlertIdRt, + CaseAttributes, } from '../../../common'; import { countAlertsForID, createCaseError, flattenCaseSavedObject } from '../../common'; import { CasesClientArgs } from '..'; @@ -171,7 +171,7 @@ export const get = async ( ); } - let theCase: SavedObject; + let theCase: SavedObject; let subCaseIds: string[] = []; if (ENABLE_CASE_CONNECTOR) { const [caseInfo, subCasesForCaseId] = await Promise.all([ diff --git a/x-pack/plugins/cases/server/client/cases/push.ts b/x-pack/plugins/cases/server/client/cases/push.ts index 9e2066984a9da..3048cf01bb3ba 100644 --- a/x-pack/plugins/cases/server/client/cases/push.ts +++ b/x-pack/plugins/cases/server/client/cases/push.ts @@ -14,10 +14,10 @@ import { CaseResponse, CaseStatuses, ExternalServiceResponse, - ESCaseAttributes, - ESCasesConfigureAttributes, CaseType, ENABLE_CASE_CONNECTOR, + CasesConfigureAttributes, + CaseAttributes, } from '../../../common'; import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; @@ -33,8 +33,8 @@ import { casesConnectors } from '../../connectors'; * In the future we could allow push to close all the sub cases of a collection but that's not currently supported. */ function shouldCloseByPush( - configureSettings: SavedObjectsFindResponse, - caseInfo: SavedObject + configureSettings: SavedObjectsFindResponse, + caseInfo: SavedObject ): boolean { return ( configureSettings.total > 0 && @@ -186,6 +186,7 @@ export const push = async ( const [updatedCase, updatedComments] = await Promise.all([ caseService.patchCase({ + originalCase: myCase, unsecuredSavedObjectsClient, caseId, updatedAttributes: { diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index afe43171563ce..ed19444414d57 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -34,13 +34,12 @@ import { CommentAttributes, CommentType, ENABLE_CASE_CONNECTOR, - ESCaseAttributes, - ESCasePatchRequest, excess, MAX_CONCURRENT_SEARCHES, SUB_CASE_SAVED_OBJECT, throwErrors, MAX_TITLE_LENGTH, + CaseAttributes, } from '../../../common'; import { buildCaseUserActions } from '../../services/user_actions/helpers'; import { getCaseToUpdate } from '../utils'; @@ -51,7 +50,6 @@ import { createCaseError, flattenCaseSavedObject, isCommentRequestTypeAlertOrGenAlert, - transformCaseConnectorToEsConnector, } from '../../common'; import { UpdateAlertRequest } from '../alerts/types'; import { CasesClientInternal } from '../client_internal'; @@ -61,17 +59,14 @@ import { Operations, OwnerEntity } from '../../authorization'; /** * Throws an error if any of the requests attempt to update a collection style cases' status field. */ -function throwIfUpdateStatusOfCollection( - requests: ESCasePatchRequest[], - casesMap: Map> -) { +function throwIfUpdateStatusOfCollection(requests: UpdateRequestWithOriginalCase[]) { const requestsUpdatingStatusOfCollection = requests.filter( - (req) => - req.status !== undefined && casesMap.get(req.id)?.attributes.type === CaseType.collection + ({ updateReq, originalCase }) => + updateReq.status !== undefined && originalCase.attributes.type === CaseType.collection ); if (requestsUpdatingStatusOfCollection.length > 0) { - const ids = requestsUpdatingStatusOfCollection.map((req) => req.id); + const ids = requestsUpdatingStatusOfCollection.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `Updating the status of a collection is not allowed ids: [${ids.join(', ')}]` ); @@ -81,18 +76,14 @@ function throwIfUpdateStatusOfCollection( /** * Throws an error if any of the requests attempt to update a collection style case to an individual one. */ -function throwIfUpdateTypeCollectionToIndividual( - requests: ESCasePatchRequest[], - casesMap: Map> -) { +function throwIfUpdateTypeCollectionToIndividual(requests: UpdateRequestWithOriginalCase[]) { const requestsUpdatingTypeCollectionToInd = requests.filter( - (req) => - req.type === CaseType.individual && - casesMap.get(req.id)?.attributes.type === CaseType.collection + ({ updateReq, originalCase }) => + updateReq.type === CaseType.individual && originalCase.attributes.type === CaseType.collection ); if (requestsUpdatingTypeCollectionToInd.length > 0) { - const ids = requestsUpdatingTypeCollectionToInd.map((req) => req.id); + const ids = requestsUpdatingTypeCollectionToInd.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `Converting a collection to an individual case is not allowed ids: [${ids.join(', ')}]` ); @@ -102,11 +93,11 @@ function throwIfUpdateTypeCollectionToIndividual( /** * Throws an error if any of the requests attempt to update the type of a case. */ -function throwIfUpdateType(requests: ESCasePatchRequest[]) { - const requestsUpdatingType = requests.filter((req) => req.type !== undefined); +function throwIfUpdateType(requests: UpdateRequestWithOriginalCase[]) { + const requestsUpdatingType = requests.filter(({ updateReq }) => updateReq.type !== undefined); if (requestsUpdatingType.length > 0) { - const ids = requestsUpdatingType.map((req) => req.id); + const ids = requestsUpdatingType.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `Updating the type of a case when sub cases are disabled is not allowed ids: [${ids.join( ', ' @@ -118,11 +109,11 @@ function throwIfUpdateType(requests: ESCasePatchRequest[]) { /** * Throws an error if any of the requests attempt to update the owner of a case. */ -function throwIfUpdateOwner(requests: ESCasePatchRequest[]) { - const requestsUpdatingOwner = requests.filter((req) => req.owner !== undefined); +function throwIfUpdateOwner(requests: UpdateRequestWithOriginalCase[]) { + const requestsUpdatingOwner = requests.filter(({ updateReq }) => updateReq.owner !== undefined); if (requestsUpdatingOwner.length > 0) { - const ids = requestsUpdatingOwner.map((req) => req.id); + const ids = requestsUpdatingOwner.map(({ updateReq }) => updateReq.id); throw Boom.badRequest(`Updating the owner of a case is not allowed ids: [${ids.join(', ')}]`); } } @@ -136,14 +127,14 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ caseService, unsecuredSavedObjectsClient, }: { - requests: ESCasePatchRequest[]; + requests: UpdateRequestWithOriginalCase[]; caseService: CasesService; unsecuredSavedObjectsClient: SavedObjectsClientContract; }) { - const getAlertsForID = async (caseToUpdate: ESCasePatchRequest) => { + const getAlertsForID = async ({ updateReq }: UpdateRequestWithOriginalCase) => { const alerts = await caseService.getAllCaseComments({ unsecuredSavedObjectsClient, - id: caseToUpdate.id, + id: updateReq.id, options: { fields: [], // there should never be generated alerts attached to an individual case but we'll check anyway @@ -159,11 +150,14 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ }, }); - return { id: caseToUpdate.id, alerts }; + return { id: updateReq.id, alerts }; }; - const requestsUpdatingTypeField = requests.filter((req) => req.type === CaseType.collection); - const getAlertsMapper = async (caseToUpdate: ESCasePatchRequest) => getAlertsForID(caseToUpdate); + const requestsUpdatingTypeField = requests.filter( + ({ updateReq }) => updateReq.type === CaseType.collection + ); + const getAlertsMapper = async (caseToUpdate: UpdateRequestWithOriginalCase) => + getAlertsForID(caseToUpdate); // Ensuring we don't too many concurrent get running. const casesAlertTotals = await pMap(requestsUpdatingTypeField, getAlertsMapper, { concurrency: MAX_CONCURRENT_SEARCHES, @@ -185,13 +179,13 @@ async function throwIfInvalidUpdateOfTypeWithAlerts({ /** * Throws an error if any of the requests updates a title and the length is over MAX_TITLE_LENGTH. */ -function throwIfTitleIsInvalid(requests: ESCasePatchRequest[]) { +function throwIfTitleIsInvalid(requests: UpdateRequestWithOriginalCase[]) { const requestsInvalidTitle = requests.filter( - (req) => req.title !== undefined && req.title.length > MAX_TITLE_LENGTH + ({ updateReq }) => updateReq.title !== undefined && updateReq.title.length > MAX_TITLE_LENGTH ); if (requestsInvalidTitle.length > 0) { - const ids = requestsInvalidTitle.map((req) => req.id); + const ids = requestsInvalidTitle.map(({ updateReq }) => updateReq.id); throw Boom.badRequest( `The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}, ids: [${ids.join( ', ' @@ -218,11 +212,11 @@ async function getAlertComments({ caseService, unsecuredSavedObjectsClient, }: { - casesToSync: ESCasePatchRequest[]; + casesToSync: UpdateRequestWithOriginalCase[]; caseService: CasesService; unsecuredSavedObjectsClient: SavedObjectsClientContract; }): Promise> { - const idsOfCasesToSync = casesToSync.map((casePatchReq) => casePatchReq.id); + const idsOfCasesToSync = casesToSync.map(({ updateReq }) => updateReq.id); // getAllCaseComments will by default get all the comments, unless page or perPage fields are set return caseService.getAllCaseComments({ @@ -310,14 +304,12 @@ function getSyncStatusForComment({ async function updateAlerts({ casesWithSyncSettingChangedToOn, casesWithStatusChangedAndSynced, - casesMap, caseService, unsecuredSavedObjectsClient, casesClientInternal, }: { - casesWithSyncSettingChangedToOn: ESCasePatchRequest[]; - casesWithStatusChangedAndSynced: ESCasePatchRequest[]; - casesMap: Map>; + casesWithSyncSettingChangedToOn: UpdateRequestWithOriginalCase[]; + casesWithStatusChangedAndSynced: UpdateRequestWithOriginalCase[]; caseService: CasesService; unsecuredSavedObjectsClient: SavedObjectsClientContract; casesClientInternal: CasesClientInternal; @@ -331,11 +323,8 @@ async function updateAlerts({ // build a map of case id to the status it has // this will have collections in it but the alerts should be associated to sub cases and not collections so it shouldn't // matter. - const casesToSyncToStatus = casesToSync.reduce((acc, caseInfo) => { - acc.set( - caseInfo.id, - caseInfo.status ?? casesMap.get(caseInfo.id)?.attributes.status ?? CaseStatuses.open - ); + const casesToSyncToStatus = casesToSync.reduce((acc, { updateReq, originalCase }) => { + acc.set(updateReq.id, updateReq.status ?? originalCase.attributes.status ?? CaseStatuses.open); return acc; }, new Map()); @@ -376,7 +365,7 @@ async function updateAlerts({ } function partitionPatchRequest( - casesMap: Map>, + casesMap: Map>, patchReqCases: CasePatchRequest[] ): { nonExistingCases: CasePatchRequest[]; @@ -409,6 +398,11 @@ function partitionPatchRequest( }; } +interface UpdateRequestWithOriginalCase { + updateReq: CasePatchRequest; + originalCase: SavedObject; +} + /** * Updates the specified cases with new values * @@ -441,7 +435,7 @@ export const update = async ( const casesMap = myCases.saved_objects.reduce((acc, so) => { acc.set(so.id, so); return acc; - }, new Map>()); + }, new Map>()); const { nonExistingCases, conflictedCases, casesToAuthorize } = partitionPatchRequest( casesMap, @@ -469,38 +463,41 @@ export const update = async ( ); } - const updateCases: ESCasePatchRequest[] = query.cases.map((updateCase) => { - const currentCase = myCases.saved_objects.find((c) => c.id === updateCase.id); - const { connector, ...thisCase } = updateCase; - return currentCase != null - ? getCaseToUpdate(currentCase.attributes, { - ...thisCase, - ...(connector != null - ? { connector: transformCaseConnectorToEsConnector(connector) } - : {}), - }) - : { id: thisCase.id, version: thisCase.version }; - }); + const updateCases: UpdateRequestWithOriginalCase[] = query.cases.reduce( + (acc: UpdateRequestWithOriginalCase[], updateCase) => { + const originalCase = casesMap.get(updateCase.id); - const updateFilterCases = updateCases.filter((updateCase) => { - const { id, version, ...updateCaseAttributes } = updateCase; - return Object.keys(updateCaseAttributes).length > 0; - }); + if (!originalCase) { + return acc; + } + + const fieldsToUpdate = getCaseToUpdate(originalCase.attributes, updateCase); - if (updateFilterCases.length <= 0) { + const { id, version, ...restFields } = fieldsToUpdate; + + if (Object.keys(restFields).length > 0) { + acc.push({ originalCase, updateReq: fieldsToUpdate }); + } + + return acc; + }, + [] + ); + + if (updateCases.length <= 0) { throw Boom.notAcceptable('All update fields are identical to current version.'); } if (!ENABLE_CASE_CONNECTOR) { - throwIfUpdateType(updateFilterCases); + throwIfUpdateType(updateCases); } - throwIfUpdateOwner(updateFilterCases); - throwIfTitleIsInvalid(updateFilterCases); - throwIfUpdateStatusOfCollection(updateFilterCases, casesMap); - throwIfUpdateTypeCollectionToIndividual(updateFilterCases, casesMap); + throwIfUpdateOwner(updateCases); + throwIfTitleIsInvalid(updateCases); + throwIfUpdateStatusOfCollection(updateCases); + throwIfUpdateTypeCollectionToIndividual(updateCases); await throwIfInvalidUpdateOfTypeWithAlerts({ - requests: updateFilterCases, + requests: updateCases, caseService, unsecuredSavedObjectsClient, }); @@ -510,9 +507,9 @@ export const update = async ( const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ unsecuredSavedObjectsClient, - cases: updateFilterCases.map((thisCase) => { + cases: updateCases.map(({ updateReq, originalCase }) => { // intentionally removing owner from the case so that we don't accidentally allow it to be updated - const { id: caseId, version, owner, ...updateCaseAttributes } = thisCase; + const { id: caseId, version, owner, ...updateCaseAttributes } = updateReq; let closedInfo = {}; if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) { closedInfo = { @@ -531,6 +528,7 @@ export const update = async ( } return { caseId, + originalCase, updatedAttributes: { ...updateCaseAttributes, ...closedInfo, @@ -544,25 +542,23 @@ export const update = async ( // If a status update occurred and the case is synced then we need to update all alerts' status // attached to the case to the new status. - const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + const casesWithStatusChangedAndSynced = updateCases.filter(({ updateReq, originalCase }) => { return ( - currentCase != null && - caseToUpdate.status != null && - currentCase.attributes.status !== caseToUpdate.status && - currentCase.attributes.settings.syncAlerts + originalCase != null && + updateReq.status != null && + originalCase.attributes.status !== updateReq.status && + originalCase.attributes.settings.syncAlerts ); }); // If syncAlerts setting turned on we need to update all alerts' status // attached to the case to the current status. - const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { - const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + const casesWithSyncSettingChangedToOn = updateCases.filter(({ updateReq, originalCase }) => { return ( - currentCase != null && - caseToUpdate.settings?.syncAlerts != null && - currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && - caseToUpdate.settings.syncAlerts + originalCase != null && + updateReq.settings?.syncAlerts != null && + originalCase.attributes.settings.syncAlerts !== updateReq.settings.syncAlerts && + updateReq.settings.syncAlerts ); }); @@ -573,7 +569,6 @@ export const update = async ( caseService, unsecuredSavedObjectsClient, casesClientInternal, - casesMap, }); const returnUpdatedCase = myCases.saved_objects diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index e8ff984fef994..ad7e1322c9e06 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -20,13 +20,13 @@ import { CaseConfigurationsResponseRt, CaseConfigureResponseRt, CasesConfigurationsResponse, + CasesConfigureAttributes, CasesConfigurePatch, CasesConfigurePatchRt, CasesConfigureRequest, CasesConfigureResponse, ConnectorMappings, ConnectorMappingsAttributes, - ESCasesConfigureAttributes, excess, GetConfigureFindRequest, GetConfigureFindRequestRt, @@ -34,11 +34,7 @@ import { SUPPORTED_CONNECTORS, throwErrors, } from '../../../common'; -import { - createCaseError, - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, -} from '../../common'; +import { createCaseError } from '../../common'; import { CasesClientInternal } from '../client_internal'; import { CasesClientArgs } from '../types'; import { getMappings } from './get_mappings'; @@ -174,7 +170,7 @@ async function get( const configurations = await pMap( myCaseConfigure.saved_objects, - async (configuration: SavedObject) => { + async (configuration: SavedObject) => { const { connector, ...caseConfigureWithoutConnector } = configuration?.attributes ?? { connector: null, }; @@ -184,7 +180,7 @@ async function get( if (connector != null) { try { mappings = await casesClientInternal.configuration.getMappings({ - connector: transformESConnectorToCaseConnector(connector), + connector, }); } catch (e) { error = e.isBoom @@ -195,7 +191,7 @@ async function get( return { ...caseConfigureWithoutConnector, - connector: transformESConnectorToCaseConnector(connector), + connector, mappings: mappings.length > 0 ? mappings[0].attributes.mappings : [], version: configuration.version ?? '', error, @@ -292,11 +288,9 @@ async function update( try { const resMappings = await casesClientInternal.configuration.getMappings({ - connector: - connector != null - ? connector - : transformESConnectorToCaseConnector(configuration.attributes.connector), + connector: connector != null ? connector : configuration.attributes.connector, }); + mappings = resMappings.length > 0 ? resMappings[0].attributes.mappings : []; if (connector != null) { @@ -325,18 +319,17 @@ async function update( configurationId: configuration.id, updatedAttributes: { ...queryWithoutVersionAndConnector, - ...(connector != null ? { connector: transformCaseConnectorToEsConnector(connector) } : {}), + ...(connector != null && { connector }), updated_at: updateDate, updated_by: user, }, + originalConfiguration: configuration, }); return CaseConfigureResponseRt.encode({ ...configuration.attributes, ...patch.attributes, - connector: transformESConnectorToCaseConnector( - patch.attributes.connector ?? configuration.attributes.connector - ), + connector: patch.attributes.connector ?? configuration.attributes.connector, mappings, version: patch.version ?? '', error, @@ -397,7 +390,7 @@ async function create( ); if (myCaseConfigure.saved_objects.length > 0) { - const deleteConfigurationMapper = async (c: SavedObject) => + const deleteConfigurationMapper = async (c: SavedObject) => caseConfigureService.delete({ unsecuredSavedObjectsClient, configurationId: c.id }); // Ensuring we don't too many concurrent deletions running. @@ -431,7 +424,7 @@ async function create( unsecuredSavedObjectsClient, attributes: { ...configuration, - connector: transformCaseConnectorToEsConnector(configuration.connector), + connector: configuration.connector, created_at: creationDate, created_by: user, updated_at: null, @@ -443,7 +436,7 @@ async function create( return CaseConfigureResponseRt.encode({ ...post.attributes, // Reserve for future implementations - connector: transformESConnectorToCaseConnector(post.attributes.connector), + connector: post.attributes.connector, mappings, version: post.version ?? '', error, diff --git a/x-pack/plugins/cases/server/client/sub_cases/update.ts b/x-pack/plugins/cases/server/client/sub_cases/update.ts index be671a8087f8e..c8cb96cbb6b8c 100644 --- a/x-pack/plugins/cases/server/client/sub_cases/update.ts +++ b/x-pack/plugins/cases/server/client/sub_cases/update.ts @@ -23,7 +23,6 @@ import { CaseStatuses, CommentAttributes, CommentType, - ESCaseAttributes, excess, SUB_CASE_SAVED_OBJECT, SubCaseAttributes, @@ -35,6 +34,7 @@ import { SubCasesResponseRt, throwErrors, User, + CaseAttributes, } from '../../../common'; import { getCaseToUpdate } from '../utils'; import { buildSubCaseUserActions } from '../../services/user_actions/helpers'; @@ -124,7 +124,7 @@ async function getParentCases({ unsecuredSavedObjectsClient: SavedObjectsClientContract; subCaseIDs: string[]; subCasesMap: Map>; -}): Promise>> { +}): Promise>> { const parentIDInfo = getParentIDs({ subCaseIDs, subCasesMap }); const parentCases = await caseService.getCases({ @@ -148,7 +148,7 @@ async function getParentCases({ acc.set(subCaseId, so); }); return acc; - }, new Map>()); + }, new Map>()); } function getValidUpdateRequests( diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts index c8ed1f4f0efa6..45ea6bacb0f51 100644 --- a/x-pack/plugins/cases/server/client/utils.test.ts +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -5,113 +5,12 @@ * 2.0. */ -import { SavedObjectsFindResponse } from 'kibana/server'; -import { - CaseConnector, - CaseType, - ConnectorTypes, - ESCaseConnector, - ESCasesConfigureAttributes, -} from '../../common/api'; -import { mockCaseConfigure } from '../routes/api/__fixtures__'; +import { CaseConnector, CaseType, ConnectorTypes } from '../../common/api'; import { newCase } from '../routes/api/__mocks__/request_responses'; -import { - transformCaseConnectorToEsConnector, - transformESConnectorToCaseConnector, - transformNewCase, -} from '../common'; -import { getConnectorFromConfiguration, sortToSnake } from './utils'; +import { transformNewCase } from '../common'; +import { sortToSnake } from './utils'; describe('utils', () => { - const caseConnector: CaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - - const esCaseConnector: ESCaseConnector = { - id: '123', - name: 'Jira', - type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], - }; - - const caseConfigure: SavedObjectsFindResponse = { - saved_objects: [{ ...mockCaseConfigure[0], score: 0 }], - total: 1, - per_page: 20, - page: 1, - }; - - describe('transformCaseConnectorToEsConnector', () => { - it('transform correctly', () => { - expect(transformCaseConnectorToEsConnector(caseConnector)).toEqual(esCaseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformCaseConnectorToEsConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: [], - }); - }); - }); - - describe('transformESConnectorToCaseConnector', () => { - it('transform correctly', () => { - expect(transformESConnectorToCaseConnector(esCaseConnector)).toEqual(caseConnector); - }); - - it('transform correctly with null attributes', () => { - // @ts-ignore this is case the connector does not exist for old cases object or configurations - expect(transformESConnectorToCaseConnector(null)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); - - describe('getConnectorFromConfiguration', () => { - it('transform correctly', () => { - expect(getConnectorFromConfiguration(caseConfigure)).toEqual({ - id: '789', - name: 'My connector 3', - type: ConnectorTypes.jira, - fields: null, - }); - }); - - it('transform correctly with no connector', () => { - const caseConfigureNoConnector: SavedObjectsFindResponse = { - ...caseConfigure, - saved_objects: [ - { - ...mockCaseConfigure[0], - // @ts-ignore this is case the connector does not exist for old cases object or configurations - attributes: { ...mockCaseConfigure[0].attributes, connector: null }, - score: 0, - }, - ], - }; - - expect(getConnectorFromConfiguration(caseConfigureNoConnector)).toEqual({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }); - }); - }); - describe('sortToSnake', () => { it('it transforms status correctly', () => { expect(sortToSnake('status')).toBe('status'); @@ -139,15 +38,11 @@ describe('utils', () => { }); describe('transformNewCase', () => { - const connector: ESCaseConnector = { + const connector: CaseConnector = { id: '123', name: 'My connector', type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], + fields: { issueType: 'Task', priority: 'High', parent: null }, }; it('transform correctly', () => { const myCase = { @@ -166,20 +61,11 @@ describe('utils', () => { "closed_at": null, "closed_by": null, "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, "id": "123", "name": "My connector", "type": ".jira", @@ -223,20 +109,11 @@ describe('utils', () => { "closed_at": null, "closed_by": null, "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, "id": "123", "name": "My connector", "type": ".jira", @@ -283,20 +160,11 @@ describe('utils', () => { "closed_at": null, "closed_by": null, "connector": Object { - "fields": Array [ - Object { - "key": "issueType", - "value": "Task", - }, - Object { - "key": "priority", - "value": "High", - }, - Object { - "key": "parent", - "value": null, - }, - ], + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, "id": "123", "name": "My connector", "type": ".jira", diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index 0e7a21816de4c..a6fd9984bfea6 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -12,20 +12,16 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; -import { SavedObjectsFindResponse } from 'kibana/server'; import { nodeBuilder, KueryNode } from '../../../../../src/plugins/data/common'; import { esKuery } from '../../../../../src/plugins/data/server'; import { AlertCommentRequestRt, ActionsCommentRequestRt, CASE_SAVED_OBJECT, - CaseConnector, CaseStatuses, CaseType, CommentRequest, - ConnectorTypes, ContextTypeUserRt, - ESCasesConfigureAttributes, excess, OWNER_FIELD, SUB_CASE_SAVED_OBJECT, @@ -437,31 +433,6 @@ export const getCaseToUpdate = ( { id: queryCase.id, version: queryCase.version } ); -export const getNoneCaseConnector = () => ({ - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, -}); - -export const getConnectorFromConfiguration = ( - caseConfigure: SavedObjectsFindResponse -): CaseConnector => { - let caseConnector = getNoneCaseConnector(); - if ( - caseConfigure.saved_objects.length > 0 && - caseConfigure.saved_objects[0].attributes.connector - ) { - caseConnector = { - id: caseConfigure.saved_objects[0].attributes.connector.id, - name: caseConfigure.saved_objects[0].attributes.connector.name, - type: caseConfigure.saved_objects[0].attributes.connector.type, - fields: null, - }; - } - return caseConnector; -}; - enum SortFieldCase { closedAt = 'closed_at', createdAt = 'created_at', diff --git a/x-pack/plugins/cases/server/common/constants.ts b/x-pack/plugins/cases/server/common/constants.ts new file mode 100644 index 0000000000000..1f6af310d6ece --- /dev/null +++ b/x-pack/plugins/cases/server/common/constants.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +/** + * The name of the saved object reference indicating the action connector ID. This is stored in the Saved Object reference + * field's name property. + */ +export const CONNECTOR_ID_REFERENCE_NAME = 'connectorId'; + +/** + * The name of the saved object reference indicating the action connector ID that was used to push a case. + */ +export const PUSH_CONNECTOR_ID_REFERENCE_NAME = 'pushConnectorId'; diff --git a/x-pack/plugins/cases/server/common/index.ts b/x-pack/plugins/cases/server/common/index.ts index 324c7e7ffd1a8..ae9af177c1bb4 100644 --- a/x-pack/plugins/cases/server/common/index.ts +++ b/x-pack/plugins/cases/server/common/index.ts @@ -9,3 +9,4 @@ export * from './models'; export * from './utils'; export * from './types'; export * from './error'; +export * from './constants'; diff --git a/x-pack/plugins/cases/server/common/models/commentable_case.ts b/x-pack/plugins/cases/server/common/models/commentable_case.ts index e082a0b290f16..03d6e5b8cea63 100644 --- a/x-pack/plugins/cases/server/common/models/commentable_case.ts +++ b/x-pack/plugins/cases/server/common/models/commentable_case.ts @@ -25,18 +25,13 @@ import { CommentPatchRequest, CommentRequest, CommentType, - ESCaseAttributes, MAX_DOCS_PER_PAGE, SUB_CASE_SAVED_OBJECT, SubCaseAttributes, User, + CaseAttributes, } from '../../../common'; -import { - transformESConnectorToCaseConnector, - flattenCommentSavedObjects, - flattenSubCaseSavedObject, - transformNewComment, -} from '..'; +import { flattenCommentSavedObjects, flattenSubCaseSavedObject, transformNewComment } from '..'; import { AttachmentService, CasesService } from '../../services'; import { createCaseError } from '../error'; import { countAlertsForID } from '../index'; @@ -52,7 +47,7 @@ interface NewCommentResp { } interface CommentableCaseParams { - collection: SavedObject; + collection: SavedObject; subCase?: SavedObject; unsecuredSavedObjectsClient: SavedObjectsClientContract; caseService: CasesService; @@ -65,7 +60,7 @@ interface CommentableCaseParams { * a Sub Case, Case, and Collection. */ export class CommentableCase { - private readonly collection: SavedObject; + private readonly collection: SavedObject; private readonly subCase?: SavedObject; private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; private readonly caseService: CasesService; @@ -168,6 +163,7 @@ export class CommentableCase { } const updatedCase = await this.caseService.patchCase({ + originalCase: this.collection, unsecuredSavedObjectsClient: this.unsecuredSavedObjectsClient, caseId: this.collection.id, updatedAttributes: { @@ -305,7 +301,6 @@ export class CommentableCase { version: this.collection.version ?? '0', totalComment, ...this.collection.attributes, - connector: transformESConnectorToCaseConnector(this.collection.attributes.connector), }; } diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 322e45094eda4..46ba33a74acd6 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -14,11 +14,7 @@ import { CommentRequest, CommentType, } from '../../common/api'; -import { - mockCaseComments, - mockCases, - mockCaseNoConnectorId, -} from '../routes/api/__fixtures__/mock_saved_objects'; +import { mockCaseComments, mockCases } from '../routes/api/__fixtures__/mock_saved_objects'; import { flattenCaseSavedObject, transformNewComment, @@ -470,14 +466,13 @@ describe('common utils', () => { `); }); - it('inserts missing connector', () => { + it('leaves the connector.id in the attributes', () => { const extraCaseData = { totalComment: 2, }; const res = flattenCaseSavedObject({ - // @ts-ignore this is to update old case saved objects to include connector - savedObject: mockCaseNoConnectorId, + savedObject: mockCases[0], ...extraCaseData, }); @@ -500,7 +495,8 @@ describe('common utils', () => { }, "description": "This is a brand new case of a bad meanie defacing data", "external_service": null, - "id": "mock-no-connector_id", + "id": "mock-id-1", + "owner": "securitySolution", "settings": Object { "syncAlerts": true, }, @@ -513,6 +509,7 @@ describe('common utils', () => { "title": "Super Bad Security Issue", "totalAlerts": 0, "totalComment": 2, + "type": "individual", "updated_at": "2019-11-25T21:54:48.952Z", "updated_by": Object { "email": "testemail@elastic.co", diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index 13d3f3768f391..bce37764467df 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -12,6 +12,7 @@ import { AlertInfo } from '.'; import { AssociationType, + CaseAttributes, CaseConnector, CaseResponse, CasesClientPostRequest, @@ -24,11 +25,8 @@ import { CommentResponse, CommentsResponse, CommentType, - ConnectorTypeFields, + ConnectorTypes, ENABLE_CASE_CONNECTOR, - ESCaseAttributes, - ESCaseConnector, - ESConnectorFields, SubCaseAttributes, SubCaseResponse, SubCasesFindResponse, @@ -55,13 +53,13 @@ export const transformNewCase = ({ newCase, username, }: { - connector: ESCaseConnector; + connector: CaseConnector; createdDate: string; email?: string | null; full_name?: string | null; newCase: CasesClientPostRequest; username?: string | null; -}): ESCaseAttributes => ({ +}): CaseAttributes => ({ ...newCase, closed_at: null, closed_by: null, @@ -135,7 +133,7 @@ export const flattenCaseSavedObject = ({ subCases, subCaseIds, }: { - savedObject: SavedObject; + savedObject: SavedObject; comments?: Array>; totalComment?: number; totalAlerts?: number; @@ -148,7 +146,6 @@ export const flattenCaseSavedObject = ({ totalComment, totalAlerts, ...savedObject.attributes, - connector: transformESConnectorToCaseConnector(savedObject.attributes.connector), subCases, subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined, }); @@ -196,47 +193,6 @@ export const flattenCommentSavedObject = ( ...savedObject.attributes, }); -export const transformCaseConnectorToEsConnector = (connector: CaseConnector): ESCaseConnector => ({ - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - type: connector?.type ?? '.none', - fields: - connector?.fields != null - ? Object.entries(connector.fields).reduce( - (acc, [key, value]) => [ - ...acc, - { - key, - value, - }, - ], - [] - ) - : [], -}); - -export const transformESConnectorToCaseConnector = (connector?: ESCaseConnector): CaseConnector => { - const connectorTypeField = { - type: connector?.type ?? '.none', - fields: - connector && connector.fields != null && connector.fields.length > 0 - ? connector.fields.reduce( - (fields, { key, value }) => ({ - ...fields, - [key]: value, - }), - {} - ) - : null, - } as ConnectorTypeFields; - - return { - id: connector?.id ?? 'none', - name: connector?.name ?? 'none', - ...connectorTypeField, - }; -}; - export const getIDsAndIndicesAsArrays = ( comment: CommentRequestAlertType ): { ids: string[]; indices: string[] } => { @@ -430,3 +386,15 @@ export function checkEnabledCaseConnectorOrThrow(subCaseID: string | undefined) ); } } + +/** + * Returns a connector that indicates that no connector was set. + * + * @returns the 'none' connector + */ +export const getNoneCaseConnector = () => ({ + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, +}); diff --git a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts index 625324312e6b9..1551f0fa611b7 100644 --- a/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/cases/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -8,17 +8,16 @@ import { SavedObject } from 'kibana/server'; import { AssociationType, + CaseAttributes, CaseStatuses, CaseType, CommentAttributes, CommentType, ConnectorTypes, - ESCaseAttributes, - ESCasesConfigureAttributes, SECURITY_SOLUTION_OWNER, } from '../../../../common'; -export const mockCases: Array> = [ +export const mockCases: Array> = [ { type: 'cases', id: 'mock-id-1', @@ -29,7 +28,7 @@ export const mockCases: Array> = [ id: 'none', name: 'none', type: ConnectorTypes.none, - fields: [], + fields: null, }, created_at: '2019-11-25T21:54:48.952Z', created_by: { @@ -68,7 +67,7 @@ export const mockCases: Array> = [ id: 'none', name: 'none', type: ConnectorTypes.none, - fields: [], + fields: null, }, created_at: '2019-11-25T22:32:00.900Z', created_by: { @@ -107,11 +106,7 @@ export const mockCases: Array> = [ id: '123', name: 'My connector', type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], + fields: { issueType: 'Task', priority: 'High', parent: null }, }, created_at: '2019-11-25T22:32:17.947Z', created_by: { @@ -154,11 +149,7 @@ export const mockCases: Array> = [ id: '123', name: 'My connector', type: ConnectorTypes.jira, - fields: [ - { key: 'issueType', value: 'Task' }, - { key: 'priority', value: 'High' }, - { key: 'parent', value: null }, - ], + fields: { issueType: 'Task', priority: 'High', parent: null }, }, created_at: '2019-11-25T22:32:17.947Z', created_by: { @@ -189,38 +180,6 @@ export const mockCases: Array> = [ }, ]; -export const mockCaseNoConnectorId: SavedObject> = { - type: 'cases', - id: 'mock-no-connector_id', - attributes: { - closed_at: null, - closed_by: null, - created_at: '2019-11-25T21:54:48.952Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - description: 'This is a brand new case of a bad meanie defacing data', - external_service: null, - title: 'Super Bad Security Issue', - status: CaseStatuses.open, - tags: ['defacement'], - updated_at: '2019-11-25T21:54:48.952Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - settings: { - syncAlerts: true, - }, - }, - references: [], - updated_at: '2019-11-25T21:54:48.952Z', - version: 'WzAsMV0=', -}; - export const mockCasesErrorTriggerData = [ { id: 'valid-id', @@ -446,35 +405,3 @@ export const mockCaseComments: Array> = [ version: 'WzYsMV0=', }, ]; - -export const mockCaseConfigure: Array> = [ - { - type: 'cases-configure', - id: 'mock-configuration-1', - attributes: { - connector: { - id: '789', - name: 'My connector 3', - type: ConnectorTypes.jira, - fields: null, - }, - closure_type: 'close-by-user', - created_at: '2020-04-09T09:43:51.778Z', - created_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - updated_at: '2020-04-09T09:43:51.778Z', - updated_by: { - full_name: 'elastic', - email: 'testemail@elastic.co', - username: 'elastic', - }, - owner: SECURITY_SOLUTION_OWNER, - }, - references: [], - updated_at: '2020-04-09T09:43:51.778Z', - version: 'WzYsMV0=', - }, -]; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases.ts index 869437f125ca0..199017c36fa3e 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases.ts @@ -52,9 +52,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, connector: { properties: { - id: { - type: 'keyword', - }, name: { type: 'text', }, @@ -91,9 +88,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - connector_id: { - type: 'keyword', - }, connector_name: { type: 'keyword', }, diff --git a/x-pack/plugins/cases/server/saved_object_types/configure.ts b/x-pack/plugins/cases/server/saved_object_types/configure.ts index e88ecb93d9d65..a763a8243cc2d 100644 --- a/x-pack/plugins/cases/server/saved_object_types/configure.ts +++ b/x-pack/plugins/cases/server/saved_object_types/configure.ts @@ -33,9 +33,6 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, connector: { properties: { - id: { - type: 'keyword', - }, name: { type: 'text', }, diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts new file mode 100644 index 0000000000000..bca12a86a544e --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.test.ts @@ -0,0 +1,351 @@ +/* + * 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 { SavedObjectSanitizedDoc } from 'kibana/server'; +import { + CaseAttributes, + CaseFullExternalService, + CASE_SAVED_OBJECT, + ConnectorTypes, + noneConnectorId, +} from '../../../common'; +import { getNoneCaseConnector } from '../../common'; +import { createExternalService, ESCaseConnectorWithId } from '../../services/test_utils'; +import { caseConnectorIdMigration } from './cases'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const create_7_14_0_case = ({ + connector, + externalService, +}: { connector?: ESCaseConnectorWithId; externalService?: CaseFullExternalService } = {}) => ({ + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + connector, + external_service: externalService, + }, +}); + +describe('7.15.0 connector ID migration', () => { + it('does not create a reference when the connector.id is none', () => { + const caseSavedObject = create_7_14_0_case({ connector: getNoneCaseConnector() }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('does not create a reference when the connector is undefined', () => { + const caseSavedObject = create_7_14_0_case(); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('sets the connector to the default none connector if the connector.id is undefined', () => { + const caseSavedObject = create_7_14_0_case({ + connector: { + fields: null, + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + } as ESCaseConnectorWithId, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('does not create a reference when the external_service is null', () => { + const caseSavedObject = create_7_14_0_case({ externalService: null }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toBeNull(); + }); + + it('does not create a reference when the external_service is undefined and sets external_service to null', () => { + const caseSavedObject = create_7_14_0_case(); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toBeNull(); + }); + + it('does not create a reference when the external_service.connector_id is none', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: createExternalService({ connector_id: noneConnectorId }), + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('preserves the existing references when migrating', () => { + const caseSavedObject = { + ...create_7_14_0_case(), + references: [{ id: '1', name: 'awesome', type: 'hello' }], + }; + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "awesome", + "type": "hello", + }, + ] + `); + }); + + it('creates a connector reference and removes the connector.id field', () => { + const caseSavedObject = create_7_14_0_case({ + connector: { + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "connector", + "type": ".jira", + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('creates a push connector reference and removes the connector_id field', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(1); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('does not create a reference and preserves the existing external_service fields when connector_id is null', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: null, + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('migrates both connector and external_service when provided', () => { + const caseSavedObject = create_7_14_0_case({ + externalService: { + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, + connector: { + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, + }, + }); + + const migratedConnector = caseConnectorIdMigration( + caseSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(2); + expect(migratedConnector.attributes.external_service).not.toHaveProperty('connector_id'); + expect(migratedConnector.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "connector", + "type": ".jira", + } + `); + expect(migratedConnector.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts new file mode 100644 index 0000000000000..8296de57b37a9 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/cases.ts @@ -0,0 +1,135 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { addOwnerToSO, SanitizedCaseOwner } from '.'; +import { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, +} from '../../../../../../src/core/server'; +import { ESConnectorFields } from '../../services'; +import { ConnectorTypes, CaseType } from '../../../common'; +import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils'; + +interface UnsanitizedCaseConnector { + connector_id: string; +} + +interface SanitizedCaseConnector { + connector: { + id: string; + name: string | null; + type: string | null; + fields: null | ESConnectorFields; + }; +} + +interface SanitizedCaseSettings { + settings: { + syncAlerts: boolean; + }; +} + +interface SanitizedCaseType { + type: string; +} + +interface ConnectorIdFields { + connector?: { id?: string }; + external_service?: { connector_id?: string | null } | null; +} + +export const caseConnectorIdMigration = ( + doc: SavedObjectUnsanitizedDoc +): SavedObjectSanitizedDoc => { + // removing the id field since it will be stored in the references instead + const { connector, external_service, ...restAttributes } = doc.attributes; + + const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference( + connector + ); + + const { + transformedPushConnector, + references: pushConnectorReferences, + } = transformPushConnectorIdToReference(external_service); + + const { references = [] } = doc; + + return { + ...doc, + attributes: { + ...restAttributes, + ...transformedConnector, + ...transformedPushConnector, + }, + references: [...references, ...connectorReferences, ...pushConnectorReferences], + }; +}; + +export const caseMigrations = { + '7.10.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; + + return { + ...doc, + attributes: { + ...attributesWithoutConnectorId, + connector: { + id: connector_id ?? 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }, + references: doc.references || [], + }; + }, + '7.11.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + settings: { + syncAlerts: true, + }, + }, + references: doc.references || [], + }; + }, + '7.12.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { fields, type } = doc.attributes.connector; + return { + ...doc, + attributes: { + ...doc.attributes, + type: CaseType.individual, + connector: { + ...doc.attributes.connector, + fields: + Array.isArray(fields) && fields.length > 0 && type === ConnectorTypes.serviceNowITSM + ? [...fields, { key: 'category', value: null }, { key: 'subcategory', value: null }] + : fields, + }, + }, + references: doc.references || [], + }; + }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, + '7.15.0': caseConnectorIdMigration, +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts new file mode 100644 index 0000000000000..4467b499817a5 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.test.ts @@ -0,0 +1,126 @@ +/* + * 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 { SavedObjectSanitizedDoc } from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { + CASE_CONFIGURE_SAVED_OBJECT, + ConnectorTypes, + SECURITY_SOLUTION_OWNER, +} from '../../../common'; +import { getNoneCaseConnector, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { ESCaseConnectorWithId } from '../../services/test_utils'; +import { ESCasesConfigureAttributes } from '../../services/configure/types'; +import { configureConnectorIdMigration } from './configuration'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const create_7_14_0_configSchema = (connector?: ESCaseConnectorWithId) => ({ + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + connector, + closure_type: 'close-by-pushing', + owner: SECURITY_SOLUTION_OWNER, + created_at: '2020-04-09T09:43:51.778Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }, +}); + +describe('7.15.0 connector ID migration', () => { + it('does not create a reference when the connector ID is none', () => { + const configureSavedObject = create_7_14_0_configSchema(getNoneCaseConnector()); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + }); + + it('does not create a reference when the connector is undefined and defaults it to the none connector', () => { + const configureSavedObject = create_7_14_0_configSchema(); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references.length).toBe(0); + expect(migratedConnector.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "name": "none", + "type": ".none", + } + `); + }); + + it('creates a reference using the connector id', () => { + const configureSavedObject = create_7_14_0_configSchema({ + id: '123', + fields: null, + name: 'connector', + type: ConnectorTypes.jira, + }); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector.references).toEqual([ + { id: '123', type: ACTION_SAVED_OBJECT_TYPE, name: CONNECTOR_ID_REFERENCE_NAME }, + ]); + expect(migratedConnector.attributes.connector).not.toHaveProperty('id'); + }); + + it('returns the other attributes and default connector when the connector is undefined', () => { + const configureSavedObject = create_7_14_0_configSchema(); + + const migratedConnector = configureConnectorIdMigration( + configureSavedObject + ) as SavedObjectSanitizedDoc; + + expect(migratedConnector).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "closure_type": "close-by-pushing", + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "updated_at": "2020-04-09T09:43:51.778Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "id": "1", + "references": Array [], + "type": "cases-configure", + } + `); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts new file mode 100644 index 0000000000000..3209feb2a9a9b --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/configuration.ts @@ -0,0 +1,78 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, +} from '../../../../../../src/core/server'; +import { ConnectorTypes } from '../../../common'; +import { addOwnerToSO, SanitizedCaseOwner } from '.'; +import { transformConnectorIdToReference } from './utils'; + +interface UnsanitizedConfigureConnector { + connector_id: string; + connector_name: string; +} + +interface SanitizedConfigureConnector { + connector: { + id: string; + name: string | null; + type: string | null; + fields: null; + }; +} + +export const configureConnectorIdMigration = ( + doc: SavedObjectUnsanitizedDoc<{ connector?: { id: string } }> +): SavedObjectSanitizedDoc => { + // removing the id field since it will be stored in the references instead + const { connector, ...restAttributes } = doc.attributes; + const { transformedConnector, references: connectorReferences } = transformConnectorIdToReference( + connector + ); + const { references = [] } = doc; + + return { + ...doc, + attributes: { + ...restAttributes, + ...transformedConnector, + }, + references: [...references, ...connectorReferences], + }; +}; + +export const configureMigrations = { + '7.10.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { + const { connector_id, connector_name, ...restAttributes } = doc.attributes; + + return { + ...doc, + attributes: { + ...restAttributes, + connector: { + id: connector_id ?? 'none', + name: connector_name ?? 'none', + type: ConnectorTypes.none, + fields: null, + }, + }, + references: doc.references || [], + }; + }, + '7.14.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return addOwnerToSO(doc); + }, + '7.15.0': configureConnectorIdMigration, +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts similarity index 53% rename from x-pack/plugins/cases/server/saved_object_types/migrations.ts rename to x-pack/plugins/cases/server/saved_object_types/migrations/index.ts index e4b201b21b756..7be87c3abc989 100644 --- a/x-pack/plugins/cases/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/index.ts @@ -7,42 +7,19 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; +import { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, +} from '../../../../../../src/core/server'; import { ConnectorTypes, CommentType, - CaseType, AssociationType, - ESConnectorFields, SECURITY_SOLUTION_OWNER, -} from '../../common'; - -interface UnsanitizedCaseConnector { - connector_id: string; -} - -interface UnsanitizedConfigureConnector { - connector_id: string; - connector_name: string; -} - -interface SanitizedCaseConnector { - connector: { - id: string; - name: string | null; - type: string | null; - fields: null | ESConnectorFields; - }; -} +} from '../../../common'; -interface SanitizedConfigureConnector { - connector: { - id: string; - name: string | null; - type: string | null; - fields: null; - }; -} +export { caseMigrations } from './cases'; +export { configureMigrations } from './configuration'; interface UserActions { action_field: string[]; @@ -50,21 +27,11 @@ interface UserActions { old_value: string; } -interface SanitizedCaseSettings { - settings: { - syncAlerts: boolean; - }; -} - -interface SanitizedCaseType { - type: string; -} - -interface SanitizedCaseOwner { +export interface SanitizedCaseOwner { owner: string; } -const addOwnerToSO = >( +export const addOwnerToSO = >( doc: SavedObjectUnsanitizedDoc ): SavedObjectSanitizedDoc => ({ ...doc, @@ -75,94 +42,6 @@ const addOwnerToSO = >( references: doc.references || [], }); -export const caseMigrations = { - '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; - - return { - ...doc, - attributes: { - ...attributesWithoutConnectorId, - connector: { - id: connector_id ?? 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - }, - references: doc.references || [], - }; - }, - '7.11.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return { - ...doc, - attributes: { - ...doc.attributes, - settings: { - syncAlerts: true, - }, - }, - references: doc.references || [], - }; - }, - '7.12.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - const { fields, type } = doc.attributes.connector; - return { - ...doc, - attributes: { - ...doc.attributes, - type: CaseType.individual, - connector: { - ...doc.attributes.connector, - fields: - Array.isArray(fields) && fields.length > 0 && type === ConnectorTypes.serviceNowITSM - ? [...fields, { key: 'category', value: null }, { key: 'subcategory', value: null }] - : fields, - }, - }, - references: doc.references || [], - }; - }, - '7.14.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return addOwnerToSO(doc); - }, -}; - -export const configureMigrations = { - '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { - const { connector_id, connector_name, ...restAttributes } = doc.attributes; - - return { - ...doc, - attributes: { - ...restAttributes, - connector: { - id: connector_id ?? 'none', - name: connector_name ?? 'none', - type: ConnectorTypes.none, - fields: null, - }, - }, - references: doc.references || [], - }; - }, - '7.14.0': ( - doc: SavedObjectUnsanitizedDoc> - ): SavedObjectSanitizedDoc => { - return addOwnerToSO(doc); - }, -}; - export const userActionsMigrations = { '7.10.0': (doc: SavedObjectUnsanitizedDoc): SavedObjectSanitizedDoc => { const { action_field, new_value, old_value, ...restAttributes } = doc.attributes; diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts new file mode 100644 index 0000000000000..f591bef6b3236 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.test.ts @@ -0,0 +1,229 @@ +/* + * 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 { noneConnectorId } from '../../../common'; +import { createExternalService, createJiraConnector } from '../../services/test_utils'; +import { transformConnectorIdToReference, transformPushConnectorIdToReference } from './utils'; + +describe('migration utils', () => { + describe('transformConnectorIdToReference', () => { + it('returns the default none connector when the connector is undefined', () => { + expect(transformConnectorIdToReference().transformedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is undefined', () => { + expect(transformConnectorIdToReference({ id: undefined }).transformedConnector) + .toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is none', () => { + expect(transformConnectorIdToReference({ id: noneConnectorId }).transformedConnector) + .toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns the default none connector when the id is none and other fields are defined', () => { + expect( + transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId }) + .transformedConnector + ).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "name": "none", + "type": ".none", + }, + } + `); + }); + + it('returns an empty array of references when the connector is undefined', () => { + expect(transformConnectorIdToReference().references.length).toBe(0); + }); + + it('returns an empty array of references when the id is undefined', () => { + expect(transformConnectorIdToReference({ id: undefined }).references.length).toBe(0); + }); + + it('returns an empty array of references when the id is the none connector', () => { + expect(transformConnectorIdToReference({ id: noneConnectorId }).references.length).toBe(0); + }); + + it('returns an empty array of references when the id is the none connector and other fields are defined', () => { + expect( + transformConnectorIdToReference({ ...createJiraConnector(), id: noneConnectorId }) + .references.length + ).toBe(0); + }); + + it('returns a jira connector', () => { + const transformedFields = transformConnectorIdToReference(createJiraConnector()); + expect(transformedFields.transformedConnector).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + }); + + describe('transformPushConnectorIdToReference', () => { + it('sets external_service to null when it is undefined', () => { + expect(transformPushConnectorIdToReference().transformedPushConnector).toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + }); + + it('sets external_service to null when it is null', () => { + expect(transformPushConnectorIdToReference(null).transformedPushConnector) + .toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is undefined', () => { + expect( + transformPushConnectorIdToReference({ connector_id: undefined }).transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": Object {}, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is null', () => { + expect(transformPushConnectorIdToReference({ connector_id: null }).transformedPushConnector) + .toMatchInlineSnapshot(` + Object { + "external_service": Object {}, + } + `); + }); + + it('returns an object when external_service is defined but connector_id is none', () => { + const otherFields = { otherField: 'hi' }; + + expect( + transformPushConnectorIdToReference({ ...otherFields, connector_id: noneConnectorId }) + .transformedPushConnector + ).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "otherField": "hi", + }, + } + `); + }); + + it('returns an empty array of references when the external_service is undefined', () => { + expect(transformPushConnectorIdToReference().references.length).toBe(0); + }); + + it('returns an empty array of references when the external_service is null', () => { + expect(transformPushConnectorIdToReference(null).references.length).toBe(0); + }); + + it('returns an empty array of references when the connector_id is undefined', () => { + expect( + transformPushConnectorIdToReference({ connector_id: undefined }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is null', () => { + expect( + transformPushConnectorIdToReference({ connector_id: undefined }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is the none connector', () => { + expect( + transformPushConnectorIdToReference({ connector_id: noneConnectorId }).references.length + ).toBe(0); + }); + + it('returns an empty array of references when the connector_id is the none connector and other fields are defined', () => { + expect( + transformPushConnectorIdToReference({ + ...createExternalService(), + connector_id: noneConnectorId, + }).references.length + ).toBe(0); + }); + + it('returns the external_service connector', () => { + const transformedFields = transformPushConnectorIdToReference(createExternalService()); + expect(transformedFields.transformedPushConnector).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + } + `); + expect(transformedFields.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts new file mode 100644 index 0000000000000..0100a04cde679 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/migrations/utils.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { noneConnectorId } from '../../../common'; +import { SavedObjectReference } from '../../../../../../src/core/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { + getNoneCaseConnector, + CONNECTOR_ID_REFERENCE_NAME, + PUSH_CONNECTOR_ID_REFERENCE_NAME, +} from '../../common'; + +export const transformConnectorIdToReference = (connector?: { + id?: string; +}): { transformedConnector: Record; references: SavedObjectReference[] } => { + const { id: connectorId, ...restConnector } = connector ?? {}; + + const references = createConnectorReference( + connectorId, + ACTION_SAVED_OBJECT_TYPE, + CONNECTOR_ID_REFERENCE_NAME + ); + + const { id: ignoreNoneId, ...restNoneConnector } = getNoneCaseConnector(); + const connectorFieldsToReturn = + connector && references.length > 0 ? restConnector : restNoneConnector; + + return { + transformedConnector: { + connector: connectorFieldsToReturn, + }, + references, + }; +}; + +const createConnectorReference = ( + id: string | null | undefined, + type: string, + name: string +): SavedObjectReference[] => { + return id && id !== noneConnectorId + ? [ + { + id, + type, + name, + }, + ] + : []; +}; + +export const transformPushConnectorIdToReference = ( + external_service?: { connector_id?: string | null } | null +): { transformedPushConnector: Record; references: SavedObjectReference[] } => { + const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {}; + + const references = createConnectorReference( + pushConnectorId, + ACTION_SAVED_OBJECT_TYPE, + PUSH_CONNECTOR_ID_REFERENCE_NAME + ); + + return { + transformedPushConnector: { external_service: external_service ? restExternalService : null }, + references, + }; +}; diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts new file mode 100644 index 0000000000000..bf7eeda7e0e2e --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -0,0 +1,1167 @@ +/* + * 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. + */ + +/** + * This test file references connector_id and connector.id. The connector_id is a field within the external_service + * object. It holds the action connector's id that was used to push the case to the external service. The connector.id + * field also holds an action connector's id. This id is the currently configured connector for the case. The next + * time the case is pushed it will use this connector to push the case. The connector_id can be different from the + * connector.id. + */ + +import { + CaseAttributes, + CaseConnector, + CaseFullExternalService, + CASE_SAVED_OBJECT, +} from '../../../common'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { + SavedObject, + SavedObjectReference, + SavedObjectsCreateOptions, + SavedObjectsFindResult, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { loggerMock } from '@kbn/logging/target/mocks'; +import { getNoneCaseConnector, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { CasesService } from '.'; +import { + createESJiraConnector, + createJiraConnector, + ESCaseConnectorWithId, + createExternalService, + createSavedObjectReferences, + createCaseSavedObjectResponse, + basicCaseFields, +} from '../test_utils'; +import { ESCaseAttributes } from './types'; + +const createUpdateSOResponse = ({ + connector, + externalService, +}: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; +} = {}): SavedObjectsUpdateResponse => { + const references: SavedObjectReference[] = createSavedObjectReferences({ + connector, + externalService, + }); + + let attributes: Partial = {}; + + if (connector) { + const { id, ...restConnector } = connector; + attributes = { ...attributes, connector: { ...restConnector } }; + } + + if (externalService) { + const { connector_id: id, ...restService } = externalService; + attributes = { ...attributes, external_service: { ...restService } }; + } else if (externalService === null) { + attributes = { ...attributes, external_service: null }; + } + + return { + type: CASE_SAVED_OBJECT, + id: '1', + attributes, + references, + }; +}; + +const createFindSO = ( + params: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; + } = {} +): SavedObjectsFindResult => ({ + ...createCaseSavedObjectResponse(params), + score: 0, +}); + +const createSOFindResponse = (savedObjects: Array>) => ({ + saved_objects: savedObjects, + total: savedObjects.length, + per_page: savedObjects.length, + page: 1, +}); + +const createCaseUpdateParams = ( + connector?: CaseConnector, + externalService?: CaseFullExternalService +): Partial => ({ + ...(connector && { connector }), + ...(externalService && { external_service: externalService }), +}); + +const createCasePostParams = ( + connector: CaseConnector, + externalService?: CaseFullExternalService +): CaseAttributes => ({ + ...basicCaseFields, + connector, + ...(externalService ? { external_service: externalService } : { external_service: null }), +}); + +const createCasePatchParams = ({ + connector, + externalService, +}: { + connector?: CaseConnector; + externalService?: CaseFullExternalService; +} = {}): Partial => ({ + ...basicCaseFields, + connector, + ...(externalService && { external_service: externalService }), +}); + +describe('CasesService', () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const mockLogger = loggerMock.create(); + + let service: CasesService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CasesService(mockLogger); + }); + + describe('transforms the external model to the Elasticsearch model', () => { + describe('patch', () => { + it('includes the passed in fields', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const { + connector: ignoreConnector, + external_service: ignoreExternalService, + ...restUpdateAttributes + } = unsecuredSavedObjectsClient.update.mock.calls[0][2] as Partial; + expect(restUpdateAttributes).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('transforms the connector.fields to an array of key/value pairs', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(connector?.fields).toMatchInlineSnapshot(` + Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ] + `); + }); + + it('preserves the connector fields but does not have the id', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(connector).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('removes the connector id and adds it to the references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(createJiraConnector()), + originalCase: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(updateAttributes.connector).not.toHaveProperty('id'); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('removes the external_service connector_id and adds it to the references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(getNoneCaseConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(updateAttributes.external_service).not.toHaveProperty('connector_id'); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('builds references for external service connector id, case connector id, and includes the existing references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(createJiraConnector(), createExternalService()), + originalCase: { + references: [{ id: 'a', name: 'awesome', type: 'hello' }], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "a", + "name": "awesome", + "type": "hello", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('builds references for connector_id and preserves the existing connector.id reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePatchParams({ externalService: createExternalService() }), + originalCase: { + references: [ + { id: '1', name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('preserves the external_service fields except for the connector_id', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCasePostParams(getNoneCaseConnector(), createExternalService()), + originalCase: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + expect(updateAttributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('creates an empty updatedAttributes when there is no connector or external_service as input', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot( + `Object {}` + ); + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toBeUndefined(); + }); + + it('creates a updatedAttributes field with the none connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(getNoneCaseConnector()), + originalCase: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [], + "name": "none", + "type": ".none", + }, + } + `); + }); + }); + + describe('post', () => { + it('creates a null external_service field when the attribute was null in the creation parameters', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(createJiraConnector()), + id: '1', + }); + + const postAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as CaseAttributes; + expect(postAttributes.external_service).toMatchInlineSnapshot(`null`); + }); + + it('includes the creation attributes excluding the connector.id and connector_id', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(createJiraConnector(), createExternalService()), + id: '1', + }); + + const creationAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as ESCaseAttributes; + expect(creationAttributes.connector).not.toHaveProperty('id'); + expect(creationAttributes.external_service).not.toHaveProperty('connector_id'); + expect(creationAttributes).toMatchInlineSnapshot(` + Object { + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "description": "This is a brand new case of a bad meanie defacing data", + "external_service": Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "type": "individual", + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ], + } + `); + }); + + it('moves the connector.id and connector_id to the references', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(createJiraConnector(), createExternalService()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('sets fields to an empty array when it is not included with the connector', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams( + createJiraConnector({ setFieldsToNull: true }), + createExternalService() + ), + id: '1', + }); + + const postAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as CaseAttributes; + expect(postAttributes.connector.fields).toEqual([]); + }); + + it('does not create a reference for a none connector', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(getNoneCaseConnector()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toEqual([]); + }); + + it('does not create a reference for an external_service field that is null', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(getNoneCaseConnector()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toEqual([]); + }); + }); + }); + + describe('transforms the Elasticsearch model to the external model', () => { + describe('bulkPatch', () => { + it('formats the update saved object by including the passed in fields and transforming the connector.fields', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockReturnValue( + Promise.resolve({ + saved_objects: [ + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createCaseSavedObjectResponse({ + connector: createESJiraConnector({ id: '2' }), + externalService: createExternalService({ connector_id: '200' }), + }), + ], + }) + ); + + const res = await service.patchCases({ + unsecuredSavedObjectsClient, + cases: [ + { + caseId: '1', + updatedAttributes: createCasePostParams( + createJiraConnector(), + createExternalService() + ), + originalCase: {} as SavedObject, + }, + ], + }); + + expect(res.saved_objects[0].attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + expect( + res.saved_objects[1].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"200"`); + + expect(res.saved_objects[1].attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "2", + "name": ".jira", + "type": ".jira", + } + `); + expect( + res.saved_objects[0].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"100"`); + }); + }); + + describe('patch', () => { + it('returns an object with a none connector and without a reference when it was set to a none connector in the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ connector: getNoneCaseConnector() })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + } + `); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an object with a null external service and without a reference when it was set to null in the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ externalService: null })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(` + Object { + "external_service": null, + } + `); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an empty object when neither the connector or external service was updated', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse()) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(`Object {}`); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an undefined connector if it is not returned by the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "attributes": Object {}, + } + `); + }); + + it('returns the default none connector when it cannot find the reference', async () => { + const { name, type, fields } = createESJiraConnector(); + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + connector: { + name, + type, + fields, + }, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('returns a null external service connector when it cannot find the reference', async () => { + const { connector_id: id, ...restExternalConnector } = createExternalService()!; + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + external_service: restExternalConnector, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.external_service?.connector_id).toBeNull(); + }); + + it('returns the saved object fields when it cannot find the reference for connector_id', async () => { + const { connector_id: id, ...restExternalConnector } = createExternalService()!; + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + external_service: restExternalConnector, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "attributes": Object { + "external_service": Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + }, + "id": "1", + "references": undefined, + "type": "cases", + "version": "1", + } + `); + }); + + it('returns the connector.id after finding the reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ connector: createESJiraConnector() })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + expect(res.attributes.connector?.id).toMatchInlineSnapshot(`"1"`); + }); + + it('returns the external_service connector_id after finding the reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateSOResponse({ externalService: createExternalService() })) + ); + + const res = await service.patchCase({ + caseId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createCaseUpdateParams(), + originalCase: {} as SavedObject, + }); + + expect(res.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_id": "100", + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"100"`); + }); + }); + + describe('post', () => { + it('includes the connector.id and connector_id fields in the response', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve( + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }) + ) + ); + + const res = await service.postNewCase({ + unsecuredSavedObjectsClient, + attributes: createCasePostParams(getNoneCaseConnector()), + id: '1', + }); + + expect(res.attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"100"`); + }); + }); + + describe('find', () => { + it('includes the connector.id and connector_id field in the response', async () => { + const findMockReturn = createSOFindResponse([ + createFindSO({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.findCases({ unsecuredSavedObjectsClient }); + expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect( + res.saved_objects[0].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"100"`); + }); + + it('includes the saved object find response fields in the result', async () => { + const findMockReturn = createSOFindResponse([ + createFindSO({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.findCases({ unsecuredSavedObjectsClient }); + const { saved_objects: ignored, ...findResponseFields } = res; + expect(findResponseFields).toMatchInlineSnapshot(` + Object { + "page": 1, + "per_page": 2, + "total": 2, + } + `); + }); + }); + + describe('bulkGet', () => { + it('includes the connector.id and connector_id fields in the response', async () => { + unsecuredSavedObjectsClient.bulkGet.mockReturnValue( + Promise.resolve({ + saved_objects: [ + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }), + createCaseSavedObjectResponse({ + connector: createESJiraConnector({ id: '2' }), + externalService: createExternalService({ connector_id: '200' }), + }), + ], + }) + ); + + const res = await service.getCases({ unsecuredSavedObjectsClient, caseIds: ['a'] }); + + expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect( + res.saved_objects[1].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"200"`); + + expect(res.saved_objects[1].attributes.connector.id).toMatchInlineSnapshot(`"2"`); + expect( + res.saved_objects[1].attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"200"`); + }); + }); + + describe('get', () => { + it('includes the connector.id and connector_id fields in the response', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve( + createCaseSavedObjectResponse({ + connector: createESJiraConnector(), + externalService: createExternalService(), + }) + ) + ); + + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector.id).toMatchInlineSnapshot(`"1"`); + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`"100"`); + }); + + it('defaults to the none connector when the connector reference cannot be found', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve( + createCaseSavedObjectResponse({ externalService: createExternalService() }) + ) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('sets external services connector_id to null when the connector id cannot be found in the references', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(createCaseSavedObjectResponse()) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.external_service?.connector_id).toMatchInlineSnapshot(`null`); + }); + + it('includes the external services fields when the connector id cannot be found in the references', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(createCaseSavedObjectResponse()) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('defaults to the none connector and null external_services when attributes is undefined', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(({ + references: [ + { + id: '1', + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ], + } as unknown) as SavedObject) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + + expect(res.attributes.external_service).toMatchInlineSnapshot(`null`); + }); + + it('returns a null external_services when it is already null', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve({ + attributes: { external_service: null }, + } as SavedObject) + ); + const res = await service.getCase({ unsecuredSavedObjectsClient, id: 'a' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + + expect(res.attributes.external_service).toMatchInlineSnapshot(`null`); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index a0e4380f95640..72c2033f83535 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -14,6 +14,8 @@ import { SavedObjectsFindResponse, SavedObjectsBulkResponse, SavedObjectsFindResult, + SavedObjectsBulkUpdateResponse, + SavedObjectsUpdateResponse, } from 'kibana/server'; import type { estypes } from '@elastic/elasticsearch'; @@ -32,7 +34,6 @@ import { CommentAttributes, CommentType, ENABLE_CASE_CONNECTOR, - ESCaseAttributes, GetCaseIdsByAlertIdAggs, MAX_CONCURRENT_SEARCHES, MAX_DOCS_PER_PAGE, @@ -41,6 +42,7 @@ import { SubCaseAttributes, SubCaseResponse, User, + CaseAttributes, } from '../../../common'; import { defaultSortField, @@ -54,6 +56,15 @@ import { ClientArgs } from '..'; import { combineFilters } from '../../client/utils'; import { includeFieldsRequiredForAuthentication } from '../../authorization/utils'; import { EnsureSOAuthCallback } from '../../authorization'; +import { + transformSavedObjectToExternalModel, + transformAttributesToESModel, + transformUpdateResponseToExternalModel, + transformUpdateResponsesToExternalModels, + transformBulkResponseToExternalModel, + transformFindResponseToExternalModel, +} from './transform'; +import { ESCaseAttributes } from './types'; interface GetCaseIdsByAlertIdArgs extends ClientArgs { alertId: string; @@ -111,7 +122,7 @@ interface FindSubCasesStatusStats { } interface PostCaseArgs extends ClientArgs { - attributes: ESCaseAttributes; + attributes: CaseAttributes; id: string; } @@ -123,7 +134,8 @@ interface CreateSubCaseArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; + originalCase: SavedObject; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; @@ -168,7 +180,7 @@ interface FindCommentsByAssociationArgs { } interface Collection { - case: SavedObjectsFindResult; + case: SavedObjectsFindResult; subCases?: SubCaseResponse[]; } @@ -713,10 +725,14 @@ export class CasesService { public async getCase({ unsecuredSavedObjectsClient, id: caseId, - }: GetCaseArgs): Promise> { + }: GetCaseArgs): Promise> { try { this.log.debug(`Attempting to GET case ${caseId}`); - return await unsecuredSavedObjectsClient.get(CASE_SAVED_OBJECT, caseId); + const caseSavedObject = await unsecuredSavedObjectsClient.get( + CASE_SAVED_OBJECT, + caseId + ); + return transformSavedObjectToExternalModel(caseSavedObject); } catch (error) { this.log.error(`Error on GET case ${caseId}: ${error}`); throw error; @@ -753,12 +769,13 @@ export class CasesService { public async getCases({ unsecuredSavedObjectsClient, caseIds, - }: GetCasesArgs): Promise> { + }: GetCasesArgs): Promise> { try { this.log.debug(`Attempting to GET cases ${caseIds.join(', ')}`); - return await unsecuredSavedObjectsClient.bulkGet( + const cases = await unsecuredSavedObjectsClient.bulkGet( caseIds.map((caseId) => ({ type: CASE_SAVED_OBJECT, id: caseId })) ); + return transformBulkResponseToExternalModel(cases); } catch (error) { this.log.error(`Error on GET cases ${caseIds.join(', ')}: ${error}`); throw error; @@ -768,14 +785,15 @@ export class CasesService { public async findCases({ unsecuredSavedObjectsClient, options, - }: FindCasesArgs): Promise> { + }: FindCasesArgs): Promise> { try { this.log.debug(`Attempting to find cases`); - return await unsecuredSavedObjectsClient.find({ + const cases = await unsecuredSavedObjectsClient.find({ sortField: defaultSortField, ...options, type: CASE_SAVED_OBJECT, }); + return transformFindResponseToExternalModel(cases); } catch (error) { this.log.error(`Error on find cases: ${error}`); throw error; @@ -1041,14 +1059,20 @@ export class CasesService { } } - public async postNewCase({ unsecuredSavedObjectsClient, attributes, id }: PostCaseArgs) { + public async postNewCase({ + unsecuredSavedObjectsClient, + attributes, + id, + }: PostCaseArgs): Promise> { try { this.log.debug(`Attempting to POST a new case`); - return await unsecuredSavedObjectsClient.create( + const transformedAttributes = transformAttributesToESModel(attributes); + const createdCase = await unsecuredSavedObjectsClient.create( CASE_SAVED_OBJECT, - attributes, - { id } + transformedAttributes.attributes, + { id, references: transformedAttributes.referenceHandler.build() } ); + return transformSavedObjectToExternalModel(createdCase); } catch (error) { this.log.error(`Error on POST a new case: ${error}`); throw error; @@ -1059,33 +1083,52 @@ export class CasesService { unsecuredSavedObjectsClient, caseId, updatedAttributes, + originalCase, version, - }: PatchCaseArgs) { + }: PatchCaseArgs): Promise> { try { this.log.debug(`Attempting to UPDATE case ${caseId}`); - return await unsecuredSavedObjectsClient.update( + const transformedAttributes = transformAttributesToESModel(updatedAttributes); + + const updatedCase = await unsecuredSavedObjectsClient.update( CASE_SAVED_OBJECT, caseId, - { ...updatedAttributes }, - { version } + transformedAttributes.attributes, + { + version, + references: transformedAttributes.referenceHandler.build(originalCase.references), + } ); + + return transformUpdateResponseToExternalModel(updatedCase); } catch (error) { this.log.error(`Error on UPDATE case ${caseId}: ${error}`); throw error; } } - public async patchCases({ unsecuredSavedObjectsClient, cases }: PatchCasesArgs) { + public async patchCases({ + unsecuredSavedObjectsClient, + cases, + }: PatchCasesArgs): Promise> { try { this.log.debug(`Attempting to UPDATE case ${cases.map((c) => c.caseId).join(', ')}`); - return await unsecuredSavedObjectsClient.bulkUpdate( - cases.map((c) => ({ + + const bulkUpdate = cases.map(({ caseId, updatedAttributes, version, originalCase }) => { + const { attributes, referenceHandler } = transformAttributesToESModel(updatedAttributes); + return { type: CASE_SAVED_OBJECT, - id: c.caseId, - attributes: c.updatedAttributes, - version: c.version, - })) + id: caseId, + attributes, + references: referenceHandler.build(originalCase.references), + version, + }; + }); + + const updatedCases = await unsecuredSavedObjectsClient.bulkUpdate( + bulkUpdate ); + return transformUpdateResponsesToExternalModels(updatedCases); } catch (error) { this.log.error(`Error on UPDATE case ${cases.map((c) => c.caseId).join(', ')}: ${error}`); throw error; diff --git a/x-pack/plugins/cases/server/services/cases/transform.test.ts b/x-pack/plugins/cases/server/services/cases/transform.test.ts new file mode 100644 index 0000000000000..96312d00b37dd --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/transform.test.ts @@ -0,0 +1,414 @@ +/* + * 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 { + createCaseSavedObjectResponse, + createESJiraConnector, + createExternalService, + createJiraConnector, +} from '../test_utils'; +import { + transformAttributesToESModel, + transformSavedObjectToExternalModel, + transformUpdateResponseToExternalModel, +} from './transform'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { ConnectorTypes } from '../../../common'; +import { + getNoneCaseConnector, + CONNECTOR_ID_REFERENCE_NAME, + PUSH_CONNECTOR_ID_REFERENCE_NAME, +} from '../../common'; + +describe('case transforms', () => { + describe('transformUpdateResponseToExternalModel', () => { + it('does not return the connector field if it is undefined', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: {}, + references: undefined, + }).attributes + ).not.toHaveProperty('connector'); + }); + + it('does not return the external_service field if it is undefined', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: {}, + references: undefined, + }).attributes + ).not.toHaveProperty('external_service'); + }); + + it('return a null external_service field if it is null', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: null, + }, + references: undefined, + }).attributes.external_service + ).toBeNull(); + }); + + it('return a null external_service.connector_id field if it is none', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: createExternalService({ connector_id: 'none' }), + }, + references: undefined, + }).attributes.external_service?.connector_id + ).toBeNull(); + }); + + it('return the external_service fields if it is populated', () => { + const { connector_id: ignore, ...restExternalService } = createExternalService()!; + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: restExternalService, + }, + references: undefined, + }).attributes.external_service + ).toMatchInlineSnapshot(` + Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('populates the connector_id field when it finds a reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: createExternalService(), + }, + references: [ + { id: '1', name: PUSH_CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + }).attributes.external_service?.connector_id + ).toMatchInlineSnapshot(`"1"`); + }); + + it('populates the external_service fields when it finds a reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + external_service: createExternalService(), + }, + references: [ + { id: '1', name: PUSH_CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + }).attributes.external_service + ).toMatchInlineSnapshot(` + Object { + "connector_id": "1", + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('populates the connector fields when it finds a reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + connector: { + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: [{ key: 'issueType', value: 'bug' }], + }, + }, + references: [ + { id: '1', name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + }).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('returns the none connector when it cannot find the reference', () => { + expect( + transformUpdateResponseToExternalModel({ + type: 'a', + id: '1', + attributes: { + connector: { + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: [{ key: 'issueType', value: 'bug' }], + }, + }, + references: undefined, + }).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + + describe('transformAttributesToESModel', () => { + it('does not return the external_service field when it is undefined', () => { + expect( + transformAttributesToESModel({ + external_service: undefined, + }).attributes + ).not.toHaveProperty('external_service'); + }); + + it('creates an undefined reference when external_service is undefined and the original reference is undefined', () => { + expect( + transformAttributesToESModel({ + external_service: undefined, + }).referenceHandler.build() + ).toBeUndefined(); + }); + + it('returns a null external_service when it is null', () => { + expect( + transformAttributesToESModel({ + external_service: null, + }).attributes.external_service + ).toBeNull(); + }); + + it('creates an undefined reference when external_service is null and the original reference is undefined', () => { + expect( + transformAttributesToESModel({ + external_service: null, + }).referenceHandler.build() + ).toBeUndefined(); + }); + + it('returns the external_service fields except for the connector_id', () => { + const transformedAttributes = transformAttributesToESModel({ + external_service: createExternalService(), + }); + + expect(transformedAttributes.attributes).toMatchInlineSnapshot(` + Object { + "external_service": Object { + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + }, + } + `); + expect(transformedAttributes.attributes.external_service).not.toHaveProperty('connector_id'); + expect(transformedAttributes.referenceHandler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "100", + "name": "pushConnectorId", + "type": "action", + }, + ] + `); + }); + + it('creates an empty references array to delete the connector_id when connector_id is null and the original references is undefined', () => { + const transformedAttributes = transformAttributesToESModel({ + external_service: createExternalService({ connector_id: null }), + }); + + expect(transformedAttributes.referenceHandler.build()).toEqual([]); + }); + + it('does not return the connector when it is undefined', () => { + expect(transformAttributesToESModel({ connector: undefined }).attributes).not.toHaveProperty( + 'connector' + ); + }); + + it('constructs an undefined reference when the connector is undefined and the original reference is undefined', () => { + expect( + transformAttributesToESModel({ connector: undefined }).referenceHandler.build() + ).toBeUndefined(); + }); + + it('returns a jira connector', () => { + const transformedAttributes = transformAttributesToESModel({ + connector: createJiraConnector(), + }); + + expect(transformedAttributes.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + }, + } + `); + expect(transformedAttributes.attributes.connector).not.toHaveProperty('id'); + expect(transformedAttributes.referenceHandler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('returns a none connector without a reference', () => { + const transformedAttributes = transformAttributesToESModel({ + connector: getNoneCaseConnector(), + }); + + expect(transformedAttributes.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [], + "name": "none", + "type": ".none", + }, + } + `); + expect(transformedAttributes.attributes.connector).not.toHaveProperty('id'); + expect(transformedAttributes.referenceHandler.build()).toEqual([]); + }); + }); + + describe('transformSavedObjectToExternalModel', () => { + it('returns the default none connector when it cannot find the reference', () => { + expect( + transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ connector: getNoneCaseConnector() }) + ).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('returns a jira connector', () => { + expect( + transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ connector: createESJiraConnector() }) + ).attributes.connector + ).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets external_service to null when it is null', () => { + expect( + transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ externalService: null }) + ).attributes.external_service + ).toBeNull(); + }); + + it('sets external_service.connector_id to null when a reference cannot be found', () => { + const transformedSO = transformSavedObjectToExternalModel( + createCaseSavedObjectResponse({ + externalService: createExternalService({ connector_id: null }), + }) + ); + + expect(transformedSO.attributes.external_service?.connector_id).toBeNull(); + expect(transformedSO.attributes.external_service).toMatchInlineSnapshot(` + Object { + "connector_id": null, + "connector_name": ".jira", + "external_id": "100", + "external_title": "awesome", + "external_url": "http://www.google.com", + "pushed_at": "2019-11-25T21:54:48.952Z", + "pushed_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/cases/transform.ts b/x-pack/plugins/cases/server/services/cases/transform.ts new file mode 100644 index 0000000000000..00b20a6290860 --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/transform.ts @@ -0,0 +1,208 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +import { + SavedObject, + SavedObjectReference, + SavedObjectsBulkResponse, + SavedObjectsBulkUpdateResponse, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { ESCaseAttributes, ExternalServicesWithoutConnectorId } from './types'; +import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { CaseAttributes, CaseFullExternalService } from '../../../common'; +import { + findConnectorIdReference, + transformFieldsToESModel, + transformESConnectorOrUseDefault, + transformESConnectorToExternalModel, +} from '../transform'; +import { ConnectorReferenceHandler } from '../connector_reference_handler'; + +export function transformUpdateResponsesToExternalModels( + response: SavedObjectsBulkUpdateResponse +): SavedObjectsBulkUpdateResponse { + return { + ...response, + saved_objects: response.saved_objects.map((so) => ({ + ...so, + ...transformUpdateResponseToExternalModel(so), + })), + }; +} + +export function transformUpdateResponseToExternalModel( + updatedCase: SavedObjectsUpdateResponse +): SavedObjectsUpdateResponse { + const { connector, external_service, ...restUpdateAttributes } = updatedCase.attributes ?? {}; + + const transformedConnector = transformESConnectorToExternalModel({ + // if the saved object had an error the attributes field will not exist + connector, + references: updatedCase.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + let externalService: CaseFullExternalService | null | undefined; + + // if external_service is not defined then we don't want to include it in the response since it wasn't passed it as an + // attribute to update + if (external_service !== undefined) { + externalService = transformESExternalService(external_service, updatedCase.references); + } + + return { + ...updatedCase, + attributes: { + ...restUpdateAttributes, + ...(transformedConnector && { connector: transformedConnector }), + // if externalService is null that means we intentionally updated it to null within ES so return that as a valid value + ...(externalService !== undefined && { external_service: externalService }), + }, + }; +} + +export function transformAttributesToESModel( + caseAttributes: CaseAttributes +): { + attributes: ESCaseAttributes; + referenceHandler: ConnectorReferenceHandler; +}; +export function transformAttributesToESModel( + caseAttributes: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +}; +export function transformAttributesToESModel( + caseAttributes: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +} { + const { connector, external_service, ...restAttributes } = caseAttributes; + const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {}; + + const transformedConnector = { + ...(connector && { + connector: { + name: connector.name, + type: connector.type, + fields: transformFieldsToESModel(connector), + }, + }), + }; + + const transformedExternalService = { + ...(external_service + ? { external_service: restExternalService } + : external_service === null + ? { external_service: null } + : {}), + }; + + return { + attributes: { + ...restAttributes, + ...transformedConnector, + ...transformedExternalService, + }, + referenceHandler: buildReferenceHandler(connector?.id, pushConnectorId), + }; +} + +function buildReferenceHandler( + connectorId?: string, + pushConnectorId?: string | null +): ConnectorReferenceHandler { + return new ConnectorReferenceHandler([ + { id: connectorId, name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + { id: pushConnectorId, name: PUSH_CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ]); +} + +/** + * Until Kibana uses typescript 4.3 or higher we'll have to keep these functions separate instead of using an overload + * definition like this: + * + * export function transformArrayResponseToExternalModel( + * response: SavedObjectsBulkResponse | SavedObjectsFindResponse + * ): SavedObjectsBulkResponse | SavedObjectsFindResponse { + * + * See this issue for more details: https://stackoverflow.com/questions/49510832/typescript-how-to-map-over-union-array-type + */ + +export function transformBulkResponseToExternalModel( + response: SavedObjectsBulkResponse +): SavedObjectsBulkResponse { + return { + ...response, + saved_objects: response.saved_objects.map((so) => ({ + ...so, + ...transformSavedObjectToExternalModel(so), + })), + }; +} + +export function transformFindResponseToExternalModel( + response: SavedObjectsFindResponse +): SavedObjectsFindResponse { + return { + ...response, + saved_objects: response.saved_objects.map((so) => ({ + ...so, + ...transformSavedObjectToExternalModel(so), + })), + }; +} + +export function transformSavedObjectToExternalModel( + caseSavedObject: SavedObject +): SavedObject { + const connector = transformESConnectorOrUseDefault({ + // if the saved object had an error the attributes field will not exist + connector: caseSavedObject.attributes?.connector, + references: caseSavedObject.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + const externalService = transformESExternalService( + caseSavedObject.attributes?.external_service, + caseSavedObject.references + ); + + return { + ...caseSavedObject, + attributes: { + ...caseSavedObject.attributes, + connector, + external_service: externalService, + }, + }; +} + +function transformESExternalService( + // this type needs to match that of CaseFullExternalService except that it does not include the connector_id, see: x-pack/plugins/cases/common/api/cases/case.ts + // that's why it can be null here + externalService: ExternalServicesWithoutConnectorId | null | undefined, + references: SavedObjectReference[] | undefined +): CaseFullExternalService | null { + const connectorIdRef = findConnectorIdReference(PUSH_CONNECTOR_ID_REFERENCE_NAME, references); + + if (!externalService) { + return null; + } + + return { + ...externalService, + connector_id: connectorIdRef?.id ?? null, + }; +} diff --git a/x-pack/plugins/cases/server/services/cases/types.ts b/x-pack/plugins/cases/server/services/cases/types.ts new file mode 100644 index 0000000000000..55c736b032590 --- /dev/null +++ b/x-pack/plugins/cases/server/services/cases/types.ts @@ -0,0 +1,32 @@ +/* + * 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 * as rt from 'io-ts'; +import { CaseAttributes, CaseExternalServiceBasicRt } from '../../../common'; +import { ESCaseConnector } from '..'; + +/** + * This type should only be used within the cases service and its helper functions (e.g. the transforms). + * + * The type represents how the external services portion of the object will be layed out when stored in ES. The external_service will have its + * connector_id field removed and placed within the references field. + */ +export type ExternalServicesWithoutConnectorId = Omit< + rt.TypeOf, + 'connector_id' +>; + +/** + * This type should only be used within the cases service and its helper functions (e.g. the transforms). + * + * The type represents how the Cases object will be layed out in ES. It will not have connector.id or external_service.connector_id. + * Instead those fields will be transformed into the references field. + */ +export type ESCaseAttributes = Omit & { + connector: ESCaseConnector; + external_service: ExternalServicesWithoutConnectorId | null; +}; diff --git a/x-pack/plugins/cases/server/services/configure/index.test.ts b/x-pack/plugins/cases/server/services/configure/index.test.ts new file mode 100644 index 0000000000000..199b541d49f98 --- /dev/null +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -0,0 +1,722 @@ +/* + * 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 { + CaseConnector, + CasesConfigureAttributes, + CasesConfigurePatch, + CASE_CONFIGURE_SAVED_OBJECT, + ConnectorTypes, + SECURITY_SOLUTION_OWNER, +} from '../../../common'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { + SavedObject, + SavedObjectReference, + SavedObjectsCreateOptions, + SavedObjectsFindResult, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from 'kibana/server'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { loggerMock } from '@kbn/logging/target/mocks'; +import { CaseConfigureService } from '.'; +import { ESCasesConfigureAttributes } from './types'; +import { getNoneCaseConnector, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { createESJiraConnector, createJiraConnector, ESCaseConnectorWithId } from '../test_utils'; + +const basicConfigFields = { + closure_type: 'close-by-pushing' as const, + owner: SECURITY_SOLUTION_OWNER, + created_at: '2020-04-09T09:43:51.778Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + updated_at: '2020-04-09T09:43:51.778Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, +}; + +const createConfigUpdateParams = ( + connector?: CaseConnector +): Partial => ({ + connector, +}); + +const createConfigPostParams = (connector: CaseConnector): CasesConfigureAttributes => ({ + ...basicConfigFields, + connector, +}); + +const createUpdateConfigSO = ( + connector?: ESCaseConnectorWithId +): SavedObjectsUpdateResponse => { + const references: SavedObjectReference[] = + connector && connector.id !== 'none' + ? [ + { + id: connector.id, + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []; + + return { + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + connector: connector + ? { name: connector.name, type: connector.type, fields: connector.fields } + : undefined, + }, + version: '1', + references, + }; +}; + +const createConfigSO = ( + connector?: ESCaseConnectorWithId +): SavedObject => { + const references: SavedObjectReference[] = connector + ? [ + { + id: connector.id, + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []; + + const formattedConnector = { + type: connector?.type ?? ConnectorTypes.jira, + name: connector?.name ?? ConnectorTypes.jira, + fields: connector?.fields ?? null, + }; + + return { + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + ...basicConfigFields, + // if connector is null we'll default this to an incomplete jira value because the service + // should switch it to a none connector when the id can't be found in the references array + connector: formattedConnector, + }, + references, + }; +}; + +const createConfigSOPromise = ( + connector?: ESCaseConnectorWithId +): Promise> => Promise.resolve(createConfigSO(connector)); + +const createConfigFindSO = ( + connector?: ESCaseConnectorWithId +): SavedObjectsFindResult => ({ + ...createConfigSO(connector), + score: 0, +}); + +const createSOFindResponse = ( + savedObjects: Array> +) => ({ + saved_objects: savedObjects, + total: savedObjects.length, + per_page: savedObjects.length, + page: 1, +}); + +describe('CaseConfigureService', () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const mockLogger = loggerMock.create(); + + let service: CaseConfigureService; + + beforeEach(() => { + jest.resetAllMocks(); + service = new CaseConfigureService(mockLogger); + }); + + describe('transforms the external model to the Elasticsearch model', () => { + describe('patch', () => { + it('creates the update attributes with the fields that were passed in', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const { connector: ignoreConnector, ...restUpdateAttributes } = unsecuredSavedObjectsClient + .update.mock.calls[0][2] as Partial; + + expect(restUpdateAttributes).toMatchInlineSnapshot(` + Object { + "closure_type": "close-by-pushing", + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "updated_at": "2020-04-09T09:43:51.778Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('transforms the connector.fields to an array of key/value pairs', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + + expect(connector?.fields).toMatchInlineSnapshot(` + Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ] + `); + }); + + it('preserves the connector fields but does not include the id', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const { connector } = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + + expect(connector).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + } + `); + expect(connector).not.toHaveProperty('id'); + }); + + it('moves the connector.id to the references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: {} as SavedObject, + }); + + const updateAttributes = unsecuredSavedObjectsClient.update.mock + .calls[0][2] as Partial; + + expect(updateAttributes.connector).not.toHaveProperty('id'); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('moves the connector.id to the references and includes the existing references', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigPostParams(createJiraConnector()), + originalConfiguration: { + references: [{ id: '123', name: 'awesome', type: 'hello' }], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "awesome", + "type": "hello", + }, + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('does not remove the connector.id reference when the update attributes do not include it', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: { + references: [ + { id: '123', name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ], + } as SavedObject, + }); + + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toMatchInlineSnapshot(` + Array [ + Object { + "id": "123", + "name": "connectorId", + "type": "action", + }, + ] + `); + }); + + it('creates an empty update object and null reference when there is no connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot( + `Object {}` + ); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + Object { + "references": undefined, + } + `); + }); + + it('creates an update object with the none connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(getNoneCaseConnector()), + originalConfiguration: {} as SavedObject, + }); + + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": Array [], + "name": "none", + "type": ".none", + }, + } + `); + const updateOptions = unsecuredSavedObjectsClient.update.mock + .calls[0][3] as SavedObjectsUpdateOptions; + expect(updateOptions.references).toEqual([]); + }); + }); + + describe('post', () => { + it('includes the creation attributes excluding the connector.id field', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(createJiraConnector()), + id: '1', + }); + + const creationAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as ESCasesConfigureAttributes; + expect(creationAttributes.connector).not.toHaveProperty('id'); + expect(creationAttributes).toMatchInlineSnapshot(` + Object { + "closure_type": "close-by-pushing", + "connector": Object { + "fields": Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ], + "name": ".jira", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "owner": "securitySolution", + "updated_at": "2020-04-09T09:43:51.778Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + } + `); + }); + + it('moves the connector.id to the references', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(createJiraConnector()), + id: '1', + }); + + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "id": "1", + "references": Array [ + Object { + "id": "1", + "name": "connectorId", + "type": "action", + }, + ], + } + `); + }); + + it('sets connector.fields to an empty array when it is not included', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(createJiraConnector({ setFieldsToNull: true })), + id: '1', + }); + + const postAttributes = unsecuredSavedObjectsClient.create.mock + .calls[0][1] as CasesConfigureAttributes; + expect(postAttributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Array [], + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('does not create a reference for a none connector', async () => { + unsecuredSavedObjectsClient.create.mockReturnValue( + Promise.resolve({} as SavedObject) + ); + + await service.post({ + unsecuredSavedObjectsClient, + attributes: createConfigPostParams(getNoneCaseConnector()), + id: '1', + }); + + const creationOptions = unsecuredSavedObjectsClient.create.mock + .calls[0][2] as SavedObjectsCreateOptions; + expect(creationOptions.references).toEqual([]); + }); + }); + }); + + describe('transform the Elasticsearch model to the external model', () => { + describe('patch', () => { + it('returns an object with a none connector and without a reference', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateConfigSO(getNoneCaseConnector())) + ); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res.attributes).toMatchInlineSnapshot(` + Object { + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + } + `); + expect(res.references).toMatchInlineSnapshot(`Array []`); + }); + + it('returns an undefined connector if it is not returned by the update', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve({} as SavedObjectsUpdateResponse) + ); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "attributes": Object {}, + } + `); + }); + + it('returns the default none connector when it cannot find the reference', async () => { + const { name, type, fields } = createESJiraConnector(); + const returnValue: SavedObjectsUpdateResponse = { + type: CASE_CONFIGURE_SAVED_OBJECT, + id: '1', + attributes: { + connector: { + name, + type, + fields, + }, + }, + version: '1', + references: undefined, + }; + + unsecuredSavedObjectsClient.update.mockReturnValue(Promise.resolve(returnValue)); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('returns a jira connector', async () => { + unsecuredSavedObjectsClient.update.mockReturnValue( + Promise.resolve(createUpdateConfigSO(createESJiraConnector())) + ); + + const res = await service.patch({ + configurationId: '1', + unsecuredSavedObjectsClient, + updatedAttributes: createConfigUpdateParams(), + originalConfiguration: {} as SavedObject, + }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "1", + "name": ".jira", + "type": ".jira", + } + `); + }); + }); + + describe('find', () => { + it('includes the id field in the response', async () => { + const findMockReturn = createSOFindResponse([ + createConfigFindSO(createESJiraConnector()), + createConfigFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.find({ unsecuredSavedObjectsClient }); + expect(res.saved_objects[0].attributes.connector.id).toMatchInlineSnapshot(`"1"`); + }); + + it('includes the saved object find response fields in the result', async () => { + const findMockReturn = createSOFindResponse([ + createConfigFindSO(createESJiraConnector()), + createConfigFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.find({ unsecuredSavedObjectsClient }); + const { saved_objects: ignored, ...findResponseFields } = res; + expect(findResponseFields).toMatchInlineSnapshot(` + Object { + "page": 1, + "per_page": 2, + "total": 2, + } + `); + }); + + it('defaults to the none connector when the id cannot be found in the references', async () => { + const findMockReturn = createSOFindResponse([ + createConfigFindSO(createESJiraConnector()), + createConfigFindSO(), + ]); + unsecuredSavedObjectsClient.find.mockReturnValue(Promise.resolve(findMockReturn)); + + const res = await service.find({ unsecuredSavedObjectsClient }); + expect(res.saved_objects[1].attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + + describe('get', () => { + it('includes the id field in the response', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + createConfigSOPromise(createESJiraConnector()) + ); + const res = await service.get({ unsecuredSavedObjectsClient, configurationId: '1' }); + + expect(res.attributes.connector.id).toMatchInlineSnapshot(`"1"`); + }); + + it('defaults to the none connector when the connector reference cannot be found', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue(createConfigSOPromise()); + const res = await service.get({ unsecuredSavedObjectsClient, configurationId: '1' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('defaults to the none connector when attributes is undefined', async () => { + unsecuredSavedObjectsClient.get.mockReturnValue( + Promise.resolve(({ + references: [ + { + id: '1', + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ], + } as unknown) as SavedObject) + ); + const res = await service.get({ unsecuredSavedObjectsClient, configurationId: '1' }); + + expect(res.attributes.connector).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 348bff954b73e..a25818f4ff593 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -5,10 +5,28 @@ * 2.0. */ -import { Logger, SavedObjectsClientContract } from 'kibana/server'; +import { + Logger, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, + SavedObjectsUpdateResponse, +} from 'kibana/server'; -import { SavedObjectFindOptionsKueryNode } from '../../common'; -import { ESCasesConfigureAttributes, CASE_CONFIGURE_SAVED_OBJECT } from '../../../common'; +import { SavedObjectFindOptionsKueryNode, CONNECTOR_ID_REFERENCE_NAME } from '../../common'; +import { + CASE_CONFIGURE_SAVED_OBJECT, + CasesConfigureAttributes, + CasesConfigurePatch, +} from '../../../common'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../../actions/server'; +import { + transformFieldsToESModel, + transformESConnectorToExternalModel, + transformESConnectorOrUseDefault, +} from '../transform'; +import { ConnectorReferenceHandler } from '../connector_reference_handler'; +import { ESCasesConfigureAttributes } from './types'; interface ClientArgs { unsecuredSavedObjectsClient: SavedObjectsClientContract; @@ -22,13 +40,14 @@ interface FindCaseConfigureArgs extends ClientArgs { } interface PostCaseConfigureArgs extends ClientArgs { - attributes: ESCasesConfigureAttributes; + attributes: CasesConfigureAttributes; id: string; } interface PatchCaseConfigureArgs extends ClientArgs { configurationId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; + originalConfiguration: SavedObject; } export class CaseConfigureService { @@ -44,45 +63,61 @@ export class CaseConfigureService { } } - public async get({ unsecuredSavedObjectsClient, configurationId }: GetCaseConfigureArgs) { + public async get({ + unsecuredSavedObjectsClient, + configurationId, + }: GetCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to GET case configuration ${configurationId}`); - return await unsecuredSavedObjectsClient.get( + const configuration = await unsecuredSavedObjectsClient.get( CASE_CONFIGURE_SAVED_OBJECT, configurationId ); + + return transformToExternalModel(configuration); } catch (error) { this.log.debug(`Error on GET case configuration ${configurationId}: ${error}`); throw error; } } - public async find({ unsecuredSavedObjectsClient, options }: FindCaseConfigureArgs) { + public async find({ + unsecuredSavedObjectsClient, + options, + }: FindCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to find all case configuration`); - return await unsecuredSavedObjectsClient.find({ + + const findResp = await unsecuredSavedObjectsClient.find({ ...options, // Get the latest configuration sortField: 'created_at', sortOrder: 'desc', type: CASE_CONFIGURE_SAVED_OBJECT, }); + + return transformFindResponseToExternalModel(findResp); } catch (error) { this.log.debug(`Attempting to find all case configuration`); throw error; } } - public async post({ unsecuredSavedObjectsClient, attributes, id }: PostCaseConfigureArgs) { + public async post({ + unsecuredSavedObjectsClient, + attributes, + id, + }: PostCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to POST a new case configuration`); - return await unsecuredSavedObjectsClient.create( + const esConfigInfo = transformAttributesToESModel(attributes); + const createdConfig = await unsecuredSavedObjectsClient.create( CASE_CONFIGURE_SAVED_OBJECT, - { - ...attributes, - }, - { id } + esConfigInfo.attributes, + { id, references: esConfigInfo.referenceHandler.build() } ); + + return transformToExternalModel(createdConfig); } catch (error) { this.log.debug(`Error on POST a new case configuration: ${error}`); throw error; @@ -93,19 +128,124 @@ export class CaseConfigureService { unsecuredSavedObjectsClient, configurationId, updatedAttributes, - }: PatchCaseConfigureArgs) { + originalConfiguration, + }: PatchCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to UPDATE case configuration ${configurationId}`); - return await unsecuredSavedObjectsClient.update( + const esUpdateInfo = transformAttributesToESModel(updatedAttributes); + + const updatedConfiguration = await unsecuredSavedObjectsClient.update( CASE_CONFIGURE_SAVED_OBJECT, configurationId, { - ...updatedAttributes, + ...esUpdateInfo.attributes, + }, + { + references: esUpdateInfo.referenceHandler.build(originalConfiguration.references), } ); + + return transformUpdateResponseToExternalModel(updatedConfiguration); } catch (error) { this.log.debug(`Error on UPDATE case configuration ${configurationId}: ${error}`); throw error; } } } + +function transformUpdateResponseToExternalModel( + updatedConfiguration: SavedObjectsUpdateResponse +): SavedObjectsUpdateResponse { + const { connector, ...restUpdatedAttributes } = updatedConfiguration.attributes ?? {}; + + const transformedConnector = transformESConnectorToExternalModel({ + connector, + references: updatedConfiguration.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + return { + ...updatedConfiguration, + attributes: { + ...restUpdatedAttributes, + // this will avoid setting connector to undefined, it won't include to field at all + ...(transformedConnector && { connector: transformedConnector }), + }, + }; +} + +function transformToExternalModel( + configuration: SavedObject +): SavedObject { + const connector = transformESConnectorOrUseDefault({ + // if the saved object had an error the attributes field will not exist + connector: configuration.attributes?.connector, + references: configuration.references, + referenceName: CONNECTOR_ID_REFERENCE_NAME, + }); + + return { + ...configuration, + attributes: { + ...configuration.attributes, + connector, + }, + }; +} + +function transformFindResponseToExternalModel( + configurations: SavedObjectsFindResponse +): SavedObjectsFindResponse { + return { + ...configurations, + saved_objects: configurations.saved_objects.map((so) => ({ + ...so, + ...transformToExternalModel(so), + })), + }; +} + +function transformAttributesToESModel( + configuration: CasesConfigureAttributes +): { + attributes: ESCasesConfigureAttributes; + referenceHandler: ConnectorReferenceHandler; +}; +function transformAttributesToESModel( + configuration: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +}; +function transformAttributesToESModel( + configuration: Partial +): { + attributes: Partial; + referenceHandler: ConnectorReferenceHandler; +} { + const { connector, ...restWithoutConnector } = configuration; + + const transformedConnector = { + ...(connector && { + connector: { + name: connector.name, + type: connector.type, + fields: transformFieldsToESModel(connector), + }, + }), + }; + + return { + attributes: { + ...restWithoutConnector, + ...transformedConnector, + }, + referenceHandler: buildReferenceHandler(connector?.id), + }; +} + +function buildReferenceHandler(id?: string): ConnectorReferenceHandler { + return new ConnectorReferenceHandler([ + { id, name: CONNECTOR_ID_REFERENCE_NAME, type: ACTION_SAVED_OBJECT_TYPE }, + ]); +} diff --git a/x-pack/plugins/cases/server/services/configure/types.ts b/x-pack/plugins/cases/server/services/configure/types.ts new file mode 100644 index 0000000000000..f52e05a2ff9b5 --- /dev/null +++ b/x-pack/plugins/cases/server/services/configure/types.ts @@ -0,0 +1,17 @@ +/* + * 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 { CasesConfigureAttributes } from '../../../common'; +import { ESCaseConnector } from '..'; + +/** + * This type should only be used within the configure service. It represents how the configure saved object will be layed + * out in ES. + */ +export type ESCasesConfigureAttributes = Omit & { + connector: ESCaseConnector; +}; diff --git a/x-pack/plugins/cases/server/services/connector_reference_handler.test.ts b/x-pack/plugins/cases/server/services/connector_reference_handler.test.ts new file mode 100644 index 0000000000000..4c42332d10627 --- /dev/null +++ b/x-pack/plugins/cases/server/services/connector_reference_handler.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { noneConnectorId } from '../../common'; +import { ConnectorReferenceHandler } from './connector_reference_handler'; + +describe('ConnectorReferenceHandler', () => { + describe('merge', () => { + it('overwrites the original reference with the new one', () => { + const handler = new ConnectorReferenceHandler([{ id: 'hello2', type: '1', name: 'a' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello2", + "name": "a", + "type": "1", + }, + ] + `); + }); + + it('returns the original references if the new references is an empty array', () => { + const handler = new ConnectorReferenceHandler([]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello", + "name": "a", + "type": "1", + }, + ] + `); + }); + + it('returns undefined when there are no original references and no new ones', () => { + const handler = new ConnectorReferenceHandler([]); + + expect(handler.build()).toBeUndefined(); + }); + + it('returns an empty array when there is an empty array of original references and no new ones', () => { + const handler = new ConnectorReferenceHandler([]); + + expect(handler.build([])).toMatchInlineSnapshot(`Array []`); + }); + + it('removes a reference when the id field is null', () => { + const handler = new ConnectorReferenceHandler([{ id: null, name: 'a', type: '1' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot( + `Array []` + ); + }); + + it('removes a reference when the id field is the none connector', () => { + const handler = new ConnectorReferenceHandler([ + { id: noneConnectorId, name: 'a', type: '1' }, + ]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot( + `Array []` + ); + }); + + it('does not remove a reference when the id field is undefined', () => { + const handler = new ConnectorReferenceHandler([{ id: undefined, name: 'a', type: '1' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello", + "name": "a", + "type": "1", + }, + ] + `); + }); + + it('adds a new reference to existing ones', () => { + const handler = new ConnectorReferenceHandler([{ id: 'awesome', type: '2', name: 'b' }]); + + expect(handler.build([{ id: 'hello', type: '1', name: 'a' }])).toMatchInlineSnapshot(` + Array [ + Object { + "id": "hello", + "name": "a", + "type": "1", + }, + Object { + "id": "awesome", + "name": "b", + "type": "2", + }, + ] + `); + }); + + it('adds new references to an undefined original reference array', () => { + const handler = new ConnectorReferenceHandler([ + { id: 'awesome', type: '2', name: 'a' }, + { id: 'awesome', type: '2', name: 'b' }, + ]); + + expect(handler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "awesome", + "name": "a", + "type": "2", + }, + Object { + "id": "awesome", + "name": "b", + "type": "2", + }, + ] + `); + }); + + it('adds new references to an empty original reference array', () => { + const handler = new ConnectorReferenceHandler([ + { id: 'awesome', type: '2', name: 'a' }, + { id: 'awesome', type: '2', name: 'b' }, + ]); + + expect(handler.build()).toMatchInlineSnapshot(` + Array [ + Object { + "id": "awesome", + "name": "a", + "type": "2", + }, + Object { + "id": "awesome", + "name": "b", + "type": "2", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/connector_reference_handler.ts b/x-pack/plugins/cases/server/services/connector_reference_handler.ts new file mode 100644 index 0000000000000..81e1541366ab5 --- /dev/null +++ b/x-pack/plugins/cases/server/services/connector_reference_handler.ts @@ -0,0 +1,57 @@ +/* + * 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 { SavedObjectReference } from 'kibana/server'; +import { noneConnectorId } from '../../common'; + +interface Reference { + soReference?: SavedObjectReference; + name: string; +} + +export class ConnectorReferenceHandler { + private newReferences: Reference[] = []; + + constructor(references: Array<{ id?: string | null; name: string; type: string }>) { + for (const { id, name, type } of references) { + // When id is null, or the none connector we'll try to remove the reference if it exists + // When id is undefined it means that we're doing a patch request and this particular field shouldn't be updated + // so we'll ignore it. If it was already in the reference array then it'll stay there when we merge them together below + if (id === null || id === noneConnectorId) { + this.newReferences.push({ name }); + } else if (id) { + this.newReferences.push({ soReference: { id, name, type }, name }); + } + } + } + + /** + * Merges the references passed to the constructor into the original references passed into this function + * + * @param originalReferences existing saved object references + * @returns a merged reference list or undefined when there are no new or existing references + */ + public build(originalReferences?: SavedObjectReference[]): SavedObjectReference[] | undefined { + if (this.newReferences.length <= 0) { + return originalReferences; + } + + const refMap = new Map( + originalReferences?.map((ref) => [ref.name, ref]) + ); + + for (const newRef of this.newReferences) { + if (newRef.soReference) { + refMap.set(newRef.name, newRef.soReference); + } else { + refMap.delete(newRef.name); + } + } + + return Array.from(refMap.values()); + } +} diff --git a/x-pack/plugins/cases/server/services/index.ts b/x-pack/plugins/cases/server/services/index.ts index 09895d9392441..f910099c0cc20 100644 --- a/x-pack/plugins/cases/server/services/index.ts +++ b/x-pack/plugins/cases/server/services/index.ts @@ -6,6 +6,7 @@ */ import { SavedObjectsClientContract } from 'kibana/server'; +import { ConnectorTypes } from '../../common'; export { CasesService } from './cases'; export { CaseConfigureService } from './configure'; @@ -17,3 +18,14 @@ export { AttachmentService } from './attachments'; export interface ClientArgs { unsecuredSavedObjectsClient: SavedObjectsClientContract; } + +export type ESConnectorFields = Array<{ + key: string; + value: unknown; +}>; + +export interface ESCaseConnector { + name: string; + type: ConnectorTypes; + fields: ESConnectorFields | null; +} diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts new file mode 100644 index 0000000000000..b712ea07f9c71 --- /dev/null +++ b/x-pack/plugins/cases/server/services/test_utils.ts @@ -0,0 +1,200 @@ +/* + * 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 { SavedObject, SavedObjectReference } from 'kibana/server'; +import { ESConnectorFields } from '.'; +import { CONNECTOR_ID_REFERENCE_NAME, PUSH_CONNECTOR_ID_REFERENCE_NAME } from '../common'; +import { + CaseConnector, + CaseFullExternalService, + CaseStatuses, + CaseType, + CASE_SAVED_OBJECT, + ConnectorTypes, + noneConnectorId, + SECURITY_SOLUTION_OWNER, +} from '../../common'; +import { ESCaseAttributes, ExternalServicesWithoutConnectorId } from './cases/types'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../actions/server'; + +/** + * This is only a utility interface to help with constructing test cases. After the migration, the ES format will no longer + * have the id field. Instead it will be moved to the references array. + */ +export interface ESCaseConnectorWithId { + id: string; + name: string; + type: ConnectorTypes; + fields: ESConnectorFields | null; +} + +/** + * This file contains utility functions to aid unit test development + */ + +/** + * Create an Elasticsearch jira connector. + * + * @param overrides fields used to override the default jira connector + * @returns a jira Elasticsearch connector (it has key value pairs for the fields) by default + */ +export const createESJiraConnector = ( + overrides?: Partial +): ESCaseConnectorWithId => { + return { + id: '1', + name: ConnectorTypes.jira, + fields: [ + { key: 'issueType', value: 'bug' }, + { key: 'priority', value: 'high' }, + { key: 'parent', value: '2' }, + ], + type: ConnectorTypes.jira, + ...(overrides && { ...overrides }), + }; +}; + +/** + * Creates a jira CaseConnector (has the actual fields defined in the object instead of key value paris) + * @param setFieldsToNull a flag that controls setting the fields property to null + * @returns a jira connector + */ +export const createJiraConnector = ({ + setFieldsToNull, +}: { setFieldsToNull?: boolean } = {}): CaseConnector => { + return { + id: '1', + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: setFieldsToNull + ? null + : { + issueType: 'bug', + priority: 'high', + parent: '2', + }, + }; +}; + +export const createExternalService = ( + overrides?: Partial +): CaseFullExternalService => ({ + connector_id: '100', + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + ...(overrides && { ...overrides }), +}); + +export const basicCaseFields = { + closed_at: null, + closed_by: null, + created_at: '2019-11-25T21:54:48.952Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + status: CaseStatuses.open, + tags: ['defacement'], + type: CaseType.individual, + updated_at: '2019-11-25T21:54:48.952Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + settings: { + syncAlerts: true, + }, + owner: SECURITY_SOLUTION_OWNER, +}; + +export const createCaseSavedObjectResponse = ({ + connector, + externalService, +}: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; +} = {}): SavedObject => { + const references: SavedObjectReference[] = createSavedObjectReferences({ + connector, + externalService, + }); + + const formattedConnector = { + type: connector?.type ?? ConnectorTypes.jira, + name: connector?.name ?? ConnectorTypes.jira, + fields: connector?.fields ?? null, + }; + + let restExternalService: ExternalServicesWithoutConnectorId | null = null; + if (externalService !== null) { + const { connector_id: ignored, ...rest } = externalService ?? { + connector_name: '.jira', + external_id: '100', + external_title: 'awesome', + external_url: 'http://www.google.com', + pushed_at: '2019-11-25T21:54:48.952Z', + pushed_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + }; + restExternalService = rest; + } + + return { + type: CASE_SAVED_OBJECT, + id: '1', + attributes: { + ...basicCaseFields, + // if connector is null we'll default this to an incomplete jira value because the service + // should switch it to a none connector when the id can't be found in the references array + connector: formattedConnector, + external_service: restExternalService, + }, + references, + }; +}; + +export const createSavedObjectReferences = ({ + connector, + externalService, +}: { + connector?: ESCaseConnectorWithId; + externalService?: CaseFullExternalService; +} = {}): SavedObjectReference[] => [ + ...(connector && connector.id !== noneConnectorId + ? [ + { + id: connector.id, + name: CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []), + ...(externalService && externalService.connector_id + ? [ + { + id: externalService.connector_id, + name: PUSH_CONNECTOR_ID_REFERENCE_NAME, + type: ACTION_SAVED_OBJECT_TYPE, + }, + ] + : []), +]; diff --git a/x-pack/plugins/cases/server/services/transform.test.ts b/x-pack/plugins/cases/server/services/transform.test.ts new file mode 100644 index 0000000000000..b4346595e4998 --- /dev/null +++ b/x-pack/plugins/cases/server/services/transform.test.ts @@ -0,0 +1,211 @@ +/* + * 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 { ACTION_SAVED_OBJECT_TYPE } from '../../../actions/server'; +import { ConnectorTypes } from '../../common'; +import { createESJiraConnector, createJiraConnector } from './test_utils'; +import { + findConnectorIdReference, + transformESConnectorOrUseDefault, + transformESConnectorToExternalModel, + transformFieldsToESModel, +} from './transform'; + +describe('service transform helpers', () => { + describe('findConnectorIdReference', () => { + it('finds the reference when it exists', () => { + expect( + findConnectorIdReference('a', [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }]) + ).toBeDefined(); + }); + + it('does not find the reference when the name is different', () => { + expect( + findConnectorIdReference('a', [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'b' }]) + ).toBeUndefined(); + }); + + it('does not find the reference when references is empty', () => { + expect(findConnectorIdReference('a', [])).toBeUndefined(); + }); + + it('does not find the reference when references is undefined', () => { + expect(findConnectorIdReference('a', undefined)).toBeUndefined(); + }); + + it('does not find the reference when the type is different', () => { + expect( + findConnectorIdReference('a', [{ id: 'hello', type: 'yo', name: 'a' }]) + ).toBeUndefined(); + }); + }); + + describe('transformESConnectorToExternalModel', () => { + it('returns undefined when the connector is undefined', () => { + expect(transformESConnectorToExternalModel({ referenceName: 'a' })).toBeUndefined(); + }); + + it('returns the default connector when it cannot find the reference', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector(), + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + + it('converts the connector.fields to an object', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector(), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + })?.fields + ).toMatchInlineSnapshot(` + Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + } + `); + }); + + it('returns the full jira connector', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector(), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": Object { + "issueType": "bug", + "parent": "2", + "priority": "high", + }, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets fields to null if it is an empty array', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector({ fields: [] }), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets fields to null if it is null', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector({ fields: null }), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + + it('sets fields to null if it is undefined', () => { + expect( + transformESConnectorToExternalModel({ + connector: createESJiraConnector({ fields: undefined }), + references: [{ id: 'hello', type: ACTION_SAVED_OBJECT_TYPE, name: 'a' }], + referenceName: 'a', + }) + ).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "hello", + "name": ".jira", + "type": ".jira", + } + `); + }); + }); + + describe('transformESConnectorOrUseDefault', () => { + it('returns the default connector when the connector is undefined', () => { + expect(transformESConnectorOrUseDefault({ referenceName: 'a' })).toMatchInlineSnapshot(` + Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + } + `); + }); + }); + + describe('transformFieldsToESModel', () => { + it('returns an empty array when fields is null', () => { + expect(transformFieldsToESModel(createJiraConnector({ setFieldsToNull: true })).length).toBe( + 0 + ); + }); + + it('returns an empty array when fields is an empty object', () => { + expect( + transformFieldsToESModel({ + id: '1', + name: ConnectorTypes.jira, + type: ConnectorTypes.jira, + fields: {} as { + issueType: string; + priority: string; + parent: string; + }, + }).length + ).toBe(0); + }); + + it('returns an array with the key/value pairs', () => { + expect(transformFieldsToESModel(createJiraConnector())).toMatchInlineSnapshot(` + Array [ + Object { + "key": "issueType", + "value": "bug", + }, + Object { + "key": "priority", + "value": "high", + }, + Object { + "key": "parent", + "value": "2", + }, + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/transform.ts b/x-pack/plugins/cases/server/services/transform.ts new file mode 100644 index 0000000000000..39351d3a4b50a --- /dev/null +++ b/x-pack/plugins/cases/server/services/transform.ts @@ -0,0 +1,100 @@ +/* + * 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 { SavedObjectReference } from 'kibana/server'; +import { CaseConnector, ConnectorTypeFields } from '../../common'; +import { ACTION_SAVED_OBJECT_TYPE } from '../../../actions/server'; +import { getNoneCaseConnector } from '../common'; +import { ESCaseConnector, ESConnectorFields } from '.'; + +export function findConnectorIdReference( + name: string, + references?: SavedObjectReference[] +): SavedObjectReference | undefined { + return references?.find((ref) => ref.type === ACTION_SAVED_OBJECT_TYPE && ref.name === name); +} + +export function transformESConnectorToExternalModel({ + connector, + references, + referenceName, +}: { + connector?: ESCaseConnector; + references?: SavedObjectReference[]; + referenceName: string; +}): CaseConnector | undefined { + const connectorIdRef = findConnectorIdReference(referenceName, references); + return transformConnectorFieldsToExternalModel(connector, connectorIdRef?.id); +} + +function transformConnectorFieldsToExternalModel( + connector?: ESCaseConnector, + connectorId?: string +): CaseConnector | undefined { + if (!connector) { + return; + } + + // if the connector is valid, but we can't find it's ID in the reference, then it must be malformed + // or it was a none connector which doesn't have a reference (a none connector doesn't point to any actual connector + // saved object) + if (!connectorId) { + return getNoneCaseConnector(); + } + + const connectorTypeField = { + type: connector.type, + fields: + connector.fields != null && connector.fields.length > 0 + ? connector.fields.reduce( + (fields, { key, value }) => ({ + ...fields, + [key]: value, + }), + {} + ) + : null, + } as ConnectorTypeFields; + + return { + id: connectorId, + name: connector.name, + ...connectorTypeField, + }; +} + +export function transformESConnectorOrUseDefault({ + connector, + references, + referenceName, +}: { + connector?: ESCaseConnector; + references?: SavedObjectReference[]; + referenceName: string; +}): CaseConnector { + return ( + transformESConnectorToExternalModel({ connector, references, referenceName }) ?? + getNoneCaseConnector() + ); +} + +export function transformFieldsToESModel(connector: CaseConnector): ESConnectorFields { + if (!connector.fields) { + return []; + } + + return Object.entries(connector.fields).reduce( + (acc, [key, value]) => [ + ...acc, + { + key, + value, + }, + ], + [] + ); +} diff --git a/x-pack/plugins/cases/server/services/user_actions/helpers.ts b/x-pack/plugins/cases/server/services/user_actions/helpers.ts index 4d0f899d40785..223e731aa8d9b 100644 --- a/x-pack/plugins/cases/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/cases/server/services/user_actions/helpers.ts @@ -13,18 +13,16 @@ import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, CaseUserActionAttributes, - ESCaseAttributes, OWNER_FIELD, SUB_CASE_SAVED_OBJECT, SubCaseAttributes, User, UserAction, UserActionField, - UserActionFieldType, + CaseAttributes, } from '../../../common'; import { isTwoArraysDifference } from '../../client/utils'; import { UserActionItem } from '.'; -import { transformESConnectorToCaseConnector } from '../../common'; export const transformNewUserAction = ({ actionField, @@ -173,17 +171,12 @@ interface CaseSubIDs { } type GetCaseAndSubID = (so: SavedObjectsUpdateResponse) => CaseSubIDs; -type GetField = ( - attributes: Pick, 'attributes'>, - field: UserActionFieldType -) => unknown; /** * Abstraction functions to retrieve a given field and the caseId and subCaseId depending on * whether we're interacting with a case or a sub case. */ interface Getters { - getField: GetField; getCaseAndSubID: GetCaseAndSubID; } @@ -209,7 +202,7 @@ const buildGenericCaseUserActions = ({ allowedFields: UserActionField; getters: Getters; }): UserActionItem[] => { - const { getCaseAndSubID, getField } = getters; + const { getCaseAndSubID } = getters; return updatedCases.reduce((acc, updatedItem) => { const { caseId, subCaseId } = getCaseAndSubID(updatedItem); // regardless of whether we're looking at a sub case or case, the id field will always be used to match between @@ -220,8 +213,8 @@ const buildGenericCaseUserActions = ({ const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach((field) => { if (allowedFields.includes(field)) { - const origValue = getField(originalItem, field); - const updatedValue = getField(updatedItem, field); + const origValue = get(originalItem, ['attributes', field]); + const updatedValue = get(updatedItem, ['attributes', field]); if (isString(origValue) && isString(updatedValue) && origValue !== updatedValue) { userActions = [ @@ -308,18 +301,12 @@ export const buildSubCaseUserActions = (args: { originalSubCases: Array>; updatedSubCases: Array>; }): UserActionItem[] => { - const getField = ( - so: Pick, 'attributes'>, - field: UserActionFieldType - ) => get(so, ['attributes', field]); - const getCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { const caseId = so.references?.find((ref) => ref.type === CASE_SAVED_OBJECT)?.id ?? ''; return { caseId, subCaseId: so.id }; }; const getters: Getters = { - getField, getCaseAndSubID, }; @@ -339,24 +326,14 @@ export const buildSubCaseUserActions = (args: { export const buildCaseUserActions = (args: { actionDate: string; actionBy: User; - originalCases: Array>; - updatedCases: Array>; + originalCases: Array>; + updatedCases: Array>; }): UserActionItem[] => { - const getField = ( - so: Pick, 'attributes'>, - field: UserActionFieldType - ) => { - return field === 'connector' && so.attributes.connector - ? transformESConnectorToCaseConnector(so.attributes.connector) - : get(so, ['attributes', field]); - }; - const caseGetIds: GetCaseAndSubID = (so: SavedObjectsUpdateResponse): CaseSubIDs => { return { caseId: so.id }; }; const getters: Getters = { - getField, getCaseAndSubID: caseGetIds, }; diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 5fdf4680f5ca8..b702448165554 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -55,6 +55,7 @@ export class CaseUserActionService { public async bulkCreate({ unsecuredSavedObjectsClient, actions }: PostCaseUserActionArgs) { try { this.log.debug(`Attempting to POST a new case user action`); + return await unsecuredSavedObjectsClient.bulkCreate( actions.map((action) => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) ); diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 6b59d9780a513..e6b21ea96a266 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -56,6 +56,8 @@ import { SignalHit } from '../../../../plugins/security_solution/server/lib/dete import { ActionResult, FindActionResult } from '../../../../plugins/actions/server/types'; import { User } from './authentication/types'; import { superUser } from './authentication/users'; +import { ESCasesConfigureAttributes } from '../../../../plugins/cases/server/services/configure/types'; +import { ESCaseAttributes } from '../../../../plugins/cases/server/services/cases/types'; function toArray(input: T | T[]): T[] { if (Array.isArray(input)) { @@ -605,6 +607,49 @@ export const getConnectorMappingsFromES = async ({ es }: { es: KibanaClient }) = return mappings; }; +interface ConfigureSavedObject { + 'cases-configure': ESCasesConfigureAttributes; +} + +/** + * Returns configure saved objects from Elasticsearch directly. + */ +export const getConfigureSavedObjectsFromES = async ({ es }: { es: KibanaClient }) => { + const configure: ApiResponse> = await es.search({ + index: '.kibana', + body: { + query: { + term: { + type: { + value: 'cases-configure', + }, + }, + }, + }, + }); + + return configure; +}; + +export const getCaseSavedObjectsFromES = async ({ es }: { es: KibanaClient }) => { + const configure: ApiResponse< + estypes.SearchResponse<{ cases: ESCaseAttributes }> + > = await es.search({ + index: '.kibana', + body: { + query: { + term: { + type: { + value: 'cases', + }, + }, + }, + }, + }); + + return configure; +}; + export const createCaseWithConnector = async ({ supertest, configureReq = {}, diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts index 941b71fb925db..f21a0ab460424 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/cases/migrations.ts @@ -11,7 +11,7 @@ import { CASES_URL, SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; -import { getCase } from '../../../../common/lib/utils'; +import { getCase, getCaseSavedObjectsFromES } from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function createGetTests({ getService }: FtrProviderContext) { @@ -121,13 +121,90 @@ export default function createGetTests({ getService }: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); }); - it('adds the owner field', async () => { - const theCase = await getCase({ - supertest, - caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + describe('owner field', () => { + it('adds the owner field', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.owner).to.be(SECURITY_SOLUTION_OWNER); + }); + }); + + describe('migrating connector id to a reference', () => { + const es = getService('es'); + + it('preserves the connector id after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.connector.id).to.be('d68508f0-cf9d-11eb-a603-13e7747d215c'); + }); + + it('preserves the connector fields after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.connector).to.eql({ + fields: { + issueType: '10002', + parent: null, + priority: null, + }, + id: 'd68508f0-cf9d-11eb-a603-13e7747d215c', + name: 'Test Jira', + type: '.jira', + }); }); - expect(theCase.owner).to.be(SECURITY_SOLUTION_OWNER); + it('removes the connector id field in the saved object', async () => { + const casesFromES = await getCaseSavedObjectsFromES({ es }); + expect(casesFromES.body.hits.hits[0]._source?.cases.connector).to.not.have.property('id'); + }); + + it('preserves the external_service.connector_id after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.external_service?.connector_id).to.be( + 'd68508f0-cf9d-11eb-a603-13e7747d215c' + ); + }); + + it('preserves the external_service fields after migration in the API response', async () => { + const theCase = await getCase({ + supertest, + caseId: 'e49ad6e0-cf9d-11eb-a603-13e7747d215c', + }); + + expect(theCase.external_service).to.eql({ + connector_id: 'd68508f0-cf9d-11eb-a603-13e7747d215c', + connector_name: 'Test Jira', + external_id: '10106', + external_title: 'TPN-99', + external_url: 'https://cases-testing.atlassian.net/browse/TPN-99', + pushed_at: '2021-06-17T18:57:45.524Z', + pushed_by: { + email: null, + full_name: 'j@j.com', + username: '711621466', + }, + }); + }); + + it('removes the connector_id field in the saved object', async () => { + const casesFromES = await getCaseSavedObjectsFromES({ es }); + expect( + casesFromES.body.hits.hits[0]._source?.cases.external_service + ).to.not.have.property('id'); + }); }); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts index 67eb23a43f397..79d5a46fa8bdd 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/common/configure/migrations.ts @@ -11,7 +11,11 @@ import { CASE_CONFIGURE_URL, SECURITY_SOLUTION_OWNER, } from '../../../../../../plugins/cases/common/constants'; -import { getConfiguration, getConnectorMappingsFromES } from '../../../../common/lib/utils'; +import { + getConfiguration, + getConfigureSavedObjectsFromES, + getConnectorMappingsFromES, +} from '../../../../common/lib/utils'; // eslint-disable-next-line import/no-default-export export default function ({ getService }: FtrProviderContext) { @@ -57,23 +61,57 @@ export default function ({ getService }: FtrProviderContext) { await esArchiver.unload('x-pack/test/functional/es_archives/cases/migrations/7.13.2'); }); - it('adds the owner field', async () => { - const configuration = await getConfiguration({ - supertest, - query: { owner: SECURITY_SOLUTION_OWNER }, + describe('owner field', () => { + it('adds the owner field', async () => { + const configuration = await getConfiguration({ + supertest, + query: { owner: SECURITY_SOLUTION_OWNER }, + }); + + expect(configuration[0].owner).to.be(SECURITY_SOLUTION_OWNER); }); - expect(configuration[0].owner).to.be(SECURITY_SOLUTION_OWNER); + it('adds the owner field to the connector mapping', async () => { + // We don't get the owner field back from the mappings when we retrieve the configuration so the only way to + // check that the migration worked is by checking the saved object stored in Elasticsearch directly + const mappings = await getConnectorMappingsFromES({ es }); + expect(mappings.body.hits.hits.length).to.be(1); + expect(mappings.body.hits.hits[0]._source?.['cases-connector-mappings'].owner).to.eql( + SECURITY_SOLUTION_OWNER + ); + }); }); - it('adds the owner field to the connector mapping', async () => { - // We don't get the owner field back from the mappings when we retrieve the configuration so the only way to - // check that the migration worked is by checking the saved object stored in Elasticsearch directly - const mappings = await getConnectorMappingsFromES({ es }); - expect(mappings.body.hits.hits.length).to.be(1); - expect(mappings.body.hits.hits[0]._source?.['cases-connector-mappings'].owner).to.eql( - SECURITY_SOLUTION_OWNER - ); + describe('migrating connector id to a reference', () => { + it('preserves the connector id after migration in the API response', async () => { + const configuration = await getConfiguration({ + supertest, + query: { owner: SECURITY_SOLUTION_OWNER }, + }); + + expect(configuration[0].connector.id).to.be('d68508f0-cf9d-11eb-a603-13e7747d215c'); + }); + + it('preserves the connector fields after migration in the API response', async () => { + const configuration = await getConfiguration({ + supertest, + query: { owner: SECURITY_SOLUTION_OWNER }, + }); + + expect(configuration[0].connector).to.eql({ + fields: null, + id: 'd68508f0-cf9d-11eb-a603-13e7747d215c', + name: 'Test Jira', + type: '.jira', + }); + }); + + it('removes the connector id field in the saved object', async () => { + const configurationFromES = await getConfigureSavedObjectsFromES({ es }); + expect( + configurationFromES.body.hits.hits[0]._source?.['cases-configure'].connector + ).to.not.have.property('id'); + }); }); }); }); diff --git a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts index 374053dd3b8b7..94fe494fc7cc4 100644 --- a/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts +++ b/x-pack/test/case_api_integration/security_and_spaces/tests/trial/cases/push_case.ts @@ -31,6 +31,7 @@ import { createConnector, getServiceNowConnector, getConnectorMappingsFromES, + getCase, } from '../../../../common/lib/utils'; import { ExternalServiceSimulator, @@ -102,6 +103,72 @@ export default ({ getService }: FtrProviderContext): void => { ).to.equal(true); }); + it('preserves the connector.id after pushing a case', async () => { + const { postedCase, connector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + const theCase = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: connector.id, + }); + + expect(theCase.connector.id).to.eql(connector.id); + }); + + it('preserves the external_service.connector_id after updating the connector', async () => { + const { postedCase, connector: pushConnector } = await createCaseWithConnector({ + supertest, + servicenowSimulatorURL, + actionsRemover, + }); + + const theCaseAfterPush = await pushCase({ + supertest, + caseId: postedCase.id, + connectorId: pushConnector.id, + }); + + const newConnector = await createConnector({ + supertest, + req: { + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }, + }); + + actionsRemover.add('default', newConnector.id, 'action', 'actions'); + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: theCaseAfterPush.version, + connector: { + id: newConnector.id, + name: newConnector.name, + type: newConnector.connector_type_id, + fields: { + urgency: '2', + impact: '2', + severity: '2', + category: 'software', + subcategory: 'os', + }, + } as CaseConnector, + }, + ], + }, + }); + + const theCaseAfterUpdate = await getCase({ supertest, caseId: postedCase.id }); + expect(theCaseAfterUpdate.connector.id).to.eql(newConnector.id); + expect(theCaseAfterUpdate.external_service?.connector_id).to.eql(pushConnector.id); + }); + it('should create the mappings when pushing a case', async () => { // create a connector but not a configuration so that the mapping will not be present const connector = await createConnector({ diff --git a/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz b/x-pack/test/functional/es_archives/cases/migrations/7.13.2/data.json.gz index c86af3f7d2fbaec66f201962574ac6ce500f4688..26a782f32508b561ac4c6264250f78a61d322b59 100644 GIT binary patch literal 1340 zcmV-C1;hFuiwFqC^#fr517u-zVJ>QOZ*BnXSy6AJI1ql%uZVaK6i9%Or`y+)?sN~; zJ#vKkhOiZQ~AdJs< zeb077+l%hK`67rGv%m@b*+a*M^+7v~65tMGIbAHOJfEn94P96jV8^ZZ^v9Evlsik~ zV0VxVk>`cJHw#0n;W(ZizsX?E?EO_0!>$R_cHg72uYfNr`C!)TQt;AyFRjJ$?8TSX zq+wkjC@bA5mE{{7Z8>LiQ%)0CjO4Rs31NG`Vi^S2$xI<-PMr+p)2j~91$wVx4sNLFke zKyrX_1`4Nu_u zJLOT_yfX}#4A*h3lQR+AKm>~fV$FLrPp%^w&SH?#(Crbn(#M1gQ~Q)j1_(@TuAI&# z^GHoWsC4CR5 zHK7LNCDSMpduLvJ#68B~@T}pV4)IxL|$-C~6<3IB|x90|pnx6a5)33LK1Q zFmCAL8f5NSk+fS*p8Ue2_j&fWA6^X_)d?^p?9$kLU3@5#FF8bnQ3$GF_ih46#Zj!> z!$S2Q0=8!2qA7F-$CS`r_cSl}Wz60~6DGosX6NiVODmA8)HjeNm^PF--MkgW)B5LM)#V}ght~Ux# z6(9Y9pu-(M8VJ-pW|SsszA{MiP1!+b!_nBI1>V&wZRLrFMaO5TvYX2d3E6pcm5|kk z2tnY+{zncG>UbJ3J2F&NfYILvm}VTL?e8g#<7Ii_I#!kh6H zq*Napwy7qP1)C5cGk*+Iu<6Brws?QSY$;Qx8#F+s^qcgAT>%jvY{@Ew}8enEDGaJq({ zDLZx)EH|7TWkJ>}X9;GRo#dCA8JJqXr-eTgt&@w&^sn*XQ0BBCI@PM>xo$F{ArnXO zmAa#1lWi*m7ik=%K{&fwdnC@WP6nKGan%@!O*QTH!8W^qVKdE7GQ{&e$Bn(4>fLUs z+iE>#SeiQD;z)^o1JC!JdE{Oz@M?#)cVNF~r5%kxcbd%N>qVe(pl)?#RShL$swl8h z-n4v%=!Mp*<}ZR)i;f0?_Yr6dxy^C4+W~aYCX18*^ZaxDji!al^gd0f^;m|zB}0ZL;3qtq=KHJGVDy1g4P=?JA8IP==#0J6aNAC>Ay_lAOHX(+K_ty literal 1351 zcmV-N1-SYjiwFqRrpsUe17u-zVJ>QOZ*BnX8BLF)HuRof5pfO-kN_d4?zK{@9!5RP zW)wN_l3|xXWSg0dM)~ix4F;Q#3{0DDBJB~sPd~qJ-bdrHK@WQ{7GopMBV)o3UQt@` zlXv#PK9iV3BSe6h1p#cLh$eYP7T|-u@jcs&HeqZ!4y;Y&+f&n-LJ-V?*mb;;BWIGu z@PqTQz9(yxp_L;cBrkB(h#Pl_QlJc89&%>;g1neCHyP?{!kA3jNGn7+o@={~Y5S%V zJUa7*7c6F;<+-z`4ubVT6UG3rLrKaGvm#9=DqzD7RvFp}>wU{JL|;&5tqV#sz`sF? z2u&g@$_?HzdFPX-LpcM!P`ncqVc}0aG5QZ z#$tKa7)xVPqplAOl~GD%1!1kNz^rdCU>B}0u!RvBCO6`deMfngK}2)BN@Bqv_UE2G z-&}Mi!KtCFg+BtJM*3S2~rP9ow;e9YPbDR-}LZX@;Q=EpRGeFO=@d;^Cq6 zN6LgG+15go-Hascsu|FSHO}7%JV-W2)uZw>uAhP5d(?Vib`=bNBwLL5Gye^kRBjzu z&eU>iR>tlWM+t|{aN#^`%QjAAV%PwNECQl=l;-he#KSB^F$?h?;Vu2Ha%*OvJW3FS z*c8g?STf&~pvLh>El1W7^$XTG#Xjb8F2(uGeJZ6?MyUC&c*YxX$;Z*?ePAkF3^32| z?ALnXG$- z{~0V+OgPTqTB5V7D|pXtrXe|TgA0!ESy%Yj8PwoX9fJVnh9K=1Cx{g8KApos$cWVn7@(6tO`?>#go@0E>NmMKd3<7+_I;^ zQy0ZgkI-v2Bo&$4y9YT__j{LmZs@?jMyWTWe79poiq(AZ!YGcfH1S9=t!bxfrkI%@ z6~kyLza*@@xBBQ`L^@pb7K@0Q16!?*n(JC1`OaL>*|2TVXaRcl&Ru!-VG(l=U3-1G zAz?fZt`f%j^1}1%(EXXqi!#Or>?}@uy*t}ksBNIezbfRq!|@WXpNJCO-Pg7F$+n`i z)VJ3o+=s-#-xPSU5NNgpWlvBZGPT&**Ja&LERAP&uG~$K@E%v+SCku-q3ez_OO9G+ zF*}qC88OyXQsm55Z9v#`R<3u%v{mh4_;)oW$;}L3%}C>woSIIIee+K2_-n^;V>=Gl zVu|d3$d~}rHQZCi>}YK6EMv-Mu2&wbkY&b_Uuw4CYW><${+kG%TvVq2eDHxXr;XF8 zRt?T|6ATNZFbJ}sP`CC4~ftg^#ZZ6v-;HP;8TJuM8|rQXT% z&~+?3bZ&m`yQN93@uFd=>-=~mIra@k*R|$>eXU?Do!s1^{hqggEGq43Gz+g6l`Vm~ z)s<5 zX~L`rS$tlN&Jv^Pa9Uv0Meg&bgROsg|9sk1Dr(h1cBan?*1Ljs`0Rktb$hKg{sHTz JR$P}M001q&oJ0Ts