[Cases] Introduces the incremental_id field and the id incrementer SO (#219757)

## Summary

This is the first PR related to
https://github.com/elastic/kibana/issues/212570. (more up-to-date
information in the [design
doc](https://docs.google.com/document/d/1DZKTPl7UryYjpjVMNhIYbE82OADVOg93-d02f0ZQtUI/edit?tab=t.0))

It only introduces the `incremental_id` field to the cases object and
introduces the new "id incrementer" saved object. Tests and migrations
have been changed accordingly and the `incremental_id` field is removed
from cases import/export.

The motivation behind releasing these changes first is so that
serverless deployments will know about the new field and SO once the
other changes are coming.


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jan Monschke 2025-06-05 08:47:24 +02:00 committed by GitHub
parent bee60b3a41
commit 57bd2fa637
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 862 additions and 224 deletions

View file

@ -174,6 +174,7 @@
"external_service.pushed_by.full_name",
"external_service.pushed_by.profile_uid",
"external_service.pushed_by.username",
"incremental_id",
"observables",
"observables.typeKey",
"observables.value",
@ -216,6 +217,11 @@
"cases-connector-mappings": [
"owner"
],
"cases-incrementing-id": [
"@timestamp",
"last_id",
"updated_at"
],
"cases-rules": [
"counter",
"createdAt",

View file

@ -600,6 +600,9 @@
}
}
},
"incremental_id": {
"type": "unsigned_long"
},
"observables": {
"properties": {
"typeKey": {
@ -733,6 +736,20 @@
}
}
},
"cases-incrementing-id": {
"dynamic": false,
"properties": {
"@timestamp": {
"type": "date"
},
"last_id": {
"type": "keyword"
},
"updated_at": {
"type": "date"
}
}
},
"cases-rules": {
"dynamic": false,
"properties": {

View file

@ -11,4 +11,4 @@ export { registerCoreObjectTypes } from './registration';
// set minimum number of registered saved objects to ensure no object types are removed after 8.8
// declared in internal implementation exclicilty to prevent unintended changes.
export const SAVED_OBJECT_TYPES_COUNT = 132 as const;
export const SAVED_OBJECT_TYPES_COUNT = 133 as const;

View file

@ -76,10 +76,11 @@ describe('checking migration metadata changes on all registered SO types', () =>
"canvas-element": "cdedc2123eb8a1506b87a56b0bcce60f4ec08bc8",
"canvas-workpad": "9d82aafb19586b119e5c9382f938abe28c26ca5c",
"canvas-workpad-template": "c077b0087346776bb3542b51e1385d172cb24179",
"cases": "91771732e2e488e4c1b1ac468057925d1c6b32b5",
"cases": "1a51230a1b00364e5a534b6d481779be1b751384",
"cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25",
"cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf",
"cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25",
"cases-incrementing-id": "3785df401fb95aedd0ab94fd047871386a568f5e",
"cases-rules": "6d1776f5c46a99e1a0f3085c537146c1cdfbc829",
"cases-telemetry": "f219eb7e26772884342487fc9602cfea07b3cedc",
"cases-user-actions": "483f10db9b3bd1617948d7032a98b7791bf87414",

View file

@ -38,6 +38,7 @@ const previouslyRegisteredTypes = [
'cases-comments',
'cases-configure',
'cases-connector-mappings',
'cases-incrementing-id', // Added in 8.19/9.1 to allow for incremental numerical ids in cases
'cases-rules',
'cases-sub-case',
'cases-user-actions',

View file

@ -25,6 +25,7 @@ export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions' as const;
export const CASE_COMMENT_SAVED_OBJECT = 'cases-comments' as const;
export const CASE_CONFIGURE_SAVED_OBJECT = 'cases-configure' as const;
export const CASE_RULES_SAVED_OBJECT = 'cases-rules' as const;
export const CASE_ID_INCREMENTER_SAVED_OBJECT = 'cases-incrementing-id' as const;
/**
* If more values are added here please also add them here: x-pack/test/cases_api_integration/common/plugins

View file

@ -134,6 +134,7 @@ const basicCase: Case = {
description: null,
},
],
incremental_id: 123,
};
describe('CasePostRequestRt', () => {

View file

@ -92,6 +92,7 @@ const basicCase = {
},
],
observables: [],
incremental_id: undefined,
};
describe('RelatedCaseRt', () => {
@ -206,6 +207,7 @@ describe('CaseAttributesRt', () => {
},
],
observables: [],
incremental_id: undefined,
};
it('has expected attributes in request', () => {

View file

@ -53,6 +53,8 @@ export const CaseSettingsRt = rt.strict({
syncAlerts: rt.boolean,
});
export const CaseIncrementalId = rt.union([rt.number, rt.undefined]);
const CaseBaseFields = {
/**
* The description of the case
@ -95,6 +97,10 @@ const CaseBaseFields = {
* Observables
*/
observables: rt.array(CaseObservableRt),
/**
* Incremental ID
*/
incremental_id: CaseIncrementalId,
};
export const CaseBaseOptionalFieldsRt = rt.exact(

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './v1';

View file

@ -0,0 +1,13 @@
/*
* 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';
export const CaseIdIncrementerAttributesRt = rt.strict({
'@timestamp': rt.number,
updated_at: rt.number,
last_id: rt.number,
});

View file

@ -256,6 +256,7 @@ export const basicCase: CaseUI = {
category: null,
customFields: [],
observables: [],
incrementalId: undefined,
};
export const basicFileMock: FileJSON = {
@ -380,6 +381,7 @@ export const mockCase: CaseUI = {
category: null,
customFields: [],
observables: [],
incrementalId: undefined,
};
export const basicCasePost: CaseUI = {
@ -565,6 +567,7 @@ export const basicCaseSnake: Case = {
updated_by: elasticUserSnake,
owner: SECURITY_SOLUTION_OWNER,
customFields: [],
incremental_id: undefined,
} as Case;
export const caseWithAlertsSnake = {

View file

@ -0,0 +1,165 @@
/*
* 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.
*/
const http = require('http');
const pMap = require('p-map');
const yargs = require('yargs');
const username = 'elastic';
const password = 'changeme';
const makeRequest = async ({ options, data }) => {
return new Promise((resolve, reject) => {
const reqData = JSON.stringify(data);
const reqOptions = {
...options,
rejectUnauthorized: false,
requestCert: true,
agent: false,
headers: {
...options.headers,
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
'Content-Type': 'application/json',
'Content-Length': reqData.length,
'kbn-xsrf': 'true',
},
};
const req = http.request(reqOptions, (res) => {
const body = [];
res.on('data', (chunk) => body.push(chunk));
res.on('end', () => {
const resString = Buffer.concat(body).toString();
try {
if (resString != null && resString.length > 0) {
const res = JSON.parse(resString);
if (res.statusCode && res.statusCode === 400) {
reject(new Error(res.message));
}
}
} catch (error) {
reject(error);
}
resolve(resString);
});
});
req.on('error', (err) => {
reject(err);
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Request time out'));
});
req.write(reqData);
req.end();
});
};
const getHostAndPort = () => ({
host: '127.0.0.1',
port: 5601,
});
const createCase = (counter, owner) => ({
title: `Sample Case ${counter}`,
tags: [],
severity: 'low',
description: `Auto generated case ${counter}`,
assignees: [],
connector: {
id: 'none',
name: 'none',
type: '.none',
fields: null,
},
settings: {
syncAlerts: false,
},
owner: owner ?? 'cases',
customFields: [],
});
const generateCases = async (cases, space) => {
try {
console.log(`Creating ${cases.length} cases in ${space ? `space: ${space}` : 'default space'}`);
const path = space ? `/s/${space}/api/cases` : '/api/cases';
await pMap(
cases,
(theCase) => {
const options = {
...getHostAndPort(),
path,
method: 'POST',
};
return makeRequest({ options, data: theCase });
},
{ concurrency: 100 }
);
} catch (error) {
console.log(error);
}
};
const main = async () => {
try {
const argv = yargs.help().options({
count: {
alias: 'c',
describe: 'number of cases to generate',
type: 'number',
default: 10,
},
owners: {
alias: 'o',
describe:
'solutions where the cases should be created. combination of securitySolution, observability, or cases',
default: 'cases',
type: 'array',
},
space: {
alias: 's',
describe: 'space where the cases should be created',
default: '',
type: 'string',
},
}).argv;
const { count, owners, space } = argv;
const numCasesToCreate = Number(count);
const potentialOwners = new Set(['securitySolution', 'observability', 'cases']);
const invalidOwnerProvided = owners.some((owner) => !potentialOwners.has(owner));
if (invalidOwnerProvided) {
console.error('Only valid owners are securitySolution, observability, and cases');
// eslint-disable-next-line no-process-exit
process.exit(1);
}
const cases = Array(numCasesToCreate)
.fill(null)
.map((_, index) => {
const owner = owners[Math.floor(Math.random() * owners.length)];
return createCase(index + 1, owner);
});
await generateCases(cases, space);
} catch (error) {
console.log(error);
}
};
main();
process.on('uncaughtException', function (err) {
console.log(err);
});

View file

@ -121,6 +121,7 @@ describe('bulkCreate', () => {
"duration": null,
"external_service": null,
"id": "mock-id-1",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -165,6 +166,7 @@ describe('bulkCreate', () => {
"duration": null,
"external_service": null,
"id": "mock-id-1",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -245,6 +247,7 @@ describe('bulkCreate', () => {
"duration": null,
"external_service": null,
"id": "mock-saved-object-id",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -284,6 +287,7 @@ describe('bulkCreate', () => {
"duration": null,
"external_service": null,
"id": "mock-saved-object-id",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {

View file

@ -901,6 +901,7 @@ describe('update', () => {
"duration": null,
"external_service": null,
"id": "mock-id-1",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -945,6 +946,7 @@ describe('update', () => {
"duration": null,
"external_service": null,
"id": "mock-id-2",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {

View file

@ -18,12 +18,7 @@ import { SECURITY_SOLUTION_OWNER } from '../../../common';
import { mockCases } from '../../mocks';
import { createCasesClientMock, createCasesClientMockArgs } from '../mocks';
import { create } from './create';
import {
CaseSeverity,
CaseStatuses,
ConnectorTypes,
CustomFieldTypes,
} from '../../../common/types/domain';
import { CaseSeverity, ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain';
import type { CaseCustomFields } from '../../../common/types/domain';
import { omit } from 'lodash';
@ -244,24 +239,9 @@ describe('create', () => {
expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
attributes: expect.objectContaining({
title: 'title with spaces',
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
category: null,
customFields: [],
observables: [],
},
id: expect.any(String),
refresh: false,
}),
})
);
});
@ -322,24 +302,9 @@ describe('create', () => {
expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
attributes: expect.objectContaining({
description: 'this is a description with spaces!!',
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
category: null,
customFields: [],
observables: [],
},
id: expect.any(String),
refresh: false,
}),
})
);
});
@ -402,24 +367,9 @@ describe('create', () => {
expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
attributes: expect.objectContaining({
tags: ['pepsi', 'coke'],
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
category: null,
customFields: [],
observables: [],
},
id: expect.any(String),
refresh: false,
}),
})
);
});
@ -470,23 +420,9 @@ describe('create', () => {
expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
attributes: expect.objectContaining({
category: 'reporting',
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
customFields: [],
observables: [],
},
id: expect.any(String),
refresh: false,
}),
})
);
});
@ -550,23 +486,9 @@ describe('create', () => {
expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
category: null,
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
attributes: expect.objectContaining({
customFields: theCustomFields,
observables: [],
},
id: expect.any(String),
refresh: false,
}),
})
);
});
@ -576,26 +498,12 @@ describe('create', () => {
expect(clientArgs.services.caseService.createCase).toHaveBeenCalledWith(
expect.objectContaining({
attributes: {
...theCase,
closed_by: null,
closed_at: null,
category: null,
created_at: expect.any(String),
created_by: expect.any(Object),
updated_at: null,
updated_by: null,
external_service: null,
duration: null,
status: CaseStatuses.open,
attributes: expect.objectContaining({
customFields: [
{ key: 'first_key', type: 'text', value: 'default value' },
{ key: 'second_key', type: 'toggle', value: null },
],
observables: [],
},
id: expect.any(String),
refresh: false,
}),
})
);
});

View file

@ -50,6 +50,7 @@ export interface CasePersistedAttributes {
category?: string | null;
customFields?: CasePersistedCustomFields;
observables?: Observable[];
incremental_id?: number;
}
type CasePersistedCustomFields = Array<{

View file

@ -150,6 +150,7 @@ describe('common utils', () => {
"description": "A description",
"duration": null,
"external_service": null,
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -206,6 +207,7 @@ describe('common utils', () => {
"description": "A description",
"duration": null,
"external_service": null,
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -266,6 +268,7 @@ describe('common utils', () => {
"description": "A description",
"duration": null,
"external_service": null,
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -332,6 +335,7 @@ describe('common utils', () => {
"description": "A description",
"duration": null,
"external_service": null,
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -393,6 +397,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-1",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -437,6 +442,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-2",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -485,6 +491,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-3",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -537,6 +544,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-4",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -618,6 +626,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-1",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -687,6 +696,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-3",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -747,6 +757,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-3",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -830,6 +841,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-3",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -888,6 +900,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-1",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -951,6 +964,7 @@ describe('common utils', () => {
"duration": null,
"external_service": null,
"id": "mock-id-1",
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {

View file

@ -89,6 +89,7 @@ export const transformNewCase = ({
category: newCase.category ?? null,
customFields: newCase.customFields ?? [],
observables: [],
incremental_id: undefined,
});
export const transformCases = ({

View file

@ -147,6 +147,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [
duration: null,
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
incremental_id: undefined,
title: 'Super Bad Security Issue',
status: CaseStatuses.open,
tags: ['defacement'],
@ -191,6 +192,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [
duration: null,
description: 'Oh no, a bad meanie destroying data!',
external_service: null,
incremental_id: undefined,
title: 'Damaging Data Destruction Detected',
status: CaseStatuses.open,
tags: ['Data Destruction'],
@ -235,6 +237,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [
duration: null,
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
incremental_id: undefined,
title: 'Another bad one',
status: CaseStatuses.open,
tags: ['LOLBins'],
@ -283,6 +286,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [
duration: null,
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
incremental_id: undefined,
status: CaseStatuses.closed,
title: 'Another bad one',
tags: ['LOLBins'],

View file

@ -17,7 +17,8 @@ import { CASE_SAVED_OBJECT } from '../../../common/constants';
import type { CasePersistedAttributes } from '../../common/types/case';
import { handleExport } from '../import_export/export';
import { caseMigrations } from '../migrations';
import { modelVersion1, modelVersion2 } from './model_versions';
import { modelVersion1, modelVersion2, modelVersion3 } from './model_versions';
import { handleImport } from '../import_export/import';
export const createCaseSavedObjectType = (
coreSetup: CoreSetup,
@ -239,12 +240,16 @@ export const createCaseSavedObjectType = (
},
},
},
incremental_id: {
type: 'unsigned_long',
},
},
},
migrations: caseMigrations,
modelVersions: {
1: modelVersion1,
2: modelVersion2,
3: modelVersion3,
},
management: {
importableAndExportable: true,
@ -255,5 +260,6 @@ export const createCaseSavedObjectType = (
context: SavedObjectsExportTransformContext,
objects: Array<SavedObject<CasePersistedAttributes>>
) => handleExport({ context, objects, coreSetup, logger }),
onImport: (objects: Array<SavedObject<CasePersistedAttributes>>) => handleImport({ objects }),
},
});

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export { modelVersion1 } from './model_version_1';
export { modelVersion2 } from './model_version_2';
export { modelVersion3 } from './model_version_3';

View file

@ -6,8 +6,7 @@
*/
import type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server';
import { casesSchemaV1, casesSchemaV2 } from './schemas';
import { casesSchemaV1 } from '../schemas';
/**
* Adds custom fields to the cases SO.
*/
@ -58,30 +57,3 @@ export const modelVersion1: SavedObjectsModelVersion = {
forwardCompatibility: casesSchemaV1.extends({}, { unknowns: 'ignore' }),
},
};
/**
* Adds case observables to the cases SO.
*/
export const modelVersion2: SavedObjectsModelVersion = {
changes: [
{
type: 'mappings_addition',
addedMappings: {
observables: {
type: 'nested',
properties: {
typeKey: {
type: 'keyword',
},
value: {
type: 'keyword',
},
},
},
},
},
],
schemas: {
forwardCompatibility: casesSchemaV2.extends({}, { unknowns: 'ignore' }),
},
};

View file

@ -0,0 +1,33 @@
/*
* 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 type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server';
import { casesSchemaV2 } from '../schemas';
export const modelVersion2: SavedObjectsModelVersion = {
changes: [
{
type: 'mappings_addition',
addedMappings: {
observables: {
type: 'nested',
properties: {
typeKey: {
type: 'keyword',
},
value: {
type: 'keyword',
},
},
},
},
},
],
schemas: {
forwardCompatibility: casesSchemaV2.extends({}, { unknowns: 'ignore' }),
},
};

View file

@ -0,0 +1,28 @@
/*
* 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 type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server';
import { casesSchemaV3 } from '../schemas';
/**
* Adds the incremental id to the cases SO.
*/
export const modelVersion3: SavedObjectsModelVersion = {
changes: [
{
type: 'mappings_addition',
addedMappings: {
incremental_id: {
type: 'unsigned_long',
},
},
},
],
schemas: {
forwardCompatibility: casesSchemaV3.extends({}, { unknowns: 'ignore' }),
},
};

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import { modelVersion1, modelVersion2 } from './model_versions';
import { modelVersion1, modelVersion2, modelVersion3 } from '.';
describe('Model versions', () => {
describe('1', () => {
it('returns the model version correctly', () => {
describe('version 1', () => {
it('returns version 1 changes correctly', () => {
expect(modelVersion1.changes).toMatchInlineSnapshot(`
Array [
Object {
@ -57,26 +57,45 @@ describe('Model versions', () => {
});
});
describe('2', () => {
expect(modelVersion2.changes).toMatchInlineSnapshot(`
Array [
Object {
"addedMappings": Object {
"observables": Object {
"properties": Object {
"typeKey": Object {
"type": "keyword",
},
"value": Object {
"type": "keyword",
describe('version 2', () => {
it('returns version 2 changes correctly', () => {
expect(modelVersion2.changes).toMatchInlineSnapshot(`
Array [
Object {
"addedMappings": Object {
"observables": Object {
"properties": Object {
"typeKey": Object {
"type": "keyword",
},
"value": Object {
"type": "keyword",
},
},
"type": "nested",
},
},
"type": "mappings_addition",
},
]
`);
});
});
describe('version 3', () => {
it('returns version 3 changes correctly', () => {
expect(modelVersion3.changes).toMatchInlineSnapshot(`
Array [
Object {
"addedMappings": Object {
"incremental_id": Object {
"type": "unsigned_long",
},
"type": "nested",
},
"type": "mappings_addition",
},
"type": "mappings_addition",
},
]
`);
]
`);
});
});
});

View file

@ -9,3 +9,4 @@ export * from './latest';
export { casesSchema as casesSchemaV1 } from './v1';
export { casesSchema as casesSchemaV2 } from './v2';
export { casesSchema as casesSchemaV3 } from './v3';

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export * from './v2';
export * from './v3';

View file

@ -0,0 +1,13 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { casesSchema as casesSchemaV2 } from './v2';
export const casesSchema = casesSchemaV2.extends({
incremental_id: schema.maybe(schema.nullable(schema.number())),
});

View file

@ -0,0 +1,60 @@
/*
* 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 { coreMock } from '@kbn/core/server/mocks';
import { createCaseSavedObjectType } from './cases';
import {
createModelVersionTestMigrator,
type ModelVersionTestMigrator,
} from '@kbn/core-test-helpers-model-versions';
import { loggerMock } from '@kbn/logging-mocks';
import { createCaseSavedObjectResponse } from '../../services/test_utils';
const mockLogger = loggerMock.create();
const mockCoreSetup = coreMock.createSetup();
const caseSavedObjectType = createCaseSavedObjectType(mockCoreSetup, mockLogger);
describe('caseSavedObjectType model version transformations', () => {
let migrator: ModelVersionTestMigrator;
beforeEach(() => {
migrator = createModelVersionTestMigrator({ type: caseSavedObjectType });
});
describe('Model version 1 to 2', () => {
const version2Fields = ['observables'];
it('by default does not add the new fields to the object', () => {
const caseObj = createCaseSavedObjectResponse();
const migrated = migrator.migrate({
document: caseObj,
fromVersion: 1,
toVersion: 2,
});
version2Fields.forEach((field) => {
expect(migrated.attributes).not.toHaveProperty(field);
});
});
});
describe('Model version 2 to 3', () => {
const version3Fields = ['incremental_id'];
it('by default does not add the new fields to the object', () => {
const migrated = migrator.migrate({
document: createCaseSavedObjectResponse(),
fromVersion: 2,
toVersion: 3,
});
version3Fields.forEach((field) => {
expect(migrated.attributes).not.toHaveProperty(field);
});
});
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import type { SavedObjectsType } from '@kbn/core/server';
import { CASE_ID_INCREMENTER_SAVED_OBJECT } from '../../common/constants';
export const caseIdIncrementerSavedObjectType: SavedObjectsType = {
name: CASE_ID_INCREMENTER_SAVED_OBJECT,
indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX,
hidden: true,
namespaceType: 'multiple-isolated',
mappings: {
dynamic: false,
properties: {
last_id: {
type: 'keyword',
},
'@timestamp': {
type: 'date',
},
updated_at: {
type: 'date',
},
},
},
};

View file

@ -0,0 +1,50 @@
/*
* 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 { httpServerMock, loggingSystemMock, coreMock } from '@kbn/core/server/mocks';
import type { SavedObjectsExportTransformContext } from '@kbn/core/server';
import { handleExport } from './export';
import { mockCases } from '../../mocks';
import type { CaseSavedObjectTransformed } from '../../common/types/case';
jest.mock('./utils', () => {
return {
getAttachmentsAndUserActionsForCases: jest.fn().mockResolvedValue([]),
};
});
describe('case export', () => {
const testRequest = httpServerMock.createFakeKibanaRequest({});
const testContext: SavedObjectsExportTransformContext = { request: testRequest };
const logger = loggingSystemMock.createLogger();
const testCases: CaseSavedObjectTransformed[] = mockCases.map((_case, idx) => ({
..._case,
attributes: {
..._case.attributes,
incremental_id: idx + 1,
},
}));
it('should remove `incremental_id` from cases when exporting', async () => {
const exported = await handleExport({
context: testContext,
coreSetup: coreMock.createSetup(),
// @ts-ignore: mock objects are not matching persisted objects
objects: testCases,
logger,
});
const containsIncrementalId = exported.some((exportedCase) => {
return (
'incremental_id' in exportedCase.attributes &&
exportedCase.attributes.incremental_id !== undefined
);
});
expect(containsIncrementalId).toBeFalsy();
});
});

View file

@ -9,23 +9,16 @@ import type {
CoreSetup,
Logger,
SavedObject,
SavedObjectsClientContract,
SavedObjectsExportTransformContext,
} from '@kbn/core/server';
import type {
CaseUserActionWithoutReferenceIds,
AttachmentAttributesWithoutRefs,
} from '../../../common/types/domain';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
MAX_DOCS_PER_PAGE,
SAVED_OBJECT_TYPES,
} from '../../../common/constants';
import { defaultSortField } from '../../common/utils';
import { SAVED_OBJECT_TYPES } from '../../../common/constants';
import { createCaseError } from '../../common/error';
import type { CasePersistedAttributes } from '../../common/types/case';
import { getAttachmentsAndUserActionsForCases } from './utils';
export async function handleExport({
context,
@ -49,18 +42,25 @@ export async function handleExport({
return [];
}
const cleanedObjects: Array<SavedObject<CasePersistedAttributes>> = objects.map((obj) => ({
...obj,
attributes: {
...obj.attributes,
incremental_id: undefined,
},
}));
const [{ savedObjects }] = await coreSetup.getStartServices();
const savedObjectsClient = savedObjects.getScopedClient(context.request, {
includedHiddenTypes: SAVED_OBJECT_TYPES,
});
const caseIds = objects.map((caseObject) => caseObject.id);
const caseIds = cleanedObjects.map((caseObject) => caseObject.id);
const attachmentsAndUserActionsForCases = await getAttachmentsAndUserActionsForCases(
savedObjectsClient,
caseIds
);
return [...objects, ...attachmentsAndUserActionsForCases.flat()];
return [...cleanedObjects, ...attachmentsAndUserActionsForCases.flat()];
} catch (error) {
throw createCaseError({
message: `Failed to retrieve associated objects for exporting of cases: ${error}`,
@ -69,57 +69,3 @@ export async function handleExport({
});
}
}
async function getAttachmentsAndUserActionsForCases(
savedObjectsClient: SavedObjectsClientContract,
caseIds: string[]
): Promise<
Array<SavedObject<AttachmentAttributesWithoutRefs | CaseUserActionWithoutReferenceIds>>
> {
const [attachments, userActions] = await Promise.all([
getAssociatedObjects<AttachmentAttributesWithoutRefs>({
savedObjectsClient,
caseIds,
sortField: defaultSortField,
type: CASE_COMMENT_SAVED_OBJECT,
}),
getAssociatedObjects<CaseUserActionWithoutReferenceIds>({
savedObjectsClient,
caseIds,
sortField: defaultSortField,
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
]);
return [...attachments, ...userActions];
}
async function getAssociatedObjects<T>({
savedObjectsClient,
caseIds,
sortField,
type,
}: {
savedObjectsClient: SavedObjectsClientContract;
caseIds: string[];
sortField: string;
type: string;
}): Promise<Array<SavedObject<T>>> {
const references = caseIds.map((id) => ({ type: CASE_SAVED_OBJECT, id }));
const finder = savedObjectsClient.createPointInTimeFinder<T>({
type,
hasReferenceOperator: 'OR',
hasReference: references,
perPage: MAX_DOCS_PER_PAGE,
sortField,
sortOrder: 'asc',
});
let result: Array<SavedObject<T>> = [];
for await (const findResults of finder.find()) {
result = result.concat(findResults.saved_objects);
}
return result;
}

View file

@ -0,0 +1,84 @@
/*
* 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 type { CaseSavedObjectTransformed } from '../../common/types/case';
import { mockCases } from '../../mocks';
import { handleImport } from './import';
describe('case import', () => {
it('should raise a warning when import contains a case with `incremental_id`', () => {
const testCases: CaseSavedObjectTransformed[] = mockCases.map((_case, idx) => ({
..._case,
attributes: {
..._case.attributes,
incremental_id: idx + 1,
},
}));
// @ts-ignore: cases attribtue types are not correct
expect(handleImport({ objects: testCases })).toEqual(
expect.objectContaining({
warnings: expect.arrayContaining([
{ message: 'The `incremental_id` field is not supported on importing.', type: 'simple' },
]),
})
);
});
it('should raise a warning when import contains a case with `incremental_id` set to 0', () => {
const testCases: CaseSavedObjectTransformed[] = mockCases.map((_case) => ({
..._case,
attributes: {
..._case.attributes,
incremental_id: 0,
},
}));
// @ts-ignore: cases attribtue types are not correct
expect(handleImport({ objects: testCases })).toEqual(
expect.objectContaining({
warnings: expect.arrayContaining([
{ message: 'The `incremental_id` field is not supported on importing.', type: 'simple' },
]),
})
);
});
it('should raise a warning when import contains a case with `incremental_id` set to a negative value', () => {
const testCases: CaseSavedObjectTransformed[] = mockCases.map((_case) => ({
..._case,
attributes: {
..._case.attributes,
incremental_id: -1,
},
}));
// @ts-ignore: cases attribtue types are not correct
expect(handleImport({ objects: testCases })).toEqual(
expect.objectContaining({
warnings: expect.arrayContaining([
{ message: 'The `incremental_id` field is not supported on importing.', type: 'simple' },
]),
})
);
});
it('should not raise a warning when import contains no case with `incremental_id`', () => {
const testCases: CaseSavedObjectTransformed[] = mockCases.map((_case) => ({
..._case,
attributes: {
..._case.attributes,
incremental_id: undefined,
},
}));
// @ts-ignore: cases attribtue types are not correct
expect(handleImport({ objects: testCases })).not.toEqual(
expect.objectContaining({
warnings: expect.arrayContaining([
{ message: 'The `incremental_id` field is not supported on importing.', type: 'simple' },
]),
})
);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 type { SavedObject, SavedObjectsImportHookResult } from '@kbn/core/server';
import type { CasePersistedAttributes } from '../../common/types/case';
export function handleImport({
objects,
}: {
objects: Array<SavedObject<CasePersistedAttributes>>;
}): SavedObjectsImportHookResult {
const hasObjectsWithIncrementalId = objects.some(
(obj) => obj.attributes.incremental_id !== undefined
);
if (hasObjectsWithIncrementalId) {
return {
warnings: [
{
type: 'simple',
message: 'The `incremental_id` field is not supported on importing.',
},
],
};
} else {
return {};
}
}

View file

@ -0,0 +1,72 @@
/*
* 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 type { SavedObject, SavedObjectsClientContract } from '@kbn/core/server';
import type {
CaseUserActionWithoutReferenceIds,
AttachmentAttributesWithoutRefs,
} from '../../../common/types/domain';
import {
CASE_COMMENT_SAVED_OBJECT,
CASE_SAVED_OBJECT,
CASE_USER_ACTION_SAVED_OBJECT,
MAX_DOCS_PER_PAGE,
} from '../../../common/constants';
import { defaultSortField } from '../../common/utils';
export async function getAttachmentsAndUserActionsForCases(
savedObjectsClient: SavedObjectsClientContract,
caseIds: string[]
): Promise<
Array<SavedObject<AttachmentAttributesWithoutRefs | CaseUserActionWithoutReferenceIds>>
> {
const [attachments, userActions] = await Promise.all([
getAssociatedObjects<AttachmentAttributesWithoutRefs>({
savedObjectsClient,
caseIds,
sortField: defaultSortField,
type: CASE_COMMENT_SAVED_OBJECT,
}),
getAssociatedObjects<CaseUserActionWithoutReferenceIds>({
savedObjectsClient,
caseIds,
sortField: defaultSortField,
type: CASE_USER_ACTION_SAVED_OBJECT,
}),
]);
return [...attachments, ...userActions];
}
async function getAssociatedObjects<T>({
savedObjectsClient,
caseIds,
sortField,
type,
}: {
savedObjectsClient: SavedObjectsClientContract;
caseIds: string[];
sortField: string;
type: string;
}): Promise<Array<SavedObject<T>>> {
const references = caseIds.map((id) => ({ type: CASE_SAVED_OBJECT, id }));
const finder = savedObjectsClient.createPointInTimeFinder<T>({
type,
hasReferenceOperator: 'OR',
hasReference: references,
perPage: MAX_DOCS_PER_PAGE,
sortField,
sortOrder: 'asc',
});
let result: Array<SavedObject<T>> = [];
for await (const findResults of finder.find()) {
result = result.concat(findResults.saved_objects);
}
return result;
}

View file

@ -14,6 +14,7 @@ import { createCaseUserActionSavedObjectType } from './user_actions';
import { caseConnectorMappingsSavedObjectType } from './connector_mappings';
import { casesTelemetrySavedObjectType } from './telemetry';
import { casesRulesSavedObjectType } from './cases_rules';
import { caseIdIncrementerSavedObjectType } from './id_incrementer';
import type { PersistableStateAttachmentTypeRegistry } from '../attachment_framework/persistable_state_registry';
interface RegisterSavedObjectsArgs {
@ -40,6 +41,7 @@ export const registerSavedObjects = ({
core.savedObjects.registerType(caseConfigureSavedObjectType);
core.savedObjects.registerType(caseConnectorMappingsSavedObjectType);
core.savedObjects.registerType(caseIdIncrementerSavedObjectType);
core.savedObjects.registerType(createCaseSavedObjectType(core, logger));
core.savedObjects.registerType(
createCaseUserActionSavedObjectType({

View file

@ -238,6 +238,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -1694,6 +1695,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2208,7 +2210,8 @@ describe('CasesService', () => {
'external_service',
'category',
'customFields',
'observables'
'observables',
'incremental_id'
);
describe('getCaseIdsByAlertId', () => {
@ -2308,6 +2311,7 @@ describe('CasesService', () => {
"description": "This is a brand new case of a bad meanie defacing data",
"duration": null,
"external_service": null,
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2411,6 +2415,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2505,6 +2510,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2599,6 +2605,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2706,6 +2713,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2763,6 +2771,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2866,6 +2875,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -2985,6 +2995,7 @@ describe('CasesService', () => {
"username": "elastic",
},
},
"incremental_id": undefined,
"observables": Array [],
"owner": "securitySolution",
"settings": Object {
@ -3212,6 +3223,18 @@ describe('CasesService', () => {
const persistedAttributes = unsecuredSavedObjectsClient.create.mock.calls[0][1];
expect(persistedAttributes).not.toHaveProperty('foo');
});
it('sets `incremental_id` field to undefined when it is passed', async () => {
const attributes = {
...createCasePostParams({ connector: createJiraConnector() }),
incremental_id: 200,
};
await expect(service.createCase({ id: 'a', attributes })).resolves.not.toThrow();
const persistedAttributes = unsecuredSavedObjectsClient.create.mock.calls[0][1];
expect((persistedAttributes as CaseAttributes).incremental_id).toBeUndefined();
});
});
describe('bulkCreateCases', () => {
@ -3253,6 +3276,22 @@ describe('CasesService', () => {
expect(persistedAttributes).not.toHaveProperty('foo');
});
it('sets `incremental_id` field to undefined when it is passed', async () => {
const attributes = {
...createCasePostParams({ connector: createJiraConnector() }),
incremental_id: 200,
};
await expect(
service.bulkCreateCases({ cases: [{ id: 'a', ...attributes }] })
).resolves.not.toThrow();
const persistedAttributes =
unsecuredSavedObjectsClient.bulkCreate.mock.calls[0][0][0].attributes;
expect((persistedAttributes as CaseAttributes).incremental_id).toBeUndefined();
});
});
describe('patch case', () => {
@ -3294,6 +3333,24 @@ describe('CasesService', () => {
const persistedAttributes = unsecuredSavedObjectsClient.update.mock.calls[0][2];
expect(persistedAttributes).not.toHaveProperty('foo');
});
it('removes incremental_id value', async () => {
const updatedAttributes = {
...createCasePostParams({ connector: createJiraConnector() }),
incremental_id: 200,
};
await expect(
service.patchCase({
caseId: '1',
updatedAttributes,
originalCase: {} as CaseSavedObjectTransformed,
})
).resolves.not.toThrow();
const persistedAttributes = unsecuredSavedObjectsClient.update.mock.calls[0][2];
expect((persistedAttributes as CaseAttributes).incremental_id).toBeUndefined();
});
});
describe('patch cases', () => {
@ -3345,6 +3402,30 @@ describe('CasesService', () => {
expect(persistedAttributes).not.toHaveProperty('foo');
});
it('removes incremental_id values', async () => {
const updatedAttributes = {
...createCasePostParams({ connector: createJiraConnector() }),
incremental_id: 200,
};
await expect(
service.patchCases({
cases: [
{
caseId: '1',
updatedAttributes,
originalCase: {} as CaseSavedObjectTransformed,
},
],
})
).resolves.not.toThrow();
const persistedAttributes =
unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0][0].attributes;
expect((persistedAttributes as CaseAttributes).incremental_id).toBeUndefined();
});
});
});
});

View file

@ -432,6 +432,12 @@ describe('case transforms', () => {
'status'
);
});
it('removes the incremental_id property', () => {
expect(transformAttributesToESModel({ incremental_id: 100 }).attributes).not.toHaveProperty(
'incremental_id'
);
});
});
describe('transformSavedObjectToExternalModel', () => {
@ -626,5 +632,29 @@ describe('case transforms', () => {
transformSavedObjectToExternalModel(CaseSOResponseWithObservables).attributes.observables
).toMatchInlineSnapshot(`Array []`);
});
it('returns incremental_id when it is defined', () => {
const CaseSOResponseWithObservables = createCaseSavedObjectResponse({
overrides: {
incremental_id: 100,
},
});
expect(
transformSavedObjectToExternalModel(CaseSOResponseWithObservables).attributes.incremental_id
).toBe(100);
});
it('returns undefined for `inceremental_id` when it is not defined', () => {
const CaseSOResponseWithObservables = createCaseSavedObjectResponse({
overrides: {
incremental_id: undefined,
},
});
expect(
transformSavedObjectToExternalModel(CaseSOResponseWithObservables).attributes.incremental_id
).not.toBeDefined();
});
});
});

View file

@ -100,6 +100,11 @@ export function transformAttributesToESModel(caseAttributes: Partial<CaseTransfo
const { connector, external_service, severity, status, ...restAttributes } = caseAttributes;
const { connector_id: pushConnectorId, ...restExternalService } = external_service ?? {};
// remove incremental_id, this one's reserved to be set by the system
if ('incremental_id' in restAttributes) {
delete restAttributes.incremental_id;
}
const transformedConnector = {
...(connector && {
connector: {
@ -182,6 +187,7 @@ export function transformSavedObjectToExternalModel(
? []
: (caseSavedObjectAttributes.customFields as CaseCustomFields);
const observables = caseSavedObjectAttributes.observables ?? [];
const incremental_id = caseSavedObjectAttributes.incremental_id ?? undefined;
return {
...caseSavedObject,
@ -194,6 +200,7 @@ export function transformSavedObjectToExternalModel(
category,
customFields,
observables,
incremental_id,
},
};
}

View file

@ -166,6 +166,7 @@ export const basicCaseFields: CaseAttributes = {
category: null,
customFields: [],
observables: [],
incremental_id: undefined,
};
export const createCaseSavedObjectResponse = ({

View file

@ -82,6 +82,7 @@
"@kbn/code-editor-mock",
"@kbn/monaco",
"@kbn/code-editor",
"@kbn/core-test-helpers-model-versions",
],
"exclude": [
"target/**/*",

View file

@ -1431,6 +1431,7 @@ const createCaseWithId = async ({
total_alerts: 0,
total_comments: 0,
observables: [],
incremental_id: undefined,
},
overwrite: false,
});

View file

@ -104,6 +104,7 @@ export const getCaseResponse = (): Case => ({
version: 'test-version',
category: null,
observables: [],
incremental_id: undefined,
});
export const getServiceNowConnector = (): Connector => ({