throw new Error('Not implemented.');
},
}),
- },
+ }),
...partialDeps,
};
const service = new UrlService(deps);
diff --git a/src/plugins/share/common/url_service/locators/locator.ts b/src/plugins/share/common/url_service/locators/locator.ts
index fc970e2c7a490..2d33f701df595 100644
--- a/src/plugins/share/common/url_service/locators/locator.ts
+++ b/src/plugins/share/common/url_service/locators/locator.ts
@@ -67,13 +67,15 @@ export class Locator implements LocatorPublic
{
state: P,
references: SavedObjectReference[]
): P => {
- return this.definition.inject ? this.definition.inject(state, references) : state;
+ if (!this.definition.inject) return state;
+ return this.definition.inject(state, references);
};
public readonly extract: PersistableState
['extract'] = (
state: P
): { state: P; references: SavedObjectReference[] } => {
- return this.definition.extract ? this.definition.extract(state) : { state, references: [] };
+ if (!this.definition.extract) return { state, references: [] };
+ return this.definition.extract(state);
};
// LocatorPublic
----------------------------------------------------------
diff --git a/src/plugins/share/common/url_service/locators/locator_client.ts b/src/plugins/share/common/url_service/locators/locator_client.ts
index 587083551aa6d..7dd69165be5dd 100644
--- a/src/plugins/share/common/url_service/locators/locator_client.ts
+++ b/src/plugins/share/common/url_service/locators/locator_client.ts
@@ -7,9 +7,12 @@
*/
import type { SerializableRecord } from '@kbn/utility-types';
+import { MigrateFunctionsObject } from 'src/plugins/kibana_utils/common';
+import { SavedObjectReference } from 'kibana/server';
import type { LocatorDependencies } from './locator';
-import type { LocatorDefinition, LocatorPublic, ILocatorClient } from './types';
+import type { LocatorDefinition, LocatorPublic, ILocatorClient, LocatorData } from './types';
import { Locator } from './locator';
+import { LocatorMigrationFunction, LocatorsMigrationMap } from '.';
export type LocatorClientDependencies = LocatorDependencies;
@@ -44,4 +47,91 @@ export class LocatorClient implements ILocatorClient {
public get
(id: string): undefined | LocatorPublic
{
return this.locators.get(id);
}
+
+ protected getOrThrow
(id: string): LocatorPublic
{
+ const locator = this.locators.get(id);
+ if (!locator) throw new Error(`Locator [ID = "${id}"] is not registered.`);
+ return locator;
+ }
+
+ public migrations(): { [locatorId: string]: MigrateFunctionsObject } {
+ const migrations: { [locatorId: string]: MigrateFunctionsObject } = {};
+
+ for (const locator of this.locators.values()) {
+ migrations[locator.id] = locator.migrations;
+ }
+
+ return migrations;
+ }
+
+ // PersistableStateService ----------------------------------------------------------
+
+ public telemetry(
+ state: LocatorData,
+ collector: Record
+ ): Record {
+ for (const locator of this.locators.values()) {
+ collector = locator.telemetry(state.state, collector);
+ }
+
+ return collector;
+ }
+
+ public inject(state: LocatorData, references: SavedObjectReference[]): LocatorData {
+ const locator = this.getOrThrow(state.id);
+ const filteredReferences = references
+ .filter((ref) => ref.name.startsWith('params:'))
+ .map((ref) => ({
+ ...ref,
+ name: ref.name.substr('params:'.length),
+ }));
+ return {
+ ...state,
+ state: locator.inject(state.state, filteredReferences),
+ };
+ }
+
+ public extract(state: LocatorData): { state: LocatorData; references: SavedObjectReference[] } {
+ const locator = this.getOrThrow(state.id);
+ const extracted = locator.extract(state.state);
+ return {
+ state: {
+ ...state,
+ state: extracted.state,
+ },
+ references: extracted.references.map((ref) => ({
+ ...ref,
+ name: 'params:' + ref.name,
+ })),
+ };
+ }
+
+ public readonly getAllMigrations = (): LocatorsMigrationMap => {
+ const locatorParamsMigrations = this.migrations();
+ const locatorMigrations: LocatorsMigrationMap = {};
+ const versions = new Set();
+
+ for (const migrationMap of Object.values(locatorParamsMigrations))
+ for (const version of Object.keys(migrationMap)) versions.add(version);
+
+ for (const version of versions.values()) {
+ const migration: LocatorMigrationFunction = (locator) => {
+ const locatorMigrationsMap = locatorParamsMigrations[locator.id];
+ if (!locatorMigrationsMap) return locator;
+
+ const migrationFunction = locatorMigrationsMap[version];
+ if (!migrationFunction) return locator;
+
+ return {
+ ...locator,
+ version,
+ state: migrationFunction(locator.state),
+ };
+ };
+
+ locatorMigrations[version] = migration;
+ }
+
+ return locatorMigrations;
+ };
}
diff --git a/src/plugins/share/common/url_service/locators/types.ts b/src/plugins/share/common/url_service/locators/types.ts
index ab0efa9b2375a..c64dc588aaf22 100644
--- a/src/plugins/share/common/url_service/locators/types.ts
+++ b/src/plugins/share/common/url_service/locators/types.ts
@@ -8,13 +8,18 @@
import type { SerializableRecord } from '@kbn/utility-types';
import { DependencyList } from 'react';
-import { PersistableState } from 'src/plugins/kibana_utils/common';
+import {
+ MigrateFunction,
+ PersistableState,
+ PersistableStateService,
+ VersionedState,
+} from 'src/plugins/kibana_utils/common';
import type { FormatSearchParamsOptions } from './redirect';
/**
* URL locator registry.
*/
-export interface ILocatorClient {
+export interface ILocatorClient extends PersistableStateService {
/**
* Create and register a new locator.
*
@@ -141,3 +146,22 @@ export interface KibanaLocation {
*/
state: S;
}
+
+/**
+ * Represents a serializable state of a locator. Includes locator ID, version
+ * and its params.
+ */
+export interface LocatorData
+ extends VersionedState,
+ SerializableRecord {
+ /**
+ * Locator ID.
+ */
+ id: string;
+}
+
+export interface LocatorsMigrationMap {
+ [semver: string]: LocatorMigrationFunction;
+}
+
+export type LocatorMigrationFunction = MigrateFunction;
diff --git a/src/plugins/share/common/url_service/mocks.ts b/src/plugins/share/common/url_service/mocks.ts
index dd86e2398589e..24ba226818427 100644
--- a/src/plugins/share/common/url_service/mocks.ts
+++ b/src/plugins/share/common/url_service/mocks.ts
@@ -18,7 +18,7 @@ export class MockUrlService extends UrlService {
getUrl: async ({ app, path }, { absolute }) => {
return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`;
},
- shortUrls: {
+ shortUrls: () => ({
get: () => ({
create: async () => {
throw new Error('Not implemented.');
@@ -33,7 +33,7 @@ export class MockUrlService extends UrlService {
throw new Error('Not implemented.');
},
}),
- },
+ }),
});
}
}
diff --git a/src/plugins/share/common/url_service/short_urls/types.ts b/src/plugins/share/common/url_service/short_urls/types.ts
index db744a25f9f79..698ffe7b8421b 100644
--- a/src/plugins/share/common/url_service/short_urls/types.ts
+++ b/src/plugins/share/common/url_service/short_urls/types.ts
@@ -6,9 +6,8 @@
* Side Public License, v 1.
*/
-import { SerializableRecord } from '@kbn/utility-types';
-import { VersionedState } from 'src/plugins/kibana_utils/common';
-import { LocatorPublic } from '../locators';
+import type { SerializableRecord } from '@kbn/utility-types';
+import type { LocatorPublic, ILocatorClient, LocatorData } from '../locators';
/**
* A factory for Short URL Service. We need this factory as the dependency
@@ -21,6 +20,10 @@ export interface IShortUrlClientFactory {
get(dependencies: D): IShortUrlClient;
}
+export type IShortUrlClientFactoryProvider = (params: {
+ locators: ILocatorClient;
+}) => IShortUrlClientFactory;
+
/**
* CRUD-like API for short URLs.
*/
@@ -128,14 +131,4 @@ export interface ShortUrlData;
}
-/**
- * Represents a serializable state of a locator. Includes locator ID, version
- * and its params.
- */
-export interface LocatorData
- extends VersionedState {
- /**
- * Locator ID.
- */
- id: string;
-}
+export type { LocatorData };
diff --git a/src/plugins/share/common/url_service/url_service.ts b/src/plugins/share/common/url_service/url_service.ts
index dedb81720865d..24e2ea0b62379 100644
--- a/src/plugins/share/common/url_service/url_service.ts
+++ b/src/plugins/share/common/url_service/url_service.ts
@@ -7,10 +7,10 @@
*/
import { LocatorClient, LocatorClientDependencies } from './locators';
-import { IShortUrlClientFactory } from './short_urls';
+import { IShortUrlClientFactoryProvider, IShortUrlClientFactory } from './short_urls';
export interface UrlServiceDependencies extends LocatorClientDependencies {
- shortUrls: IShortUrlClientFactory;
+ shortUrls: IShortUrlClientFactoryProvider;
}
/**
@@ -26,6 +26,8 @@ export class UrlService {
constructor(protected readonly deps: UrlServiceDependencies) {
this.locators = new LocatorClient(deps);
- this.shortUrls = deps.shortUrls;
+ this.shortUrls = deps.shortUrls({
+ locators: this.locators,
+ });
}
}
diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts
index 73df7257290f0..33cdf141de9f3 100644
--- a/src/plugins/share/public/mocks.ts
+++ b/src/plugins/share/public/mocks.ts
@@ -18,7 +18,7 @@ const url = new UrlService({
getUrl: async ({ app, path }, { absolute }) => {
return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`;
},
- shortUrls: {
+ shortUrls: () => ({
get: () => ({
create: async () => {
throw new Error('Not implemented');
@@ -33,7 +33,7 @@ const url = new UrlService({
throw new Error('Not implemented.');
},
}),
- },
+ }),
});
const createSetupContract = (): Setup => {
diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts
index 103fbb50bb95f..fd8a5fd7541a6 100644
--- a/src/plugins/share/public/plugin.ts
+++ b/src/plugins/share/public/plugin.ts
@@ -104,7 +104,7 @@ export class SharePlugin implements Plugin {
});
return url;
},
- shortUrls: {
+ shortUrls: () => ({
get: () => ({
create: async () => {
throw new Error('Not implemented');
@@ -119,7 +119,7 @@ export class SharePlugin implements Plugin {
throw new Error('Not implemented.');
},
}),
- },
+ }),
});
this.url.locators.create(new LegacyShortUrlLocatorDefinition());
diff --git a/src/plugins/share/server/plugin.ts b/src/plugins/share/server/plugin.ts
index f0e4abf9eb589..d79588420fe87 100644
--- a/src/plugins/share/server/plugin.ts
+++ b/src/plugins/share/server/plugin.ts
@@ -9,11 +9,14 @@
import { i18n } from '@kbn/i18n';
import { schema } from '@kbn/config-schema';
import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server';
-import { url } from './saved_objects';
import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../common/constants';
import { UrlService } from '../common/url_service';
-import { ServerUrlService, ServerShortUrlClientFactory } from './url_service';
-import { registerUrlServiceRoutes } from './url_service/http/register_url_service_routes';
+import {
+ ServerUrlService,
+ ServerShortUrlClientFactory,
+ registerUrlServiceRoutes,
+ registerUrlServiceSavedObjectType,
+} from './url_service';
import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator';
/** @public */
@@ -44,18 +47,17 @@ export class SharePlugin implements Plugin {
getUrl: async () => {
throw new Error('Locator .getUrl() currently is not supported on the server.');
},
- shortUrls: new ServerShortUrlClientFactory({
- currentVersion: this.version,
- }),
+ shortUrls: ({ locators }) =>
+ new ServerShortUrlClientFactory({
+ currentVersion: this.version,
+ locators,
+ }),
});
-
this.url.locators.create(new LegacyShortUrlLocatorDefinition());
- const router = core.http.createRouter();
-
- registerUrlServiceRoutes(core, router, this.url);
+ registerUrlServiceSavedObjectType(core.savedObjects, this.url);
+ registerUrlServiceRoutes(core, core.http.createRouter(), this.url);
- core.savedObjects.registerType(url);
core.uiSettings.register({
[CSV_SEPARATOR_SETTING]: {
name: i18n.translate('share.advancedSettings.csv.separatorTitle', {
diff --git a/src/plugins/share/server/saved_objects/index.ts b/src/plugins/share/server/saved_objects/index.ts
deleted file mode 100644
index ff37efb9fec17..0000000000000
--- a/src/plugins/share/server/saved_objects/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/*
- * 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 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-export { url } from './url';
diff --git a/src/plugins/share/server/saved_objects/url.ts b/src/plugins/share/server/saved_objects/url.ts
deleted file mode 100644
index 6288e87f629f5..0000000000000
--- a/src/plugins/share/server/saved_objects/url.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * 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 and the Server Side Public License, v 1; you may not use this file except
- * in compliance with, at your election, the Elastic License 2.0 or the Server
- * Side Public License, v 1.
- */
-
-import { SavedObjectsType } from 'kibana/server';
-
-export const url: SavedObjectsType = {
- name: 'url',
- namespaceType: 'single',
- hidden: false,
- management: {
- icon: 'link',
- defaultSearchField: 'url',
- importableAndExportable: true,
- getTitle(obj) {
- return `/goto/${encodeURIComponent(obj.id)}`;
- },
- getInAppUrl(obj) {
- return {
- path: '/goto/' + encodeURIComponent(obj.id),
- uiCapabilitiesPath: '',
- };
- },
- },
- mappings: {
- properties: {
- slug: {
- type: 'text',
- fields: {
- keyword: {
- type: 'keyword',
- },
- },
- },
- accessCount: {
- type: 'long',
- },
- accessDate: {
- type: 'date',
- },
- createDate: {
- type: 'date',
- },
- // Legacy field - contains already pre-formatted final URL.
- // This is here to support old saved objects that have this field.
- // TODO: Remove this field and execute a migration to the new format.
- url: {
- type: 'text',
- fields: {
- keyword: {
- type: 'keyword',
- ignore_above: 2048,
- },
- },
- },
- // Information needed to load and execute a locator.
- locatorJSON: {
- type: 'text',
- index: false,
- },
- },
- },
-};
diff --git a/src/plugins/share/server/url_service/index.ts b/src/plugins/share/server/url_service/index.ts
index 068a5289d42ed..62d1329371736 100644
--- a/src/plugins/share/server/url_service/index.ts
+++ b/src/plugins/share/server/url_service/index.ts
@@ -8,3 +8,5 @@
export * from './types';
export * from './short_urls';
+export { registerUrlServiceRoutes } from './http/register_url_service_routes';
+export { registerUrlServiceSavedObjectType } from './saved_objects/register_url_service_saved_object_type';
diff --git a/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts
new file mode 100644
index 0000000000000..651169f6101a9
--- /dev/null
+++ b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.test.ts
@@ -0,0 +1,144 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { SerializableRecord } from '@kbn/utility-types';
+import type {
+ SavedObjectMigrationMap,
+ SavedObjectsType,
+ SavedObjectUnsanitizedDoc,
+} from 'kibana/server';
+import { ServerShortUrlClientFactory } from '..';
+import { UrlService, LocatorDefinition } from '../../../common/url_service';
+import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator';
+import { MemoryShortUrlStorage } from '../short_urls/storage/memory_short_url_storage';
+import { ShortUrlSavedObjectAttributes } from '../short_urls/storage/saved_object_short_url_storage';
+import { registerUrlServiceSavedObjectType } from './register_url_service_saved_object_type';
+
+const setup = () => {
+ const currentVersion = '7.7.7';
+ const service = new UrlService({
+ getUrl: () => {
+ throw new Error('Not implemented.');
+ },
+ navigate: () => {
+ throw new Error('Not implemented.');
+ },
+ shortUrls: ({ locators }) =>
+ new ServerShortUrlClientFactory({
+ currentVersion,
+ locators,
+ }),
+ });
+ const definition = new LegacyShortUrlLocatorDefinition();
+ const locator = service.locators.create(definition);
+ const storage = new MemoryShortUrlStorage();
+ const client = service.shortUrls.get({ storage });
+
+ let type: SavedObjectsType;
+ registerUrlServiceSavedObjectType(
+ {
+ registerType: (urlSavedObjectType) => {
+ type = urlSavedObjectType;
+ },
+ },
+ service
+ );
+
+ return {
+ type: type!,
+ client,
+ service,
+ storage,
+ locator,
+ definition,
+ currentVersion,
+ };
+};
+
+describe('migrations', () => {
+ test('returns empty migrations object if there are no migrations', () => {
+ const { type } = setup();
+
+ expect((type.migrations as () => SavedObjectMigrationMap)()).toEqual({});
+ });
+
+ test('migrates locator to the latest version', () => {
+ interface FooLocatorParamsOld extends SerializableRecord {
+ color: string;
+ indexPattern: string;
+ }
+
+ interface FooLocatorParams extends SerializableRecord {
+ color: string;
+ indexPatterns: string[];
+ }
+
+ class FooLocatorDefinition implements LocatorDefinition {
+ public readonly id = 'FOO_LOCATOR';
+
+ public async getLocation() {
+ return {
+ app: 'foo',
+ path: '',
+ state: {},
+ };
+ }
+
+ migrations = {
+ '8.0.0': ({ indexPattern, ...rest }: FooLocatorParamsOld): FooLocatorParams => ({
+ ...rest,
+ indexPatterns: [indexPattern],
+ }),
+ };
+ }
+
+ const { type, service } = setup();
+
+ service.locators.create(new FooLocatorDefinition());
+
+ const migrationFunction = (type.migrations as () => SavedObjectMigrationMap)()['8.0.0'];
+
+ expect(typeof migrationFunction).toBe('function');
+
+ const doc1: SavedObjectUnsanitizedDoc = {
+ id: 'foo',
+ attributes: {
+ accessCount: 0,
+ accessDate: 0,
+ createDate: 0,
+ locatorJSON: JSON.stringify({
+ id: 'FOO_LOCATOR',
+ version: '7.7.7',
+ state: {
+ color: 'red',
+ indexPattern: 'myIndex',
+ },
+ }),
+ url: '',
+ },
+ type: 'url',
+ };
+
+ const doc2 = migrationFunction(doc1, {} as any);
+
+ expect(doc2.id).toBe('foo');
+ expect(doc2.type).toBe('url');
+ expect(doc2.attributes.accessCount).toBe(0);
+ expect(doc2.attributes.accessDate).toBe(0);
+ expect(doc2.attributes.createDate).toBe(0);
+ expect(doc2.attributes.url).toBe('');
+ expect(JSON.parse(doc2.attributes.locatorJSON)).toEqual({
+ id: 'FOO_LOCATOR',
+ version: '8.0.0',
+ state: {
+ color: 'red',
+ indexPatterns: ['myIndex'],
+ },
+ });
+ });
+});
diff --git a/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts
new file mode 100644
index 0000000000000..b2fcefcc767cf
--- /dev/null
+++ b/src/plugins/share/server/url_service/saved_objects/register_url_service_saved_object_type.ts
@@ -0,0 +1,97 @@
+/*
+ * 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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import type {
+ SavedObjectMigrationMap,
+ SavedObjectsServiceSetup,
+ SavedObjectsType,
+} from 'kibana/server';
+import type { LocatorData } from 'src/plugins/share/common/url_service';
+import type { ServerUrlService } from '..';
+
+export const registerUrlServiceSavedObjectType = (
+ so: Pick,
+ service: ServerUrlService
+) => {
+ const urlSavedObjectType: SavedObjectsType = {
+ name: 'url',
+ namespaceType: 'single',
+ hidden: false,
+ management: {
+ icon: 'link',
+ defaultSearchField: 'url',
+ importableAndExportable: true,
+ getTitle(obj) {
+ return `/goto/${encodeURIComponent(obj.id)}`;
+ },
+ getInAppUrl(obj) {
+ return {
+ path: '/goto/' + encodeURIComponent(obj.id),
+ uiCapabilitiesPath: '',
+ };
+ },
+ },
+ mappings: {
+ properties: {
+ slug: {
+ type: 'text',
+ fields: {
+ keyword: {
+ type: 'keyword',
+ },
+ },
+ },
+ accessCount: {
+ type: 'long',
+ },
+ accessDate: {
+ type: 'date',
+ },
+ createDate: {
+ type: 'date',
+ },
+ // Legacy field - contains already pre-formatted final URL.
+ // This is here to support old saved objects that have this field.
+ // TODO: Remove this field and execute a migration to the new format.
+ url: {
+ type: 'text',
+ fields: {
+ keyword: {
+ type: 'keyword',
+ ignore_above: 2048,
+ },
+ },
+ },
+ // Information needed to load and execute a locator.
+ locatorJSON: {
+ type: 'text',
+ index: false,
+ },
+ },
+ },
+ migrations: () => {
+ const locatorMigrations = service.locators.getAllMigrations();
+ const savedObjectLocatorMigrations: SavedObjectMigrationMap = {};
+
+ for (const [version, locatorMigration] of Object.entries(locatorMigrations)) {
+ savedObjectLocatorMigrations[version] = (doc) => {
+ const locator = JSON.parse(doc.attributes.locatorJSON) as LocatorData;
+ doc.attributes = {
+ ...doc.attributes,
+ locatorJSON: JSON.stringify(locatorMigration(locator)),
+ };
+ return doc;
+ };
+ }
+
+ return savedObjectLocatorMigrations;
+ },
+ };
+
+ so.registerType(urlSavedObjectType);
+};
diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts
index ac684eb03a9d5..503748a2b1cad 100644
--- a/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts
+++ b/src/plugins/share/server/url_service/short_urls/short_url_client.test.ts
@@ -7,9 +7,11 @@
*/
import { ServerShortUrlClientFactory } from './short_url_client_factory';
-import { UrlService } from '../../../common/url_service';
+import { UrlService, LocatorDefinition } from '../../../common/url_service';
import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator';
import { MemoryShortUrlStorage } from './storage/memory_short_url_storage';
+import { SerializableRecord } from '@kbn/utility-types';
+import { SavedObjectReference } from 'kibana/server';
const setup = () => {
const currentVersion = '1.2.3';
@@ -20,9 +22,11 @@ const setup = () => {
navigate: () => {
throw new Error('Not implemented.');
},
- shortUrls: new ServerShortUrlClientFactory({
- currentVersion,
- }),
+ shortUrls: ({ locators }) =>
+ new ServerShortUrlClientFactory({
+ currentVersion,
+ locators,
+ }),
});
const definition = new LegacyShortUrlLocatorDefinition();
const locator = service.locators.create(definition);
@@ -177,4 +181,111 @@ describe('ServerShortUrlClient', () => {
);
});
});
+
+ describe('Persistable State', () => {
+ interface FooLocatorParams extends SerializableRecord {
+ dashboardId: string;
+ indexPatternId: string;
+ }
+
+ class FooLocatorDefinition implements LocatorDefinition {
+ public readonly id = 'FOO_LOCATOR';
+
+ public readonly getLocation = async () => ({
+ app: 'foo_app',
+ path: '/foo/path',
+ state: {},
+ });
+
+ public readonly extract = (
+ state: FooLocatorParams
+ ): { state: FooLocatorParams; references: SavedObjectReference[] } => ({
+ state,
+ references: [
+ {
+ id: state.dashboardId,
+ type: 'dashboard',
+ name: 'dashboardId',
+ },
+ {
+ id: state.indexPatternId,
+ type: 'index_pattern',
+ name: 'indexPatternId',
+ },
+ ],
+ });
+
+ public readonly inject = (
+ state: FooLocatorParams,
+ references: SavedObjectReference[]
+ ): FooLocatorParams => {
+ const dashboard = references.find(
+ (ref) => ref.type === 'dashboard' && ref.name === 'dashboardId'
+ );
+ const indexPattern = references.find(
+ (ref) => ref.type === 'index_pattern' && ref.name === 'indexPatternId'
+ );
+
+ return {
+ ...state,
+ dashboardId: dashboard ? dashboard.id : '',
+ indexPatternId: indexPattern ? indexPattern.id : '',
+ };
+ };
+ }
+
+ test('extracts and persists references', async () => {
+ const { service, client, storage } = setup();
+ const locator = service.locators.create(new FooLocatorDefinition());
+ const shortUrl = await client.create({
+ locator,
+ params: {
+ dashboardId: '123',
+ indexPatternId: '456',
+ },
+ });
+ const record = await storage.getById(shortUrl.data.id);
+
+ expect(record.references).toEqual([
+ {
+ id: '123',
+ type: 'dashboard',
+ name: 'locator:params:dashboardId',
+ },
+ {
+ id: '456',
+ type: 'index_pattern',
+ name: 'locator:params:indexPatternId',
+ },
+ ]);
+ });
+
+ test('injects references', async () => {
+ const { service, client, storage } = setup();
+ const locator = service.locators.create(new FooLocatorDefinition());
+ const shortUrl1 = await client.create({
+ locator,
+ params: {
+ dashboardId: '3',
+ indexPatternId: '5',
+ },
+ });
+ const record1 = await storage.getById(shortUrl1.data.id);
+
+ record1.data.locator.state = {};
+
+ await storage.update(record1.data.id, record1.data);
+
+ const record2 = await storage.getById(shortUrl1.data.id);
+
+ expect(record2.data.locator.state).toEqual({});
+
+ const shortUrl2 = await client.get(shortUrl1.data.id);
+
+ expect(shortUrl2.data.locator.state).toEqual({
+ dashboardId: '3',
+ indexPatternId: '5',
+ });
+ });
+ });
});
diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client.ts b/src/plugins/share/server/url_service/short_urls/short_url_client.ts
index caaa76bef172d..1efece073d955 100644
--- a/src/plugins/share/server/url_service/short_urls/short_url_client.ts
+++ b/src/plugins/share/server/url_service/short_urls/short_url_client.ts
@@ -7,8 +7,17 @@
*/
import type { SerializableRecord } from '@kbn/utility-types';
+import { SavedObjectReference } from 'kibana/server';
import { generateSlug } from 'random-word-slugs';
-import type { IShortUrlClient, ShortUrl, ShortUrlCreateParams } from '../../../common/url_service';
+import { ShortUrlRecord } from '.';
+import type {
+ IShortUrlClient,
+ ShortUrl,
+ ShortUrlCreateParams,
+ ILocatorClient,
+ ShortUrlData,
+ LocatorData,
+} from '../../../common/url_service';
import type { ShortUrlStorage } from './types';
import { validateSlug } from './util';
@@ -36,6 +45,11 @@ export interface ServerShortUrlClientDependencies {
* Storage provider for short URLs.
*/
storage: ShortUrlStorage;
+
+ /**
+ * The locators service.
+ */
+ locators: ILocatorClient;
}
export class ServerShortUrlClient implements IShortUrlClient {
@@ -64,44 +78,80 @@ export class ServerShortUrlClient implements IShortUrlClient {
}
}
+ const extracted = this.extractReferences({
+ id: locator.id,
+ version: currentVersion,
+ state: params,
+ });
const now = Date.now();
- const data = await storage.create({
- accessCount: 0,
- accessDate: now,
- createDate: now,
- slug,
- locator: {
- id: locator.id,
- version: currentVersion,
- state: params,
+
+ const data = await storage.create(
+ {
+ accessCount: 0,
+ accessDate: now,
+ createDate: now,
+ slug,
+ locator: extracted.state as LocatorData
,
},
- });
+ { references: extracted.references }
+ );
return {
data,
};
}
- public async get(id: string): Promise {
- const { storage } = this.dependencies;
- const data = await storage.getById(id);
+ private extractReferences(locatorData: LocatorData): {
+ state: LocatorData;
+ references: SavedObjectReference[];
+ } {
+ const { locators } = this.dependencies;
+ const { state, references } = locators.extract(locatorData);
+ return {
+ state,
+ references: references.map((ref) => ({
+ ...ref,
+ name: 'locator:' + ref.name,
+ })),
+ };
+ }
+ private injectReferences({ data, references }: ShortUrlRecord): ShortUrlData {
+ const { locators } = this.dependencies;
+ const locatorReferences = references
+ .filter((ref) => ref.name.startsWith('locator:'))
+ .map((ref) => ({
+ ...ref,
+ name: ref.name.substr('locator:'.length),
+ }));
return {
- data,
+ ...data,
+ locator: locators.inject(data.locator, locatorReferences),
};
}
- public async delete(id: string): Promise {
+ public async get(id: string): Promise {
const { storage } = this.dependencies;
- await storage.delete(id);
+ const record = await storage.getById(id);
+ const data = this.injectReferences(record);
+
+ return {
+ data,
+ };
}
public async resolve(slug: string): Promise {
const { storage } = this.dependencies;
- const data = await storage.getBySlug(slug);
+ const record = await storage.getBySlug(slug);
+ const data = this.injectReferences(record);
return {
data,
};
}
+
+ public async delete(id: string): Promise {
+ const { storage } = this.dependencies;
+ await storage.delete(id);
+ }
}
diff --git a/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts
index 696233b7a1ca5..63456c36daa68 100644
--- a/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts
+++ b/src/plugins/share/server/url_service/short_urls/short_url_client_factory.ts
@@ -8,7 +8,7 @@
import { SavedObjectsClientContract } from 'kibana/server';
import { ShortUrlStorage } from './types';
-import type { IShortUrlClientFactory } from '../../../common/url_service';
+import type { IShortUrlClientFactory, ILocatorClient } from '../../../common/url_service';
import { ServerShortUrlClient } from './short_url_client';
import { SavedObjectShortUrlStorage } from './storage/saved_object_short_url_storage';
@@ -20,6 +20,11 @@ export interface ServerShortUrlClientFactoryDependencies {
* Current version of Kibana, e.g. 7.15.0.
*/
currentVersion: string;
+
+ /**
+ * Locators service.
+ */
+ locators: ILocatorClient;
}
export interface ServerShortUrlClientFactoryCreateParams {
@@ -39,9 +44,11 @@ export class ServerShortUrlClientFactory
savedObjects: params.savedObjects!,
savedObjectType: 'url',
});
+ const { currentVersion, locators } = this.dependencies;
const client = new ServerShortUrlClient({
storage,
- currentVersion: this.dependencies.currentVersion,
+ currentVersion,
+ locators,
});
return client;
diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts
index d178e0b81786c..5d1b0bfa0bf55 100644
--- a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts
+++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.test.ts
@@ -41,6 +41,46 @@ describe('.create()', () => {
});
});
+describe('.update()', () => {
+ test('can update an existing short URL', async () => {
+ const storage = new MemoryShortUrlStorage();
+ const now = Date.now();
+ const url1 = await storage.create({
+ accessCount: 0,
+ createDate: now,
+ accessDate: now,
+ locator: {
+ id: 'TEST_LOCATOR',
+ version: '7.11',
+ state: {
+ foo: 'bar',
+ },
+ },
+ slug: 'test-slug',
+ });
+
+ await storage.update(url1.id, {
+ accessCount: 1,
+ });
+
+ const url2 = await storage.getById(url1.id);
+
+ expect(url1.accessCount).toBe(0);
+ expect(url2.data.accessCount).toBe(1);
+ });
+
+ test('throws when URL does not exist', async () => {
+ const storage = new MemoryShortUrlStorage();
+ const [, error] = await of(
+ storage.update('DOES_NOT_EXIST', {
+ accessCount: 1,
+ })
+ );
+
+ expect(error).toBeInstanceOf(Error);
+ });
+});
+
describe('.getById()', () => {
test('can fetch by ID a newly created short URL', async () => {
const storage = new MemoryShortUrlStorage();
@@ -58,7 +98,7 @@ describe('.getById()', () => {
},
slug: 'test-slug',
});
- const url2 = await storage.getById(url1.id);
+ const url2 = (await storage.getById(url1.id)).data;
expect(url2.accessCount).toBe(0);
expect(url1.createDate).toBe(now);
@@ -112,7 +152,7 @@ describe('.getBySlug()', () => {
},
slug: 'test-slug',
});
- const url2 = await storage.getBySlug('test-slug');
+ const url2 = (await storage.getBySlug('test-slug')).data;
expect(url2.accessCount).toBe(0);
expect(url1.createDate).toBe(now);
diff --git a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts
index 40d76a91154ba..fafd00344eecd 100644
--- a/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts
+++ b/src/plugins/share/server/url_service/short_urls/storage/memory_short_url_storage.ts
@@ -9,35 +9,54 @@
import { v4 as uuidv4 } from 'uuid';
import type { SerializableRecord } from '@kbn/utility-types';
import { ShortUrlData } from 'src/plugins/share/common/url_service/short_urls/types';
-import { ShortUrlStorage } from '../types';
+import { SavedObjectReference } from 'kibana/server';
+import { ShortUrlStorage, ShortUrlRecord } from '../types';
+
+const clone = (obj: P): P => JSON.parse(JSON.stringify(obj)) as P;
export class MemoryShortUrlStorage implements ShortUrlStorage {
- private urls = new Map();
+ private urls = new Map();
public async create(
- data: Omit, 'id'>
+ data: Omit, 'id'>,
+ { references = [] }: { references?: SavedObjectReference[] } = {}
): Promise> {
const id = uuidv4();
- const url: ShortUrlData = { ...data, id };
+ const url: ShortUrlRecord
= {
+ data: { ...data, id },
+ references,
+ };
this.urls.set(id, url);
- return url;
+
+ return clone(url.data);
+ }
+
+ public async update
(
+ id: string,
+ data: Partial, 'id'>>,
+ { references }: { references?: SavedObjectReference[] } = {}
+ ): Promise {
+ const so = await this.getById(id);
+ Object.assign(so.data, data);
+ if (references) so.references = references;
+ this.urls.set(id, so);
}
public async getById(
id: string
- ): Promise> {
+ ): Promise> {
if (!this.urls.has(id)) {
throw new Error(`No short url with id "${id}"`);
}
- return this.urls.get(id)! as ShortUrlData;
+ return clone(this.urls.get(id)! as ShortUrlRecord
);
}
public async getBySlug
(
slug: string
- ): Promise> {
+ ): Promise> {
for (const url of this.urls.values()) {
- if (url.slug === slug) {
- return url as ShortUrlData;
+ if (url.data.slug === slug) {
+ return clone(url as ShortUrlRecord
);
}
}
throw new Error(`No short url with slug "${slug}".`);
@@ -45,7 +64,7 @@ export class MemoryShortUrlStorage implements ShortUrlStorage {
public async exists(slug: string): Promise {
for (const url of this.urls.values()) {
- if (url.slug === slug) {
+ if (url.data.slug === slug) {
return true;
}
}
diff --git a/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts
index c66db6d82cdbd..792dfabde3cab 100644
--- a/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts
+++ b/src/plugins/share/server/url_service/short_urls/storage/saved_object_short_url_storage.ts
@@ -7,7 +7,8 @@
*/
import type { SerializableRecord } from '@kbn/utility-types';
-import { SavedObject, SavedObjectsClientContract } from 'kibana/server';
+import { SavedObject, SavedObjectReference, SavedObjectsClientContract } from 'kibana/server';
+import { ShortUrlRecord } from '..';
import { LEGACY_SHORT_URL_LOCATOR_ID } from '../../../../common/url_service/locators/legacy_short_url_locator';
import { ShortUrlData } from '../../../../common/url_service/short_urls/types';
import { ShortUrlStorage } from '../types';
@@ -85,12 +86,15 @@ const createShortUrlData = (
};
const createAttributes =
(
- data: Omit, 'id'>
+ data: Partial, 'id'>>
): ShortUrlSavedObjectAttributes => {
- const { locator, ...rest } = data;
+ const { accessCount = 0, accessDate = 0, createDate = 0, slug = '', locator } = data;
const attributes: ShortUrlSavedObjectAttributes = {
- ...rest,
- locatorJSON: JSON.stringify(locator),
+ accessCount,
+ accessDate,
+ createDate,
+ slug,
+ locatorJSON: locator ? JSON.stringify(locator) : '',
url: '',
};
@@ -106,30 +110,49 @@ export class SavedObjectShortUrlStorage implements ShortUrlStorage {
constructor(private readonly dependencies: SavedObjectShortUrlStorageDependencies) {}
public async create(
- data: Omit, 'id'>
+ data: Omit, 'id'>,
+ { references }: { references?: SavedObjectReference[] } = {}
): Promise> {
const { savedObjects, savedObjectType } = this.dependencies;
const attributes = createAttributes(data);
const savedObject = await savedObjects.create(savedObjectType, attributes, {
refresh: true,
+ references,
});
return createShortUrlData(savedObject);
}
+ public async update
(
+ id: string,
+ data: Partial, 'id'>>,
+ { references }: { references?: SavedObjectReference[] } = {}
+ ): Promise {
+ const { savedObjects, savedObjectType } = this.dependencies;
+ const attributes = createAttributes(data);
+
+ await savedObjects.update(savedObjectType, id, attributes, {
+ refresh: true,
+ references,
+ });
+ }
+
public async getById(
id: string
- ): Promise> {
+ ): Promise> {
const { savedObjects, savedObjectType } = this.dependencies;
const savedObject = await savedObjects.get(savedObjectType, id);
- return createShortUrlData(savedObject);
+ return {
+ data: createShortUrlData
(savedObject),
+ references: savedObject.references,
+ };
}
public async getBySlug
(
slug: string
- ): Promise> {
+ ): Promise> {
const { savedObjects } = this.dependencies;
const search = `(attributes.slug:"${escapeSearchReservedChars(slug)}")`;
const result = await savedObjects.find({
@@ -143,7 +166,10 @@ export class SavedObjectShortUrlStorage implements ShortUrlStorage {
const savedObject = result.saved_objects[0] as ShortUrlSavedObject;
- return createShortUrlData(savedObject);
+ return {
+ data: createShortUrlData
(savedObject),
+ references: savedObject.references,
+ };
}
public async exists(slug: string): Promise {
diff --git a/src/plugins/share/server/url_service/short_urls/types.ts b/src/plugins/share/server/url_service/short_urls/types.ts
index 7aab70ca49519..9a9d9006eb371 100644
--- a/src/plugins/share/server/url_service/short_urls/types.ts
+++ b/src/plugins/share/server/url_service/short_urls/types.ts
@@ -7,6 +7,7 @@
*/
import type { SerializableRecord } from '@kbn/utility-types';
+import { SavedObjectReference } from 'kibana/server';
import { ShortUrlData } from '../../../common/url_service/short_urls/types';
/**
@@ -17,20 +18,32 @@ export interface ShortUrlStorage {
* Create and store a new short URL entry.
*/
create(
- data: Omit, 'id'>
+ data: Omit, 'id'>,
+ options?: { references?: SavedObjectReference[] }
): Promise>;
+ /**
+ * Update an existing short URL entry.
+ */
+ update(
+ id: string,
+ data: Partial, 'id'>>,
+ options?: { references?: SavedObjectReference[] }
+ ): Promise;
+
/**
* Fetch a short URL entry by ID.
*/
- getById(id: string): Promise>;
+ getById(
+ id: string
+ ): Promise>;
/**
* Fetch a short URL entry by slug.
*/
getBySlug(
slug: string
- ): Promise>;
+ ): Promise>;
/**
* Checks if a short URL exists by slug.
@@ -42,3 +55,8 @@ export interface ShortUrlStorage {
*/
delete(id: string): Promise;
}
+
+export interface ShortUrlRecord {
+ data: ShortUrlData;
+ references: SavedObjectReference[];
+}
diff --git a/src/plugins/vis_types/pie/public/types/types.ts b/src/plugins/vis_types/pie/public/types/types.ts
index a1f41e80fae28..fb5efb5971805 100644
--- a/src/plugins/vis_types/pie/public/types/types.ts
+++ b/src/plugins/vis_types/pie/public/types/types.ts
@@ -8,7 +8,8 @@
import { Position } from '@elastic/charts';
import { UiCounterMetricType } from '@kbn/analytics';
-import { DatatableColumn, SerializedFieldFormat } from '../../../../expressions/public';
+import { DatatableColumn } from '../../../../expressions/public';
+import type { SerializedFieldFormat } from '../../../../field_formats/common';
import { ExpressionValueVisDimension } from '../../../../visualizations/public';
import { ExpressionValuePieLabels } from '../expression_functions/pie_labels';
import { PaletteOutput, ChartsPluginSetup } from '../../../../charts/public';
diff --git a/src/plugins/vis_types/pie/public/utils/get_layers.ts b/src/plugins/vis_types/pie/public/utils/get_layers.ts
index 6ecef858619b5..c9d8da15b78f6 100644
--- a/src/plugins/vis_types/pie/public/utils/get_layers.ts
+++ b/src/plugins/vis_types/pie/public/utils/get_layers.ts
@@ -133,7 +133,6 @@ export const getLayers = (
syncColors: boolean
): PartitionLayer[] => {
const fillLabel: Partial = {
- textInvertible: true,
valueFont: {
fontWeight: 700,
},
diff --git a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx
index d7b7bb14723d7..e6d2638bedf48 100644
--- a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx
+++ b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx
@@ -64,6 +64,8 @@ const DefaultYAxis = () => (
id="left"
domain={withStaticPadding({
fit: false,
+ min: NaN,
+ max: NaN,
})}
position={Position.Left}
groupId={`${MAIN_GROUP_ID}`}
diff --git a/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts b/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts
index 3c76b95bd05ca..98be5efc55a26 100644
--- a/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts
+++ b/src/plugins/vis_types/timelion/public/helpers/panel_utils.ts
@@ -88,8 +88,8 @@ const adaptYaxisParams = (yaxis: IAxis) => {
tickFormat: y.tickFormatter,
domain: withStaticPadding({
fit: y.min === undefined && y.max === undefined,
- min: y.min,
- max: y.max,
+ min: y.min ?? NaN,
+ max: y.max ?? NaN,
}),
};
};
@@ -118,6 +118,8 @@ export const extractAllYAxis = (series: Series[]) => {
groupId,
domain: withStaticPadding({
fit: false,
+ min: NaN,
+ max: NaN,
}),
id: (yaxis?.position || Position.Left) + index,
position: Position.Left,
diff --git a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js
index cfeed174307ac..13c17b8f4c38f 100644
--- a/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js
+++ b/src/plugins/vis_types/vega/public/data_model/vega_parser.test.js
@@ -81,6 +81,20 @@ describe(`VegaParser.parseAsync`, () => {
})
)
);
+
+ test(`should return a specific error in case of $schema URL not valid`, async () => {
+ const vp = new VegaParser({
+ $schema: 'https://vega.github.io/schema/vega-lite/v4.jsonanythingtobreakthis',
+ mark: 'circle',
+ encoding: { row: { field: 'a' } },
+ });
+
+ await vp.parseAsync();
+
+ expect(vp.error).toBe(
+ 'The URL for the JSON "$schema" is incorrect. Correct the URL, then click Update.'
+ );
+ });
});
describe(`VegaParser._setDefaultValue`, () => {
diff --git a/src/plugins/vis_types/vega/public/data_model/vega_parser.ts b/src/plugins/vis_types/vega/public/data_model/vega_parser.ts
index 9000fed7f6116..bf2a6be25c71a 100644
--- a/src/plugins/vis_types/vega/public/data_model/vega_parser.ts
+++ b/src/plugins/vis_types/vega/public/data_model/vega_parser.ts
@@ -553,25 +553,37 @@ The URL is an identifier only. Kibana and your browser will never access this UR
* @private
*/
private parseSchema(spec: VegaSpec) {
- const schema = schemaParser(spec.$schema);
- const isVegaLite = schema.library === 'vega-lite';
- const libVersion = isVegaLite ? vegaLiteVersion : vegaVersion;
+ try {
+ const schema = schemaParser(spec.$schema);
+ const isVegaLite = schema.library === 'vega-lite';
+ const libVersion = isVegaLite ? vegaLiteVersion : vegaVersion;
- if (versionCompare(schema.version, libVersion) > 0) {
- this._onWarning(
- i18n.translate('visTypeVega.vegaParser.notValidLibraryVersionForInputSpecWarningMessage', {
+ if (versionCompare(schema.version, libVersion) > 0) {
+ this._onWarning(
+ i18n.translate(
+ 'visTypeVega.vegaParser.notValidLibraryVersionForInputSpecWarningMessage',
+ {
+ defaultMessage:
+ 'The input spec uses {schemaLibrary} {schemaVersion}, but current version of {schemaLibrary} is {libraryVersion}.',
+ values: {
+ schemaLibrary: schema.library,
+ schemaVersion: schema.version,
+ libraryVersion: libVersion,
+ },
+ }
+ )
+ );
+ }
+
+ return { isVegaLite, libVersion };
+ } catch (e) {
+ throw Error(
+ i18n.translate('visTypeVega.vegaParser.notValidSchemaForInputSpec', {
defaultMessage:
- 'The input spec uses {schemaLibrary} {schemaVersion}, but current version of {schemaLibrary} is {libraryVersion}.',
- values: {
- schemaLibrary: schema.library,
- schemaVersion: schema.version,
- libraryVersion: libVersion,
- },
+ 'The URL for the JSON "$schema" is incorrect. Correct the URL, then click Update.',
})
);
}
-
- return { isVegaLite, libVersion };
}
/**
diff --git a/src/plugins/vis_types/xy/public/components/xy_settings.tsx b/src/plugins/vis_types/xy/public/components/xy_settings.tsx
index 5e02b65822d6c..74aff7535c2d8 100644
--- a/src/plugins/vis_types/xy/public/components/xy_settings.tsx
+++ b/src/plugins/vis_types/xy/public/components/xy_settings.tsx
@@ -71,7 +71,6 @@ function getValueLabelsStyling() {
return {
displayValue: {
fontSize: { min: VALUE_LABELS_MIN_FONTSIZE, max: VALUE_LABELS_MAX_FONTSIZE },
- fill: { textInverted: false, textContrast: true },
alignment: { horizontal: HorizontalAlignment.Center, vertical: VerticalAlignment.Middle },
},
};
diff --git a/src/plugins/vis_types/xy/public/config/get_axis.ts b/src/plugins/vis_types/xy/public/config/get_axis.ts
index b5cc96830e46a..09495725296cd 100644
--- a/src/plugins/vis_types/xy/public/config/get_axis.ts
+++ b/src/plugins/vis_types/xy/public/config/get_axis.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { identity, isNil } from 'lodash';
+import { identity } from 'lodash';
import { AxisSpec, TickFormatter, YDomainRange, ScaleType as ECScaleType } from '@elastic/charts';
@@ -171,17 +171,5 @@ function getAxisDomain(
const fit = defaultYExtents;
const padding = boundsMargin || undefined;
- if (!isNil(min) && !isNil(max)) {
- return { fit, padding, min, max };
- }
-
- if (!isNil(min)) {
- return { fit, padding, min };
- }
-
- if (!isNil(max)) {
- return { fit, padding, max };
- }
-
- return { fit, padding };
+ return { fit, padding, min: min ?? NaN, max: max ?? NaN };
}
diff --git a/src/plugins/vis_types/xy/public/utils/domain.ts b/src/plugins/vis_types/xy/public/utils/domain.ts
index fa8dd74e3942a..5b1310863979a 100644
--- a/src/plugins/vis_types/xy/public/utils/domain.ts
+++ b/src/plugins/vis_types/xy/public/utils/domain.ts
@@ -33,6 +33,8 @@ export const getXDomain = (params: Aspect['params']): DomainRange => {
return {
minInterval,
+ min: NaN,
+ max: NaN,
};
};
@@ -74,9 +76,9 @@ export const getAdjustedDomain = (
};
}
- return 'interval' in params
- ? {
- minInterval: params.interval,
- }
- : {};
+ return {
+ minInterval: 'interval' in params ? params.interval : undefined,
+ min: NaN,
+ max: NaN,
+ };
};
diff --git a/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts b/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts
index 5fe1b03dd8b93..c14e313b1e7a4 100644
--- a/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts
+++ b/src/plugins/vis_types/xy/public/utils/render_all_series.test.mocks.ts
@@ -112,7 +112,10 @@ export const getVisConfig = (): VisConfig => {
mode: AxisMode.Normal,
type: 'linear',
},
- domain: {},
+ domain: {
+ min: NaN,
+ max: NaN,
+ },
integersOnly: false,
},
],
@@ -246,7 +249,10 @@ export const getVisConfigMutipleYaxis = (): VisConfig => {
mode: AxisMode.Normal,
type: 'linear',
},
- domain: {},
+ domain: {
+ min: NaN,
+ max: NaN,
+ },
integersOnly: false,
},
],
@@ -435,7 +441,10 @@ export const getVisConfigPercentiles = (): VisConfig => {
mode: AxisMode.Normal,
type: 'linear',
},
- domain: {},
+ domain: {
+ min: NaN,
+ max: NaN,
+ },
integersOnly: false,
},
],
diff --git a/src/plugins/vis_types/xy/public/vis_component.tsx b/src/plugins/vis_types/xy/public/vis_component.tsx
index f4d566f49602e..515ad3e7eaf6f 100644
--- a/src/plugins/vis_types/xy/public/vis_component.tsx
+++ b/src/plugins/vis_types/xy/public/vis_component.tsx
@@ -19,6 +19,7 @@ import {
ScaleType,
AccessorFn,
Accessor,
+ XYBrushEvent,
} from '@elastic/charts';
import { compact } from 'lodash';
@@ -131,7 +132,10 @@ const VisComponent = (props: VisComponentProps) => {
): BrushEndListener | undefined => {
if (xAccessor !== null && isInterval) {
return (brushArea) => {
- const event = getBrushFromChartBrushEventFn(visData, xAccessor)(brushArea);
+ const event = getBrushFromChartBrushEventFn(
+ visData,
+ xAccessor
+ )(brushArea as XYBrushEvent);
props.fireEvent(event);
};
}
diff --git a/src/plugins/visualizations/common/expression_functions/xy_dimension.ts b/src/plugins/visualizations/common/expression_functions/xy_dimension.ts
index 82538fea8605a..5bbddd48e9b8b 100644
--- a/src/plugins/visualizations/common/expression_functions/xy_dimension.ts
+++ b/src/plugins/visualizations/common/expression_functions/xy_dimension.ts
@@ -14,8 +14,8 @@ import type {
ExpressionValueBoxed,
Datatable,
DatatableColumn,
- SerializedFieldFormat,
} from '../../../expressions/common';
+import type { SerializedFieldFormat } from '../../../field_formats/common';
export interface DateHistogramParams {
date: boolean;
diff --git a/src/plugins/visualizations/public/vis_schemas.ts b/src/plugins/visualizations/public/vis_schemas.ts
index 115e13ece45ff..f80f85fb55a60 100644
--- a/src/plugins/visualizations/public/vis_schemas.ts
+++ b/src/plugins/visualizations/public/vis_schemas.ts
@@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
-import { SerializedFieldFormat } from '../../expressions/public';
+import type { SerializedFieldFormat } from '../../field_formats/common';
import { IAggConfig, search } from '../../data/public';
import { Vis, VisToExpressionAstParams } from './types';
diff --git a/test/functional/apps/dashboard/dashboard_state.ts b/test/functional/apps/dashboard/dashboard_state.ts
index 45ba62749dd77..0cc0fa4806482 100644
--- a/test/functional/apps/dashboard/dashboard_state.ts
+++ b/test/functional/apps/dashboard/dashboard_state.ts
@@ -7,6 +7,7 @@
*/
import expect from '@kbn/expect';
+import chroma from 'chroma-js';
import { PIE_CHART_VIS_NAME, AREA_CHART_VIS_NAME } from '../../page_objects/dashboard_page';
import { DEFAULT_PANEL_WIDTH } from '../../../../src/plugins/dashboard/public/application/embeddable/dashboard_constants';
@@ -264,14 +265,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.try(async () => {
- const allPieSlicesColor = await pieChart.getAllPieSliceStyles('80,000');
- let whitePieSliceCounts = 0;
- allPieSlicesColor.forEach((style) => {
- if (style.indexOf('rgb(255, 255, 255)') > -1) {
- whitePieSliceCounts++;
- }
- });
-
+ const allPieSlicesColor = await pieChart.getAllPieSliceColor('80,000');
+ const whitePieSliceCounts = allPieSlicesColor.reduce((count, color) => {
+ // converting the color to a common format, testing the color, not the string format
+ return chroma(color).hex().toUpperCase() === '#FFFFFF' ? count + 1 : count;
+ }, 0);
expect(whitePieSliceCounts).to.be(1);
});
});
diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts
index d2e4091f93577..b0e9e21d07b0b 100644
--- a/test/functional/page_objects/visualize_chart_page.ts
+++ b/test/functional/page_objects/visualize_chart_page.ts
@@ -7,7 +7,7 @@
*/
import { Position } from '@elastic/charts';
-import Color from 'color';
+import chroma from 'chroma-js';
import { FtrService } from '../ftr_provider_context';
@@ -181,17 +181,17 @@ export class VisualizeChartPageObject extends FtrService {
return items.some(({ color: c }) => c === color);
}
- public async doesSelectedLegendColorExistForPie(color: string) {
+ public async doesSelectedLegendColorExistForPie(matchingColor: string) {
if (await this.isNewLibraryChart(pieChartSelector)) {
+ const hexMatchingColor = chroma(matchingColor).hex().toUpperCase();
const slices =
(await this.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ?? [];
- return slices.some(({ color: c }) => {
- const rgbColor = new Color(color).rgb().toString();
- return c === rgbColor;
+ return slices.some(({ color }) => {
+ return hexMatchingColor === chroma(color).hex().toUpperCase();
});
}
- return await this.testSubjects.exists(`legendSelectedColor-${color}`);
+ return await this.testSubjects.exists(`legendSelectedColor-${matchingColor}`);
}
public async expectError() {
diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts
index 7c925318f0211..ff0c24e2830cf 100644
--- a/test/functional/services/visualizations/pie_chart.ts
+++ b/test/functional/services/visualizations/pie_chart.ts
@@ -7,6 +7,7 @@
*/
import expect from '@kbn/expect';
+import { isNil } from 'lodash';
import { FtrService } from '../../ftr_provider_context';
const pieChartSelector = 'visTypePieChart';
@@ -100,8 +101,8 @@ export class PieChartService extends FtrService {
return await pieSlice.getAttribute('style');
}
- async getAllPieSliceStyles(name: string) {
- this.log.debug(`VisualizePage.getAllPieSliceStyles(${name})`);
+ async getAllPieSliceColor(name: string) {
+ this.log.debug(`VisualizePage.getAllPieSliceColor(${name})`);
if (await this.visChart.isNewLibraryChart(pieChartSelector)) {
const slices =
(await this.visChart.getEsChartDebugState(pieChartSelector))?.partition?.[0]?.partitions ??
@@ -112,9 +113,22 @@ export class PieChartService extends FtrService {
return selectedSlice.map((slice) => slice.color);
}
const pieSlices = await this.getAllPieSlices(name);
- return await Promise.all(
+ const slicesStyles = await Promise.all(
pieSlices.map(async (pieSlice) => await pieSlice.getAttribute('style'))
);
+ return slicesStyles
+ .map(
+ (styles) =>
+ styles.split(';').reduce>((styleAsObj, style) => {
+ const stylePair = style.split(':');
+ if (stylePair.length !== 2) {
+ return styleAsObj;
+ }
+ styleAsObj[stylePair[0].trim()] = stylePair[1].trim();
+ return styleAsObj;
+ }, {}).fill // in vislib the color is available on the `fill` style prop
+ )
+ .filter((d) => !isNil(d));
}
async getPieChartData() {
diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md
index b19e89a599840..0d66c9d30f8b9 100644
--- a/x-pack/plugins/actions/README.md
+++ b/x-pack/plugins/actions/README.md
@@ -33,29 +33,36 @@ Table of Contents
- [actionsClient.execute(options)](#actionsclientexecuteoptions)
- [Example](#example-2)
- [Built-in Action Types](#built-in-action-types)
- - [ServiceNow](#servicenow)
+ - [ServiceNow ITSM](#servicenow-itsm)
- [`params`](#params)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice)
- [`subActionParams (getFields)`](#subactionparams-getfields)
- [`subActionParams (getIncident)`](#subactionparams-getincident)
- [`subActionParams (getChoices)`](#subactionparams-getchoices)
- - [Jira](#jira)
+ - [ServiceNow Sec Ops](#servicenow-sec-ops)
- [`params`](#params-1)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1)
+ - [`subActionParams (getFields)`](#subactionparams-getfields-1)
- [`subActionParams (getIncident)`](#subactionparams-getincident-1)
+ - [`subActionParams (getChoices)`](#subactionparams-getchoices-1)
+ - [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-)
+ - [Jira](#jira)
+ - [`params`](#params-2)
+ - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
+ - [`subActionParams (getIncident)`](#subactionparams-getincident-2)
- [`subActionParams (issueTypes)`](#subactionparams-issuetypes)
- [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype)
- [`subActionParams (issues)`](#subactionparams-issues)
- [`subActionParams (issue)`](#subactionparams-issue)
- - [`subActionParams (getFields)`](#subactionparams-getfields-1)
- - [IBM Resilient](#ibm-resilient)
- - [`params`](#params-2)
- - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
- [`subActionParams (getFields)`](#subactionparams-getfields-2)
+ - [IBM Resilient](#ibm-resilient)
+ - [`params`](#params-3)
+ - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3)
+ - [`subActionParams (getFields)`](#subactionparams-getfields-3)
- [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes)
- [`subActionParams (severity)`](#subactionparams-severity)
- [Swimlane](#swimlane)
- - [`params`](#params-3)
+ - [`params`](#params-4)
- [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-)
- [Command Line Utility](#command-line-utility)
- [Developing New Action Types](#developing-new-action-types)
@@ -246,9 +253,9 @@ Kibana ships with a set of built-in action types. See [Actions and connector typ
In addition to the documented configurations, several built in action type offer additional `params` configurations.
-## ServiceNow
+## ServiceNow ITSM
-The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
+The [ServiceNow ITSM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
### `params`
| Property | Description | Type |
@@ -265,16 +272,18 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib
The following table describes the properties of the `incident` object.
-| Property | Description | Type |
-| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
-| short_description | The title of the incident. | string |
-| description | The description of the incident. | string _(optional)_ |
-| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
-| severity | The severity in ServiceNow. | string _(optional)_ |
-| urgency | The urgency in ServiceNow. | string _(optional)_ |
-| impact | The impact in ServiceNow. | string _(optional)_ |
-| category | The category in ServiceNow. | string _(optional)_ |
-| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
+| Property | Description | Type |
+| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
+| short_description | The title of the incident. | string |
+| description | The description of the incident. | string _(optional)_ |
+| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
+| severity | The severity in ServiceNow. | string _(optional)_ |
+| urgency | The urgency in ServiceNow. | string _(optional)_ |
+| impact | The impact in ServiceNow. | string _(optional)_ |
+| category | The category in ServiceNow. | string _(optional)_ |
+| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
+| correlation_id | The correlation id of the incident. | string _(optional)_ |
+| correlation_display | The correlation display of the ServiceNow. | string _(optional)_ |
#### `subActionParams (getFields)`
@@ -289,12 +298,64 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`.
#### `subActionParams (getChoices)`
-| Property | Description | Type |
-| -------- | ------------------------------------------------------------ | -------- |
-| fields | An array of fields. Example: `[priority, category, impact]`. | string[] |
+| Property | Description | Type |
+| -------- | -------------------------------------------------- | -------- |
+| fields | An array of fields. Example: `[category, impact]`. | string[] |
---
+## ServiceNow Sec Ops
+
+The [ServiceNow SecOps user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-sir-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
+
+### `params`
+
+| Property | Description | Type |
+| --------------- | -------------------------------------------------------------------------------------------------- | ------ |
+| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices`. | string |
+| subActionParams | The parameters of the subaction. | object |
+
+#### `subActionParams (pushToService)`
+
+| Property | Description | Type |
+| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- |
+| incident | The ServiceNow security incident. | object |
+| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ |
+
+The following table describes the properties of the `incident` object.
+
+| Property | Description | Type |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
+| short_description | The title of the security incident. | string |
+| description | The description of the security incident. | string _(optional)_ |
+| externalId | The ID of the security incident in ServiceNow. If present, the security incident is updated. Otherwise, a new security incident is created. | string _(optional)_ |
+| priority | The priority in ServiceNow. | string _(optional)_ |
+| dest_ip | A list of destination IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
+| source_ip | A list of source IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
+| malware_hash | A list of malware hashes related to the security incident. The hashes will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
+| malware_url | A list of malware URLs related to the security incident. The URLs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
+| category | The category in ServiceNow. | string _(optional)_ |
+| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
+| correlation_id | The correlation id of the security incident. | string _(optional)_ |
+| correlation_display | The correlation display of the security incident. | string _(optional)_ |
+
+#### `subActionParams (getFields)`
+
+No parameters for the `getFields` subaction. Provide an empty object `{}`.
+
+#### `subActionParams (getIncident)`
+
+| Property | Description | Type |
+| ---------- | ---------------------------------------------- | ------ |
+| externalId | The ID of the security incident in ServiceNow. | string |
+
+
+#### `subActionParams (getChoices)`
+
+| Property | Description | Type |
+| -------- | ---------------------------------------------------- | -------- |
+| fields | An array of fields. Example: `[priority, category]`. | string[] |
+---
## Jira
The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/master/jira-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
diff --git a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts
index 5d83b658111e4..7710ff79d08b4 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/pagerduty.ts
@@ -143,7 +143,7 @@ export function getActionType({
}),
validate: {
config: schema.object(configSchemaProps, {
- validate: curry(valdiateActionTypeConfig)(configurationUtilities),
+ validate: curry(validateActionTypeConfig)(configurationUtilities),
}),
secrets: SecretsSchema,
params: ParamsSchema,
@@ -152,7 +152,7 @@ export function getActionType({
};
}
-function valdiateActionTypeConfig(
+function validateActionTypeConfig(
configurationUtilities: ActionsConfigurationUtilities,
configObject: ActionTypeConfigType
) {
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
index 8d24e48d4d515..e1f66263729e2 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts
@@ -25,6 +25,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -57,6 +58,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -78,6 +80,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
+ config: {},
secrets: { username: 'elastic', password: 'elastic' },
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -93,6 +96,9 @@ describe('api', () => {
caller_id: 'elastic',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
+ opened_by: 'elastic',
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
@@ -103,6 +109,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -118,6 +125,8 @@ describe('api', () => {
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@@ -132,6 +141,8 @@ describe('api', () => {
comments: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@@ -142,6 +153,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
@@ -157,6 +169,8 @@ describe('api', () => {
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@@ -171,6 +185,8 @@ describe('api', () => {
work_notes: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@@ -182,6 +198,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params: apiParams,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -210,6 +227,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -228,6 +246,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -243,6 +262,8 @@ describe('api', () => {
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
@@ -253,6 +274,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@@ -267,6 +289,8 @@ describe('api', () => {
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-3',
});
@@ -281,6 +305,8 @@ describe('api', () => {
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-2',
});
@@ -291,6 +317,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
+ config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
@@ -305,6 +332,8 @@ describe('api', () => {
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-3',
});
@@ -319,6 +348,8 @@ describe('api', () => {
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
+ correlation_display: 'Alerting',
+ correlation_id: 'ruleId',
},
incidentId: 'incident-2',
});
@@ -344,4 +375,23 @@ describe('api', () => {
expect(res).toEqual(serviceNowChoices);
});
});
+
+ describe('getIncident', () => {
+ test('it gets the incident correctly', async () => {
+ const res = await api.getIncident({
+ externalService,
+ params: {
+ externalId: 'incident-1',
+ },
+ });
+ expect(res).toEqual({
+ description: 'description from servicenow',
+ id: 'incident-1',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ short_description: 'title from servicenow',
+ title: 'INC01',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
index 4120c07c32303..88cdfd069cf1b 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts
@@ -6,7 +6,7 @@
*/
import {
- ExternalServiceApi,
+ ExternalServiceAPI,
GetChoicesHandlerArgs,
GetChoicesResponse,
GetCommonFieldsHandlerArgs,
@@ -19,7 +19,11 @@ import {
} from './types';
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
-const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {};
+const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {
+ const { externalId: id } = params;
+ const res = await externalService.getIncident(id);
+ return res;
+};
const pushToServiceHandler = async ({
externalService,
@@ -42,6 +46,7 @@ const pushToServiceHandler = async ({
incident: {
...incident,
caller_id: secrets.username,
+ opened_by: secrets.username,
},
});
}
@@ -84,7 +89,7 @@ const getChoicesHandler = async ({
return res;
};
-export const api: ExternalServiceApi = {
+export const api: ExternalServiceAPI = {
getChoices: getChoicesHandler,
getFields: getFieldsHandler,
getIncident: getIncidentHandler,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts
new file mode 100644
index 0000000000000..358af7cd2e9ef
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.test.ts
@@ -0,0 +1,286 @@
+/*
+ * 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 { Logger } from '../../../../../../src/core/server';
+import { externalServiceSIRMock, sirParams } from './mocks';
+import { ExternalServiceSIR, ObservableTypes } from './types';
+import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir';
+let mockedLogger: jest.Mocked;
+
+describe('api_sir', () => {
+ let externalService: jest.Mocked;
+
+ beforeEach(() => {
+ externalService = externalServiceSIRMock.create();
+ jest.clearAllMocks();
+ });
+
+ describe('combineObservables', () => {
+ test('it returns an empty array when both arguments are an empty array', async () => {
+ expect(combineObservables([], [])).toEqual([]);
+ });
+
+ test('it returns an empty array when both arguments are an empty string', async () => {
+ expect(combineObservables('', '')).toEqual([]);
+ });
+
+ test('it returns an empty array when a="" and b=[]', async () => {
+ expect(combineObservables('', [])).toEqual([]);
+ });
+
+ test('it returns an empty array when a=[] and b=""', async () => {
+ expect(combineObservables([], '')).toEqual([]);
+ });
+
+ test('it returns a if b is empty', async () => {
+ expect(combineObservables('a', '')).toEqual(['a']);
+ });
+
+ test('it returns b if a is empty', async () => {
+ expect(combineObservables([], ['b'])).toEqual(['b']);
+ });
+
+ test('it combines two strings', async () => {
+ expect(combineObservables('a,b', 'c,d')).toEqual(['a', 'b', 'c', 'd']);
+ });
+
+ test('it combines two arrays', async () => {
+ expect(combineObservables(['a'], ['b'])).toEqual(['a', 'b']);
+ });
+
+ test('it combines a string with an array', async () => {
+ expect(combineObservables('a', ['b'])).toEqual(['a', 'b']);
+ });
+
+ test('it combines an array with a string ', async () => {
+ expect(combineObservables(['a'], 'b')).toEqual(['a', 'b']);
+ });
+
+ test('it combines a "," concatenated string', async () => {
+ expect(combineObservables(['a'], 'b,c,d')).toEqual(['a', 'b', 'c', 'd']);
+ expect(combineObservables('b,c,d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
+ });
+
+ test('it combines a "|" concatenated string', async () => {
+ expect(combineObservables(['a'], 'b|c|d')).toEqual(['a', 'b', 'c', 'd']);
+ expect(combineObservables('b|c|d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
+ });
+
+ test('it combines a space concatenated string', async () => {
+ expect(combineObservables(['a'], 'b c d')).toEqual(['a', 'b', 'c', 'd']);
+ expect(combineObservables('b c d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
+ });
+
+ test('it combines a "\\n" concatenated string', async () => {
+ expect(combineObservables(['a'], 'b\nc\nd')).toEqual(['a', 'b', 'c', 'd']);
+ expect(combineObservables('b\nc\nd', ['a'])).toEqual(['b', 'c', 'd', 'a']);
+ });
+
+ test('it combines a "\\r" concatenated string', async () => {
+ expect(combineObservables(['a'], 'b\rc\rd')).toEqual(['a', 'b', 'c', 'd']);
+ expect(combineObservables('b\rc\rd', ['a'])).toEqual(['b', 'c', 'd', 'a']);
+ });
+
+ test('it combines a "\\t" concatenated string', async () => {
+ expect(combineObservables(['a'], 'b\tc\td')).toEqual(['a', 'b', 'c', 'd']);
+ expect(combineObservables('b\tc\td', ['a'])).toEqual(['b', 'c', 'd', 'a']);
+ });
+
+ test('it combines two strings with different delimiter', async () => {
+ expect(combineObservables('a|b|c', 'd e f')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']);
+ });
+ });
+
+ describe('formatObservables', () => {
+ test('it formats array observables correctly', async () => {
+ const expectedTypes: Array<[ObservableTypes, string]> = [
+ [ObservableTypes.ip4, 'ipv4-addr'],
+ [ObservableTypes.sha256, 'SHA256'],
+ [ObservableTypes.url, 'URL'],
+ ];
+
+ for (const type of expectedTypes) {
+ expect(formatObservables(['a', 'b', 'c'], type[0])).toEqual([
+ { type: type[1], value: 'a' },
+ { type: type[1], value: 'b' },
+ { type: type[1], value: 'c' },
+ ]);
+ }
+ });
+
+ test('it removes duplicates from array observables correctly', async () => {
+ expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([
+ { type: 'ipv4-addr', value: 'a' },
+ { type: 'ipv4-addr', value: 'c' },
+ ]);
+ });
+
+ test('it formats an empty array correctly', async () => {
+ expect(formatObservables([], ObservableTypes.ip4)).toEqual([]);
+ });
+
+ test('it removes empty observables correctly', async () => {
+ expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([
+ { type: 'ipv4-addr', value: 'a' },
+ { type: 'ipv4-addr', value: 'c' },
+ ]);
+ });
+ });
+
+ describe('prepareParams', () => {
+ test('it prepares the params correctly when the connector is legacy', async () => {
+ expect(prepareParams(true, sirParams)).toEqual({
+ ...sirParams,
+ incident: {
+ ...sirParams.incident,
+ dest_ip: '192.168.1.1,192.168.1.3',
+ source_ip: '192.168.1.2,192.168.1.4',
+ malware_hash: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
+ malware_url: 'https://example.com',
+ },
+ });
+ });
+
+ test('it prepares the params correctly when the connector is not legacy', async () => {
+ expect(prepareParams(false, sirParams)).toEqual({
+ ...sirParams,
+ incident: {
+ ...sirParams.incident,
+ dest_ip: null,
+ source_ip: null,
+ malware_hash: null,
+ malware_url: null,
+ },
+ });
+ });
+
+ test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => {
+ const {
+ dest_ip: destIp,
+ source_ip: sourceIp,
+ malware_hash: malwareHash,
+ malware_url: malwareURL,
+ ...incidentWithoutObservables
+ } = sirParams.incident;
+
+ expect(
+ prepareParams(true, {
+ ...sirParams,
+ // @ts-expect-error
+ incident: incidentWithoutObservables,
+ })
+ ).toEqual({
+ ...sirParams,
+ incident: {
+ ...sirParams.incident,
+ dest_ip: null,
+ source_ip: null,
+ malware_hash: null,
+ malware_url: null,
+ },
+ });
+ });
+ });
+
+ describe('pushToService', () => {
+ test('it creates an incident correctly', async () => {
+ const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
+ const res = await apiSIR.pushToService({
+ externalService,
+ params,
+ config: { isLegacy: false },
+ secrets: {},
+ logger: mockedLogger,
+ commentFieldKey: 'work_notes',
+ });
+
+ expect(res).toEqual({
+ id: 'incident-1',
+ title: 'INC01',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
+ comments: [
+ {
+ commentId: 'case-comment-1',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ },
+ {
+ commentId: 'case-comment-2',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ },
+ ],
+ });
+ });
+
+ test('it adds observables correctly', async () => {
+ const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
+ await apiSIR.pushToService({
+ externalService,
+ params,
+ config: { isLegacy: false },
+ secrets: {},
+ logger: mockedLogger,
+ commentFieldKey: 'work_notes',
+ });
+
+ expect(externalService.bulkAddObservableToIncident).toHaveBeenCalledWith(
+ [
+ { type: 'ipv4-addr', value: '192.168.1.1' },
+ { type: 'ipv4-addr', value: '192.168.1.3' },
+ { type: 'ipv4-addr', value: '192.168.1.2' },
+ { type: 'ipv4-addr', value: '192.168.1.4' },
+ {
+ type: 'SHA256',
+ value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
+ },
+ { type: 'URL', value: 'https://example.com' },
+ ],
+ // createIncident mock returns this incident id
+ 'incident-1'
+ );
+ });
+
+ test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => {
+ const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
+ await apiSIR.pushToService({
+ externalService,
+ params,
+ config: { isLegacy: true },
+ secrets: {},
+ logger: mockedLogger,
+ commentFieldKey: 'work_notes',
+ });
+
+ expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled();
+ });
+
+ test('it does not call bulkAddObservableToIncident if there are no observables', async () => {
+ const params = {
+ ...sirParams,
+ incident: {
+ ...sirParams.incident,
+ dest_ip: null,
+ source_ip: null,
+ malware_hash: null,
+ malware_url: null,
+ externalId: null,
+ },
+ };
+
+ await apiSIR.pushToService({
+ externalService,
+ params,
+ config: { isLegacy: false },
+ secrets: {},
+ logger: mockedLogger,
+ commentFieldKey: 'work_notes',
+ });
+
+ expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts
new file mode 100644
index 0000000000000..326bb79a0e708
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api_sir.ts
@@ -0,0 +1,154 @@
+/*
+ * 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 { isEmpty, isString } from 'lodash';
+
+import {
+ ExecutorSubActionPushParamsSIR,
+ ExternalServiceAPI,
+ ExternalServiceSIR,
+ ObservableTypes,
+ PushToServiceApiHandlerArgs,
+ PushToServiceApiParamsSIR,
+ PushToServiceResponse,
+} from './types';
+
+import { api } from './api';
+
+const SPLIT_REGEX = /[ ,|\r\n\t]+/;
+
+export const formatObservables = (observables: string[], type: ObservableTypes) => {
+ /**
+ * ServiceNow accepted formats are: comma, new line, tab, or pipe separators.
+ * Before the application the observables were being sent to ServiceNow as a concatenated string with
+ * delimiter. With the application the format changed to an array of observables.
+ */
+ const uniqueObservables = new Set(observables);
+ return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type }));
+};
+
+const obsAsArray = (obs: string | string[]): string[] => {
+ if (isEmpty(obs)) {
+ return [];
+ }
+
+ if (isString(obs)) {
+ return obs.split(SPLIT_REGEX);
+ }
+
+ return obs;
+};
+
+export const combineObservables = (a: string | string[], b: string | string[]): string[] => {
+ const first = obsAsArray(a);
+ const second = obsAsArray(b);
+
+ return [...first, ...second];
+};
+
+const observablesToString = (obs: string | string[] | null | undefined): string | null => {
+ if (Array.isArray(obs)) {
+ return obs.join(',');
+ }
+
+ return obs ?? null;
+};
+
+export const prepareParams = (
+ isLegacy: boolean,
+ params: PushToServiceApiParamsSIR
+): PushToServiceApiParamsSIR => {
+ if (isLegacy) {
+ /**
+ * The schema has change to accept an array of observables
+ * or a string. In the case of a legacy connector we need to
+ * convert the observables to a string
+ */
+ return {
+ ...params,
+ incident: {
+ ...params.incident,
+ dest_ip: observablesToString(params.incident.dest_ip),
+ malware_hash: observablesToString(params.incident.malware_hash),
+ malware_url: observablesToString(params.incident.malware_url),
+ source_ip: observablesToString(params.incident.source_ip),
+ },
+ };
+ }
+
+ /**
+ * For non legacy connectors the observables
+ * will be added in a different call.
+ * They need to be set to null when sending the fields
+ * to ServiceNow
+ */
+ return {
+ ...params,
+ incident: {
+ ...params.incident,
+ dest_ip: null,
+ malware_hash: null,
+ malware_url: null,
+ source_ip: null,
+ },
+ };
+};
+
+const pushToServiceHandler = async ({
+ externalService,
+ params,
+ config,
+ secrets,
+ commentFieldKey,
+ logger,
+}: PushToServiceApiHandlerArgs): Promise => {
+ const res = await api.pushToService({
+ externalService,
+ params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR),
+ config,
+ secrets,
+ commentFieldKey,
+ logger,
+ });
+
+ const {
+ incident: {
+ dest_ip: destIP,
+ malware_hash: malwareHash,
+ malware_url: malwareUrl,
+ source_ip: sourceIP,
+ },
+ } = params as ExecutorSubActionPushParamsSIR;
+
+ /**
+ * Add bulk observables is only available for new connectors
+ * Old connectors gonna add their observables
+ * through the pushToService call.
+ */
+
+ if (!config.isLegacy) {
+ const sirExternalService = externalService as ExternalServiceSIR;
+
+ const obsWithType: Array<[string[], ObservableTypes]> = [
+ [combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4],
+ [obsAsArray(malwareHash ?? []), ObservableTypes.sha256],
+ [obsAsArray(malwareUrl ?? []), ObservableTypes.url],
+ ];
+
+ const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat();
+ if (observables.length > 0) {
+ await sirExternalService.bulkAddObservableToIncident(observables, res.id);
+ }
+ }
+
+ return res;
+};
+
+export const apiSIR: ExternalServiceAPI = {
+ ...api,
+ pushToService: pushToServiceHandler,
+};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts
new file mode 100644
index 0000000000000..babd360cbcb82
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.test.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 { snExternalServiceConfig } from './config';
+
+/**
+ * The purpose of this test is to
+ * prevent developers from accidentally
+ * change important configuration values
+ * such as the scope or the import set table
+ * of our ServiceNow application
+ */
+
+describe('config', () => {
+ test('ITSM: the config are correct', async () => {
+ const snConfig = snExternalServiceConfig['.servicenow'];
+ expect(snConfig).toEqual({
+ importSetTable: 'x_elas2_inc_int_elastic_incident',
+ appScope: 'x_elas2_inc_int',
+ table: 'incident',
+ useImportAPI: true,
+ commentFieldKey: 'work_notes',
+ });
+ });
+
+ test('SIR: the config are correct', async () => {
+ const snConfig = snExternalServiceConfig['.servicenow-sir'];
+ expect(snConfig).toEqual({
+ importSetTable: 'x_elas2_sir_int_elastic_si_incident',
+ appScope: 'x_elas2_sir_int',
+ table: 'sn_si_incident',
+ useImportAPI: true,
+ commentFieldKey: 'work_notes',
+ });
+ });
+});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts
new file mode 100644
index 0000000000000..37e4c6994b403
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/config.ts
@@ -0,0 +1,37 @@
+/*
+ * 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 {
+ ENABLE_NEW_SN_ITSM_CONNECTOR,
+ ENABLE_NEW_SN_SIR_CONNECTOR,
+} from '../../constants/connectors';
+import { SNProductsConfig } from './types';
+
+export const serviceNowITSMTable = 'incident';
+export const serviceNowSIRTable = 'sn_si_incident';
+
+export const ServiceNowITSMActionTypeId = '.servicenow';
+export const ServiceNowSIRActionTypeId = '.servicenow-sir';
+
+export const snExternalServiceConfig: SNProductsConfig = {
+ '.servicenow': {
+ importSetTable: 'x_elas2_inc_int_elastic_incident',
+ appScope: 'x_elas2_inc_int',
+ table: 'incident',
+ useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR,
+ commentFieldKey: 'work_notes',
+ },
+ '.servicenow-sir': {
+ importSetTable: 'x_elas2_sir_int_elastic_si_incident',
+ appScope: 'x_elas2_sir_int',
+ table: 'sn_si_incident',
+ useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR,
+ commentFieldKey: 'work_notes',
+ },
+};
+
+export const FIELD_PREFIX = 'u_';
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
index f2b500df6ccb3..29907381d45da 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts
@@ -18,7 +18,7 @@ import {
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
import { createExternalService } from './service';
-import { api } from './api';
+import { api as commonAPI } from './api';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
import {
@@ -30,7 +30,25 @@ import {
ExecutorSubActionCommonFieldsParams,
ServiceNowExecutorResultData,
ExecutorSubActionGetChoicesParams,
+ ServiceFactory,
+ ExternalServiceAPI,
} from './types';
+import {
+ ServiceNowITSMActionTypeId,
+ serviceNowITSMTable,
+ ServiceNowSIRActionTypeId,
+ serviceNowSIRTable,
+ snExternalServiceConfig,
+} from './config';
+import { createExternalServiceSIR } from './service_sir';
+import { apiSIR } from './api_sir';
+
+export {
+ ServiceNowITSMActionTypeId,
+ serviceNowITSMTable,
+ ServiceNowSIRActionTypeId,
+ serviceNowSIRTable,
+};
export type ActionParamsType =
| TypeOf
@@ -41,12 +59,6 @@ interface GetActionTypeParams {
configurationUtilities: ActionsConfigurationUtilities;
}
-const serviceNowITSMTable = 'incident';
-const serviceNowSIRTable = 'sn_si_incident';
-
-export const ServiceNowITSMActionTypeId = '.servicenow';
-export const ServiceNowSIRActionTypeId = '.servicenow-sir';
-
export type ServiceNowActionType = ActionType<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
@@ -79,8 +91,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic
executor: curry(executor)({
logger,
configurationUtilities,
- table: serviceNowITSMTable,
- commentFieldKey: 'work_notes',
+ actionTypeId: ServiceNowITSMActionTypeId,
+ createService: createExternalService,
+ api: commonAPI,
}),
};
}
@@ -103,8 +116,9 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service
executor: curry(executor)({
logger,
configurationUtilities,
- table: serviceNowSIRTable,
- commentFieldKey: 'work_notes',
+ actionTypeId: ServiceNowSIRActionTypeId,
+ createService: createExternalServiceSIR,
+ api: apiSIR,
}),
};
}
@@ -115,28 +129,31 @@ async function executor(
{
logger,
configurationUtilities,
- table,
- commentFieldKey = 'comments',
+ actionTypeId,
+ createService,
+ api,
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
- table: string;
- commentFieldKey?: string;
+ actionTypeId: string;
+ createService: ServiceFactory;
+ api: ExternalServiceAPI;
},
execOptions: ServiceNowActionTypeExecutorOptions
): Promise> {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params;
+ const externalServiceConfig = snExternalServiceConfig[actionTypeId];
let data: ServiceNowExecutorResultData | null = null;
- const externalService = createExternalService(
- table,
+ const externalService = createService(
{
config,
secrets,
},
logger,
- configurationUtilities
+ configurationUtilities,
+ externalServiceConfig
);
if (!api[subAction]) {
@@ -156,9 +173,10 @@ async function executor(
data = await api.pushToService({
externalService,
params: pushToServiceParams,
+ config,
secrets,
logger,
- commentFieldKey,
+ commentFieldKey: externalServiceConfig.commentFieldKey,
});
logger.debug(`response push to service for incident id: ${data.id}`);
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
index 909200472be33..3629fb33915ae 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/mocks.ts
@@ -5,7 +5,14 @@
* 2.0.
*/
-import { ExternalService, ExecutorSubActionPushParams } from './types';
+import {
+ ExternalService,
+ ExecutorSubActionPushParams,
+ PushToServiceApiParamsSIR,
+ ExternalServiceSIR,
+ Observable,
+ ObservableTypes,
+} from './types';
export const serviceNowCommonFields = [
{
@@ -74,6 +81,10 @@ const createMock = (): jest.Mocked => {
getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)),
getIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
+ id: 'incident-1',
+ title: 'INC01',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
short_description: 'title from servicenow',
description: 'description from servicenow',
})
@@ -95,16 +106,60 @@ const createMock = (): jest.Mocked => {
})
),
findIncidents: jest.fn(),
+ getApplicationInformation: jest.fn().mockImplementation(() =>
+ Promise.resolve({
+ name: 'Elastic',
+ scope: 'x_elas2_inc_int',
+ version: '1.0.0',
+ })
+ ),
+ checkIfApplicationIsInstalled: jest.fn(),
+ getUrl: jest.fn().mockImplementation(() => 'https://instance.service-now.com'),
+ checkInstance: jest.fn(),
};
return service;
};
-const externalServiceMock = {
+const createSIRMock = (): jest.Mocked => {
+ const service = {
+ ...createMock(),
+ addObservableToIncident: jest.fn().mockImplementation(() =>
+ Promise.resolve({
+ value: 'https://example.com',
+ observable_sys_id: '3',
+ })
+ ),
+ bulkAddObservableToIncident: jest.fn().mockImplementation(() =>
+ Promise.resolve([
+ {
+ value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
+ observable_sys_id: '1',
+ },
+ {
+ value: '127.0.0.1',
+ observable_sys_id: '2',
+ },
+ {
+ value: 'https://example.com',
+ observable_sys_id: '3',
+ },
+ ])
+ ),
+ };
+
+ return service;
+};
+
+export const externalServiceMock = {
create: createMock,
};
-const executorParams: ExecutorSubActionPushParams = {
+export const externalServiceSIRMock = {
+ create: createSIRMock,
+};
+
+export const executorParams: ExecutorSubActionPushParams = {
incident: {
externalId: 'incident-3',
short_description: 'Incident title',
@@ -114,6 +169,8 @@ const executorParams: ExecutorSubActionPushParams = {
impact: '3',
category: 'software',
subcategory: 'os',
+ correlation_id: 'ruleId',
+ correlation_display: 'Alerting',
},
comments: [
{
@@ -127,6 +184,46 @@ const executorParams: ExecutorSubActionPushParams = {
],
};
-const apiParams = executorParams;
+export const sirParams: PushToServiceApiParamsSIR = {
+ incident: {
+ externalId: 'incident-3',
+ short_description: 'Incident title',
+ description: 'Incident description',
+ dest_ip: ['192.168.1.1', '192.168.1.3'],
+ source_ip: ['192.168.1.2', '192.168.1.4'],
+ malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'],
+ malware_url: ['https://example.com'],
+ category: 'software',
+ subcategory: 'os',
+ correlation_id: 'ruleId',
+ correlation_display: 'Alerting',
+ priority: '1',
+ },
+ comments: [
+ {
+ commentId: 'case-comment-1',
+ comment: 'A comment',
+ },
+ {
+ commentId: 'case-comment-2',
+ comment: 'Another comment',
+ },
+ ],
+};
+
+export const observables: Observable[] = [
+ {
+ value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
+ type: ObservableTypes.sha256,
+ },
+ {
+ value: '127.0.0.1',
+ type: ObservableTypes.ip4,
+ },
+ {
+ value: 'https://example.com',
+ type: ObservableTypes.url,
+ },
+];
-export { externalServiceMock, executorParams, apiParams };
+export const apiParams = executorParams;
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
index 6fec30803d6d7..dab68bb9d3e9d 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts
@@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
+ isLegacy: schema.boolean({ defaultValue: false }),
};
export const ExternalIncidentServiceConfigurationSchema = schema.object(
@@ -39,6 +40,8 @@ const CommonAttributes = {
externalId: schema.nullable(schema.string()),
category: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
+ correlation_id: schema.nullable(schema.string()),
+ correlation_display: schema.nullable(schema.string()),
};
// Schema for ServiceNow Incident Management (ITSM)
@@ -56,10 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({
export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
incident: schema.object({
...CommonAttributes,
- dest_ip: schema.nullable(schema.string()),
- malware_hash: schema.nullable(schema.string()),
- malware_url: schema.nullable(schema.string()),
- source_ip: schema.nullable(schema.string()),
+ dest_ip: schema.oneOf(
+ [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
+ { defaultValue: null }
+ ),
+ malware_hash: schema.oneOf(
+ [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
+ { defaultValue: null }
+ ),
+ malware_url: schema.oneOf(
+ [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
+ { defaultValue: null }
+ ),
+ source_ip: schema.oneOf(
+ [schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
+ { defaultValue: null }
+ ),
priority: schema.nullable(schema.string()),
}),
comments: CommentsSchema,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts
index 37bfb662508a2..b8499b01e6a02 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.test.ts
@@ -5,15 +5,16 @@
* 2.0.
*/
-import axios from 'axios';
+import axios, { AxiosResponse } from 'axios';
import { createExternalService } from './service';
import * as utils from '../lib/axios_utils';
-import { ExternalService } from './types';
+import { ExternalService, ServiceNowITSMIncident } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../../actions_config.mock';
import { serviceNowCommonFields, serviceNowChoices } from './mocks';
+import { snExternalServiceConfig } from './config';
const logger = loggingSystemMock.create().get() as jest.Mocked;
jest.mock('axios');
@@ -28,24 +29,134 @@ jest.mock('../lib/axios_utils', () => {
axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
-const patchMock = utils.patch as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
-const table = 'incident';
+
+const getImportSetAPIResponse = (update = false) => ({
+ import_set: 'ISET01',
+ staging_table: 'x_elas2_inc_int_elastic_incident',
+ result: [
+ {
+ transform_map: 'Elastic Incident',
+ table: 'incident',
+ display_name: 'number',
+ display_value: 'INC01',
+ record_link: 'https://example.com/api/now/table/incident/1',
+ status: update ? 'updated' : 'inserted',
+ sys_id: '1',
+ },
+ ],
+});
+
+const getImportSetAPIError = () => ({
+ import_set: 'ISET01',
+ staging_table: 'x_elas2_inc_int_elastic_incident',
+ result: [
+ {
+ transform_map: 'Elastic Incident',
+ status: 'error',
+ error_message: 'An error has occurred while importing the incident',
+ status_message: 'failure',
+ },
+ ],
+});
+
+const mockApplicationVersion = () =>
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' },
+ },
+ }));
+
+const mockImportIncident = (update: boolean) =>
+ requestMock.mockImplementationOnce(() => ({
+ data: getImportSetAPIResponse(update),
+ }));
+
+const mockIncidentResponse = (update: boolean) =>
+ requestMock.mockImplementation(() => ({
+ data: {
+ result: {
+ sys_id: '1',
+ number: 'INC01',
+ ...(update
+ ? { sys_updated_on: '2020-03-10 12:24:20' }
+ : { sys_created_on: '2020-03-10 12:24:20' }),
+ },
+ },
+ }));
+
+const createIncident = async (service: ExternalService) => {
+ // Get application version
+ mockApplicationVersion();
+ // Import set api response
+ mockImportIncident(false);
+ // Get incident response
+ mockIncidentResponse(false);
+
+ return await service.createIncident({
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ });
+};
+
+const updateIncident = async (service: ExternalService) => {
+ // Get application version
+ mockApplicationVersion();
+ // Import set api response
+ mockImportIncident(true);
+ // Get incident response
+ mockIncidentResponse(true);
+
+ return await service.updateIncident({
+ incidentId: '1',
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ });
+};
+
+const expectImportedIncident = (update: boolean) => {
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health',
+ method: 'get',
+ });
+
+ expect(requestMock).toHaveBeenNthCalledWith(2, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident',
+ method: 'post',
+ data: {
+ u_short_description: 'title',
+ u_description: 'desc',
+ ...(update ? { elastic_incident_id: '1' } : {}),
+ },
+ });
+
+ expect(requestMock).toHaveBeenNthCalledWith(3, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/v2/table/incident/1',
+ method: 'get',
+ });
+};
describe('ServiceNow service', () => {
let service: ExternalService;
beforeEach(() => {
service = createExternalService(
- table,
{
// The trailing slash at the end of the url is intended.
// All API calls need to have the trailing slash removed.
- config: { apiUrl: 'https://dev102283.service-now.com/' },
+ config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
- configurationUtilities
+ configurationUtilities,
+ snExternalServiceConfig['.servicenow']
);
});
@@ -57,13 +168,13 @@ describe('ServiceNow service', () => {
test('throws without url', () => {
expect(() =>
createExternalService(
- table,
{
config: { apiUrl: null },
secrets: { username: 'admin', password: 'admin' },
},
logger,
- configurationUtilities
+ configurationUtilities,
+ snExternalServiceConfig['.servicenow']
)
).toThrow();
});
@@ -71,13 +182,13 @@ describe('ServiceNow service', () => {
test('throws without username', () => {
expect(() =>
createExternalService(
- table,
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: 'admin' },
},
logger,
- configurationUtilities
+ configurationUtilities,
+ snExternalServiceConfig['.servicenow']
)
).toThrow();
});
@@ -85,13 +196,13 @@ describe('ServiceNow service', () => {
test('throws without password', () => {
expect(() =>
createExternalService(
- table,
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: undefined },
},
logger,
- configurationUtilities
+ configurationUtilities,
+ snExternalServiceConfig['.servicenow']
)
).toThrow();
});
@@ -116,19 +227,20 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
+ url: 'https://example.com/api/now/v2/table/incident/1',
+ method: 'get',
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
- 'sn_si_incident',
{
- config: { apiUrl: 'https://dev102283.service-now.com/' },
+ config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
- configurationUtilities
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
);
requestMock.mockImplementation(() => ({
@@ -140,7 +252,8 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
+ url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
+ method: 'get',
});
});
@@ -166,214 +279,346 @@ describe('ServiceNow service', () => {
});
describe('createIncident', () => {
- test('it creates the incident correctly', async () => {
- requestMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
- }));
-
- const res = await service.createIncident({
- incident: { short_description: 'title', description: 'desc' },
+ // new connectors
+ describe('import set table', () => {
+ test('it creates the incident correctly', async () => {
+ const res = await createIncident(service);
+ expect(res).toEqual({
+ title: 'INC01',
+ id: '1',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
+ });
});
- expect(res).toEqual({
- title: 'INC01',
- id: '1',
- pushedDate: '2020-03-10T12:24:20.000Z',
- url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1',
+ test('it should call request with correct arguments', async () => {
+ await createIncident(service);
+ expect(requestMock).toHaveBeenCalledTimes(3);
+ expectImportedIncident(false);
});
- });
- test('it should call request with correct arguments', async () => {
- requestMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
- }));
+ test('it should call request with correct arguments when table changes', async () => {
+ service = createExternalService(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ snExternalServiceConfig['.servicenow-sir']
+ );
- await service.createIncident({
- incident: { short_description: 'title', description: 'desc' },
- });
+ const res = await createIncident(service);
- expect(requestMock).toHaveBeenCalledWith({
- axios,
- logger,
- configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/incident',
- method: 'post',
- data: { short_description: 'title', description: 'desc' },
- });
- });
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
+ method: 'get',
+ });
- test('it should call request with correct arguments when table changes', async () => {
- service = createExternalService(
- 'sn_si_incident',
- {
- config: { apiUrl: 'https://dev102283.service-now.com/' },
- secrets: { username: 'admin', password: 'admin' },
- },
- logger,
- configurationUtilities
- );
+ expect(requestMock).toHaveBeenNthCalledWith(2, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident',
+ method: 'post',
+ data: { u_short_description: 'title', u_description: 'desc' },
+ });
+
+ expect(requestMock).toHaveBeenNthCalledWith(3, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
+ method: 'get',
+ });
- requestMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
- }));
+ expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
+ });
- const res = await service.createIncident({
- incident: { short_description: 'title', description: 'desc' },
+ test('it should throw an error when the application is not installed', async () => {
+ requestMock.mockImplementation(() => {
+ throw new Error('An error has occurred');
+ });
+
+ await expect(
+ service.createIncident({
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ })
+ ).rejects.toThrow(
+ '[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null'
+ );
});
- expect(requestMock).toHaveBeenCalledWith({
- axios,
- logger,
- configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident',
- method: 'post',
- data: { short_description: 'title', description: 'desc' },
+ test('it should throw an error when instance is not alive', async () => {
+ requestMock.mockImplementation(() => ({
+ status: 200,
+ data: {},
+ request: { connection: { servername: 'Developer instance' } },
+ }));
+ await expect(
+ service.createIncident({
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ })
+ ).rejects.toThrow(
+ 'There is an issue with your Service Now Instance. Please check Developer instance.'
+ );
});
- expect(res.url).toEqual(
- 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
- );
+ test('it should throw an error when there is an import set api error', async () => {
+ requestMock.mockImplementation(() => ({ data: getImportSetAPIError() }));
+ await expect(
+ service.createIncident({
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ })
+ ).rejects.toThrow(
+ '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown'
+ );
+ });
});
- test('it should throw an error', async () => {
- requestMock.mockImplementation(() => {
- throw new Error('An error has occurred');
+ // old connectors
+ describe('table API', () => {
+ beforeEach(() => {
+ service = createExternalService(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
+ );
});
- await expect(
- service.createIncident({
- incident: { short_description: 'title', description: 'desc' },
- })
- ).rejects.toThrow(
- '[Action][ServiceNow]: Unable to create incident. Error: An error has occurred'
- );
- });
+ test('it creates the incident correctly', async () => {
+ mockIncidentResponse(false);
+ const res = await service.createIncident({
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ });
+
+ expect(res).toEqual({
+ title: 'INC01',
+ id: '1',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
+ });
+
+ expect(requestMock).toHaveBeenCalledTimes(2);
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/v2/table/incident',
+ method: 'post',
+ data: { short_description: 'title', description: 'desc' },
+ });
+ });
- test('it should throw an error when instance is not alive', async () => {
- requestMock.mockImplementation(() => ({
- status: 200,
- data: {},
- request: { connection: { servername: 'Developer instance' } },
- }));
- await expect(service.getIncident('1')).rejects.toThrow(
- 'There is an issue with your Service Now Instance. Please check Developer instance.'
- );
- });
- });
+ test('it should call request with correct arguments when table changes', async () => {
+ service = createExternalService(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }
+ );
- describe('updateIncident', () => {
- test('it updates the incident correctly', async () => {
- patchMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
- }));
+ mockIncidentResponse(false);
- const res = await service.updateIncident({
- incidentId: '1',
- incident: { short_description: 'title', description: 'desc' },
- });
+ const res = await service.createIncident({
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ });
+
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/v2/table/sn_si_incident',
+ method: 'post',
+ data: { short_description: 'title', description: 'desc' },
+ });
- expect(res).toEqual({
- title: 'INC01',
- id: '1',
- pushedDate: '2020-03-10T12:24:20.000Z',
- url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1',
+ expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
});
});
+ });
- test('it should call request with correct arguments', async () => {
- patchMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
- }));
-
- await service.updateIncident({
- incidentId: '1',
- incident: { short_description: 'title', description: 'desc' },
+ describe('updateIncident', () => {
+ // new connectors
+ describe('import set table', () => {
+ test('it updates the incident correctly', async () => {
+ const res = await updateIncident(service);
+
+ expect(res).toEqual({
+ title: 'INC01',
+ id: '1',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
+ });
});
- expect(patchMock).toHaveBeenCalledWith({
- axios,
- logger,
- configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
- data: { short_description: 'title', description: 'desc' },
+ test('it should call request with correct arguments', async () => {
+ await updateIncident(service);
+ expectImportedIncident(true);
});
- });
- test('it should call request with correct arguments when table changes', async () => {
- service = createExternalService(
- 'sn_si_incident',
- {
- config: { apiUrl: 'https://dev102283.service-now.com/' },
- secrets: { username: 'admin', password: 'admin' },
- },
- logger,
- configurationUtilities
- );
+ test('it should call request with correct arguments when table changes', async () => {
+ service = createExternalService(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ snExternalServiceConfig['.servicenow-sir']
+ );
- patchMock.mockImplementation(() => ({
- data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
- }));
+ const res = await updateIncident(service);
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
+ method: 'get',
+ });
- const res = await service.updateIncident({
- incidentId: '1',
- incident: { short_description: 'title', description: 'desc' },
+ expect(requestMock).toHaveBeenNthCalledWith(2, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident',
+ method: 'post',
+ data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' },
+ });
+
+ expect(requestMock).toHaveBeenNthCalledWith(3, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
+ method: 'get',
+ });
+
+ expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
});
- expect(patchMock).toHaveBeenCalledWith({
- axios,
- logger,
- configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
- data: { short_description: 'title', description: 'desc' },
+ test('it should throw an error when the application is not installed', async () => {
+ requestMock.mockImplementation(() => {
+ throw new Error('An error has occurred');
+ });
+
+ await expect(
+ service.updateIncident({
+ incidentId: '1',
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ })
+ ).rejects.toThrow(
+ '[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null'
+ );
});
- expect(res.url).toEqual(
- 'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
- );
+ test('it should throw an error when instance is not alive', async () => {
+ requestMock.mockImplementation(() => ({
+ status: 200,
+ data: {},
+ request: { connection: { servername: 'Developer instance' } },
+ }));
+ await expect(
+ service.updateIncident({
+ incidentId: '1',
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ })
+ ).rejects.toThrow(
+ 'There is an issue with your Service Now Instance. Please check Developer instance.'
+ );
+ });
+
+ test('it should throw an error when there is an import set api error', async () => {
+ requestMock.mockImplementation(() => ({ data: getImportSetAPIError() }));
+ await expect(
+ service.updateIncident({
+ incidentId: '1',
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ })
+ ).rejects.toThrow(
+ '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown'
+ );
+ });
});
- test('it should throw an error', async () => {
- patchMock.mockImplementation(() => {
- throw new Error('An error has occurred');
+ // old connectors
+ describe('table API', () => {
+ beforeEach(() => {
+ service = createExternalService(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
+ );
});
- await expect(
- service.updateIncident({
+ test('it updates the incident correctly', async () => {
+ mockIncidentResponse(true);
+ const res = await service.updateIncident({
incidentId: '1',
- incident: { short_description: 'title', description: 'desc' },
- })
- ).rejects.toThrow(
- '[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred'
- );
- });
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ });
+
+ expect(res).toEqual({
+ title: 'INC01',
+ id: '1',
+ pushedDate: '2020-03-10T12:24:20.000Z',
+ url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
+ });
+
+ expect(requestMock).toHaveBeenCalledTimes(2);
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/v2/table/incident/1',
+ method: 'patch',
+ data: { short_description: 'title', description: 'desc' },
+ });
+ });
- test('it creates the comment correctly', async () => {
- patchMock.mockImplementation(() => ({
- data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } },
- }));
+ test('it should call request with correct arguments when table changes', async () => {
+ service = createExternalService(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }
+ );
- const res = await service.updateIncident({
- incidentId: '1',
- comment: 'comment-1',
- });
+ mockIncidentResponse(false);
- expect(res).toEqual({
- title: 'INC011',
- id: '11',
- pushedDate: '2020-03-10T12:24:20.000Z',
- url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11',
- });
- });
+ const res = await service.updateIncident({
+ incidentId: '1',
+ incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
+ });
- test('it should throw an error when instance is not alive', async () => {
- requestMock.mockImplementation(() => ({
- status: 200,
- data: {},
- request: { connection: { servername: 'Developer instance' } },
- }));
- await expect(service.getIncident('1')).rejects.toThrow(
- 'There is an issue with your Service Now Instance. Please check Developer instance.'
- );
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
+ method: 'patch',
+ data: { short_description: 'title', description: 'desc' },
+ });
+
+ expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
+ });
});
});
@@ -388,7 +633,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
+ url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
});
});
@@ -402,13 +647,13 @@ describe('ServiceNow service', () => {
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
- 'sn_si_incident',
{
- config: { apiUrl: 'https://dev102283.service-now.com/' },
+ config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
- configurationUtilities
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
);
requestMock.mockImplementation(() => ({
@@ -420,7 +665,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
+ url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
});
});
@@ -456,7 +701,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
+ url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
});
});
@@ -470,13 +715,13 @@ describe('ServiceNow service', () => {
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
- 'sn_si_incident',
{
- config: { apiUrl: 'https://dev102283.service-now.com/' },
+ config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
- configurationUtilities
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
);
requestMock.mockImplementation(() => ({
@@ -489,7 +734,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
- url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
+ url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
});
});
@@ -513,4 +758,79 @@ describe('ServiceNow service', () => {
);
});
});
+
+ describe('getUrl', () => {
+ test('it returns the instance url', async () => {
+ expect(service.getUrl()).toBe('https://example.com');
+ });
+ });
+
+ describe('checkInstance', () => {
+ test('it throws an error if there is no result on data', () => {
+ const res = { status: 200, data: {} } as AxiosResponse;
+ expect(() => service.checkInstance(res)).toThrow();
+ });
+
+ test('it does NOT throws an error if the status > 400', () => {
+ const res = { status: 500, data: {} } as AxiosResponse;
+ expect(() => service.checkInstance(res)).not.toThrow();
+ });
+
+ test('it shows the servername', () => {
+ const res = {
+ status: 200,
+ data: {},
+ request: { connection: { servername: 'https://example.com' } },
+ } as AxiosResponse;
+ expect(() => service.checkInstance(res)).toThrow(
+ 'There is an issue with your Service Now Instance. Please check https://example.com.'
+ );
+ });
+
+ describe('getApplicationInformation', () => {
+ test('it returns the application information', async () => {
+ mockApplicationVersion();
+ const res = await service.getApplicationInformation();
+ expect(res).toEqual({
+ name: 'Elastic',
+ scope: 'x_elas2_inc_int',
+ version: '1.0.0',
+ });
+ });
+
+ test('it should throw an error', async () => {
+ requestMock.mockImplementation(() => {
+ throw new Error('An error has occurred');
+ });
+ await expect(service.getApplicationInformation()).rejects.toThrow(
+ '[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown'
+ );
+ });
+ });
+
+ describe('checkIfApplicationIsInstalled', () => {
+ test('it logs the application information', async () => {
+ mockApplicationVersion();
+ await service.checkIfApplicationIsInstalled();
+ expect(logger.debug).toHaveBeenCalledWith(
+ 'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0'
+ );
+ });
+
+ test('it does not log if useOldApi = true', async () => {
+ service = createExternalService(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ { ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
+ );
+ await service.checkIfApplicationIsInstalled();
+ expect(requestMock).not.toHaveBeenCalled();
+ expect(logger.debug).not.toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
index 07ed9edc94d39..cb030c7bb6933 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
@@ -7,28 +7,35 @@
import axios, { AxiosResponse } from 'axios';
-import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types';
+import {
+ ExternalServiceCredentials,
+ ExternalService,
+ ExternalServiceParamsCreate,
+ ExternalServiceParamsUpdate,
+ ImportSetApiResponse,
+ ImportSetApiResponseError,
+ ServiceNowIncident,
+ GetApplicationInfoResponse,
+ SNProductsConfigValue,
+ ServiceFactory,
+} from './types';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
-import {
- ServiceNowPublicConfigurationType,
- ServiceNowSecretConfigurationType,
- ResponseError,
-} from './types';
-import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils';
+import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types';
+import { request } from '../lib/axios_utils';
import { ActionsConfigurationUtilities } from '../../actions_config';
+import { createServiceError, getPushedDate, prepareIncident } from './utils';
-const API_VERSION = 'v2';
-const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`;
+export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`;
-export const createExternalService = (
- table: string,
+export const createExternalService: ServiceFactory = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
- configurationUtilities: ActionsConfigurationUtilities
+ configurationUtilities: ActionsConfigurationUtilities,
+ { table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue
): ExternalService => {
- const { apiUrl: url } = config as ServiceNowPublicConfigurationType;
+ const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType;
const { username, password } = secrets as ServiceNowSecretConfigurationType;
if (!url || !username || !password) {
@@ -36,13 +43,26 @@ export const createExternalService = (
}
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
- const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`;
- const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
- const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`;
+ const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${importSetTable}`;
+ const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/v2/table/${table}`;
+ const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY_ENDPOINT}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
+ const choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`;
+ /**
+ * Need to be set the same at:
+ * x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts
+ */
+ const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`;
+
const axiosInstance = axios.create({
auth: { username, password },
});
+ const useOldApi = !useImportAPI || isLegacy;
+
+ const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl);
+ const getUpdateIncidentUrl = (incidentId: string) =>
+ useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl;
+
const getIncidentViewURL = (id: string) => {
// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`;
@@ -57,7 +77,7 @@ export const createExternalService = (
};
const checkInstance = (res: AxiosResponse) => {
- if (res.status === 200 && res.data.result == null) {
+ if (res.status >= 200 && res.status < 400 && res.data.result == null) {
throw new Error(
`There is an issue with your Service Now Instance. Please check ${
res.request?.connection?.servername ?? ''
@@ -66,34 +86,70 @@ export const createExternalService = (
}
};
- const createErrorMessage = (errorResponse: ResponseError): string => {
- if (errorResponse == null) {
- return '';
+ const isImportSetApiResponseAnError = (
+ data: ImportSetApiResponse['result'][0]
+ ): data is ImportSetApiResponseError['result'][0] => data.status === 'error';
+
+ const throwIfImportSetApiResponseIsAnError = (res: ImportSetApiResponse) => {
+ if (res.result.length === 0) {
+ throw new Error('Unexpected result');
}
- const { error } = errorResponse;
- return error != null ? `${error?.message}: ${error?.detail}` : '';
+ const data = res.result[0];
+
+ // Create ResponseError message?
+ if (isImportSetApiResponseAnError(data)) {
+ throw new Error(data.error_message);
+ }
};
- const getIncident = async (id: string) => {
+ /**
+ * Gets the Elastic SN Application information including the current version.
+ * It should not be used on legacy connectors.
+ */
+ const getApplicationInformation = async (): Promise => {
try {
const res = await request({
axios: axiosInstance,
- url: `${incidentUrl}/${id}`,
+ url: getVersionUrl(),
logger,
configurationUtilities,
+ method: 'get',
});
+
checkInstance(res);
+
return { ...res.data.result };
} catch (error) {
- throw new Error(
- getErrorMessage(
- i18n.SERVICENOW,
- `Unable to get incident with id ${id}. Error: ${
- error.message
- } Reason: ${createErrorMessage(error.response?.data)}`
- )
- );
+ throw createServiceError(error, 'Unable to get application version');
+ }
+ };
+
+ const logApplicationInfo = (scope: string, version: string) =>
+ logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`);
+
+ const checkIfApplicationIsInstalled = async () => {
+ if (!useOldApi) {
+ const { version, scope } = await getApplicationInformation();
+ logApplicationInfo(scope, version);
+ }
+ };
+
+ const getIncident = async (id: string): Promise => {
+ try {
+ const res = await request({
+ axios: axiosInstance,
+ url: `${tableApiIncidentUrl}/${id}`,
+ logger,
+ configurationUtilities,
+ method: 'get',
+ });
+
+ checkInstance(res);
+
+ return { ...res.data.result };
+ } catch (error) {
+ throw createServiceError(error, `Unable to get incident with id ${id}`);
}
};
@@ -101,7 +157,7 @@ export const createExternalService = (
try {
const res = await request({
axios: axiosInstance,
- url: incidentUrl,
+ url: tableApiIncidentUrl,
logger,
params,
configurationUtilities,
@@ -109,71 +165,80 @@ export const createExternalService = (
checkInstance(res);
return res.data.result.length > 0 ? { ...res.data.result } : undefined;
} catch (error) {
- throw new Error(
- getErrorMessage(
- i18n.SERVICENOW,
- `Unable to find incidents by query. Error: ${error.message} Reason: ${createErrorMessage(
- error.response?.data
- )}`
- )
- );
+ throw createServiceError(error, 'Unable to find incidents by query');
}
};
- const createIncident = async ({ incident }: ExternalServiceParams) => {
+ const getUrl = () => urlWithoutTrailingSlash;
+
+ const createIncident = async ({ incident }: ExternalServiceParamsCreate) => {
try {
+ await checkIfApplicationIsInstalled();
+
const res = await request({
axios: axiosInstance,
- url: `${incidentUrl}`,
+ url: getCreateIncidentUrl(),
logger,
method: 'post',
- data: { ...(incident as Record) },
+ data: prepareIncident(useOldApi, incident),
configurationUtilities,
});
+
checkInstance(res);
+
+ if (!useOldApi) {
+ throwIfImportSetApiResponseIsAnError(res.data);
+ }
+
+ const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id;
+ const insertedIncident = await getIncident(incidentId);
+
return {
- title: res.data.result.number,
- id: res.data.result.sys_id,
- pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(),
- url: getIncidentViewURL(res.data.result.sys_id),
+ title: insertedIncident.number,
+ id: insertedIncident.sys_id,
+ pushedDate: getPushedDate(insertedIncident.sys_created_on),
+ url: getIncidentViewURL(insertedIncident.sys_id),
};
} catch (error) {
- throw new Error(
- getErrorMessage(
- i18n.SERVICENOW,
- `Unable to create incident. Error: ${error.message} Reason: ${createErrorMessage(
- error.response?.data
- )}`
- )
- );
+ throw createServiceError(error, 'Unable to create incident');
}
};
- const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => {
+ const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => {
try {
- const res = await patch({
+ await checkIfApplicationIsInstalled();
+
+ const res = await request({
axios: axiosInstance,
- url: `${incidentUrl}/${incidentId}`,
+ url: getUpdateIncidentUrl(incidentId),
+ // Import Set API supports only POST.
+ method: useOldApi ? 'patch' : 'post',
logger,
- data: { ...(incident as Record) },
+ data: {
+ ...prepareIncident(useOldApi, incident),
+ // elastic_incident_id is used to update the incident when using the Import Set API.
+ ...(useOldApi ? {} : { elastic_incident_id: incidentId }),
+ },
configurationUtilities,
});
+
checkInstance(res);
+
+ if (!useOldApi) {
+ throwIfImportSetApiResponseIsAnError(res.data);
+ }
+
+ const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id;
+ const updatedIncident = await getIncident(id);
+
return {
- title: res.data.result.number,
- id: res.data.result.sys_id,
- pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(),
- url: getIncidentViewURL(res.data.result.sys_id),
+ title: updatedIncident.number,
+ id: updatedIncident.sys_id,
+ pushedDate: getPushedDate(updatedIncident.sys_updated_on),
+ url: getIncidentViewURL(updatedIncident.sys_id),
};
} catch (error) {
- throw new Error(
- getErrorMessage(
- i18n.SERVICENOW,
- `Unable to update incident with id ${incidentId}. Error: ${
- error.message
- } Reason: ${createErrorMessage(error.response?.data)}`
- )
- );
+ throw createServiceError(error, `Unable to update incident with id ${incidentId}`);
}
};
@@ -185,17 +250,12 @@ export const createExternalService = (
logger,
configurationUtilities,
});
+
checkInstance(res);
+
return res.data.result.length > 0 ? res.data.result : [];
} catch (error) {
- throw new Error(
- getErrorMessage(
- i18n.SERVICENOW,
- `Unable to get fields. Error: ${error.message} Reason: ${createErrorMessage(
- error.response?.data
- )}`
- )
- );
+ throw createServiceError(error, 'Unable to get fields');
}
};
@@ -210,14 +270,7 @@ export const createExternalService = (
checkInstance(res);
return res.data.result;
} catch (error) {
- throw new Error(
- getErrorMessage(
- i18n.SERVICENOW,
- `Unable to get choices. Error: ${error.message} Reason: ${createErrorMessage(
- error.response?.data
- )}`
- )
- );
+ throw createServiceError(error, 'Unable to get choices');
}
};
@@ -228,5 +281,9 @@ export const createExternalService = (
getIncident,
updateIncident,
getChoices,
+ getUrl,
+ checkInstance,
+ getApplicationInformation,
+ checkIfApplicationIsInstalled,
};
};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts
new file mode 100644
index 0000000000000..0fc94b6287abd
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.test.ts
@@ -0,0 +1,129 @@
+/*
+ * 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 axios from 'axios';
+
+import { createExternalServiceSIR } from './service_sir';
+import * as utils from '../lib/axios_utils';
+import { ExternalServiceSIR } from './types';
+import { Logger } from '../../../../../../src/core/server';
+import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
+import { actionsConfigMock } from '../../actions_config.mock';
+import { observables } from './mocks';
+import { snExternalServiceConfig } from './config';
+
+const logger = loggingSystemMock.create().get() as jest.Mocked;
+
+jest.mock('axios');
+jest.mock('../lib/axios_utils', () => {
+ const originalUtils = jest.requireActual('../lib/axios_utils');
+ return {
+ ...originalUtils,
+ request: jest.fn(),
+ patch: jest.fn(),
+ };
+});
+
+axios.create = jest.fn(() => axios);
+const requestMock = utils.request as jest.Mock;
+const configurationUtilities = actionsConfigMock.create();
+
+const mockApplicationVersion = () =>
+ requestMock.mockImplementationOnce(() => ({
+ data: {
+ result: { name: 'Elastic', scope: 'x_elas2_sir_int', version: '1.0.0' },
+ },
+ }));
+
+const getAddObservablesResponse = () => [
+ {
+ value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
+ observable_sys_id: '1',
+ },
+ {
+ value: '127.0.0.1',
+ observable_sys_id: '2',
+ },
+ {
+ value: 'https://example.com',
+ observable_sys_id: '3',
+ },
+];
+
+const mockAddObservablesResponse = (single: boolean) => {
+ const res = getAddObservablesResponse();
+ requestMock.mockImplementation(() => ({
+ data: {
+ result: single ? res[0] : res,
+ },
+ }));
+};
+
+const expectAddObservables = (single: boolean) => {
+ expect(requestMock).toHaveBeenNthCalledWith(1, {
+ axios,
+ logger,
+ configurationUtilities,
+ url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
+ method: 'get',
+ });
+
+ const url = single
+ ? 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables'
+ : 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk';
+
+ const data = single ? observables[0] : observables;
+
+ expect(requestMock).toHaveBeenNthCalledWith(2, {
+ axios,
+ logger,
+ configurationUtilities,
+ url,
+ method: 'post',
+ data,
+ });
+};
+
+describe('ServiceNow SIR service', () => {
+ let service: ExternalServiceSIR;
+
+ beforeEach(() => {
+ service = createExternalServiceSIR(
+ {
+ config: { apiUrl: 'https://example.com/' },
+ secrets: { username: 'admin', password: 'admin' },
+ },
+ logger,
+ configurationUtilities,
+ snExternalServiceConfig['.servicenow-sir']
+ ) as ExternalServiceSIR;
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('bulkAddObservableToIncident', () => {
+ test('it adds multiple observables correctly', async () => {
+ mockApplicationVersion();
+ mockAddObservablesResponse(false);
+
+ const res = await service.bulkAddObservableToIncident(observables, 'incident-1');
+ expect(res).toEqual(getAddObservablesResponse());
+ expectAddObservables(false);
+ });
+
+ test('it adds a single observable correctly', async () => {
+ mockApplicationVersion();
+ mockAddObservablesResponse(true);
+
+ const res = await service.addObservableToIncident(observables[0], 'incident-1');
+ expect(res).toEqual(getAddObservablesResponse()[0]);
+ expectAddObservables(true);
+ });
+ });
+});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts
new file mode 100644
index 0000000000000..fc8d8cc555bc8
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/service_sir.ts
@@ -0,0 +1,104 @@
+/*
+ * 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 axios from 'axios';
+
+import {
+ ExternalServiceCredentials,
+ SNProductsConfigValue,
+ Observable,
+ ExternalServiceSIR,
+ ObservableResponse,
+ ServiceFactory,
+} from './types';
+
+import { Logger } from '../../../../../../src/core/server';
+import { ServiceNowSecretConfigurationType } from './types';
+import { request } from '../lib/axios_utils';
+import { ActionsConfigurationUtilities } from '../../actions_config';
+import { createExternalService } from './service';
+import { createServiceError } from './utils';
+
+const getAddObservableToIncidentURL = (url: string, incidentID: string) =>
+ `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`;
+
+const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) =>
+ `${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`;
+
+export const createExternalServiceSIR: ServiceFactory = (
+ credentials: ExternalServiceCredentials,
+ logger: Logger,
+ configurationUtilities: ActionsConfigurationUtilities,
+ serviceConfig: SNProductsConfigValue
+): ExternalServiceSIR => {
+ const snService = createExternalService(
+ credentials,
+ logger,
+ configurationUtilities,
+ serviceConfig
+ );
+
+ const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType;
+ const axiosInstance = axios.create({
+ auth: { username, password },
+ });
+
+ const _addObservable = async (data: Observable | Observable[], url: string) => {
+ snService.checkIfApplicationIsInstalled();
+
+ const res = await request({
+ axios: axiosInstance,
+ url,
+ logger,
+ method: 'post',
+ data,
+ configurationUtilities,
+ });
+
+ snService.checkInstance(res);
+ return res.data.result;
+ };
+
+ const addObservableToIncident = async (
+ observable: Observable,
+ incidentID: string
+ ): Promise => {
+ try {
+ return await _addObservable(
+ observable,
+ getAddObservableToIncidentURL(snService.getUrl(), incidentID)
+ );
+ } catch (error) {
+ throw createServiceError(
+ error,
+ `Unable to add observable to security incident with id ${incidentID}`
+ );
+ }
+ };
+
+ const bulkAddObservableToIncident = async (
+ observables: Observable[],
+ incidentID: string
+ ): Promise => {
+ try {
+ return await _addObservable(
+ observables,
+ getBulkAddObservableToIncidentURL(snService.getUrl(), incidentID)
+ );
+ } catch (error) {
+ throw createServiceError(
+ error,
+ `Unable to add observables to security incident with id ${incidentID}`
+ );
+ }
+ };
+ return {
+ ...snService,
+ addObservableToIncident,
+ bulkAddObservableToIncident,
+ };
+};
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
index 50631cf289a73..ecca1e55e0fec 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts
@@ -7,6 +7,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
+import { AxiosError, AxiosResponse } from 'axios';
import { TypeOf } from '@kbn/config-schema';
import {
ExecutorParamsSchemaITSM,
@@ -78,15 +79,29 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
-export type ExternalServiceParams = Record;
+export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident;
+export type PartialIncident = Partial;
+
+export interface ExternalServiceParamsCreate {
+ incident: Incident & Record;
+}
+
+export interface ExternalServiceParamsUpdate {
+ incidentId: string;
+ incident: PartialIncident & Record;
+}
export interface ExternalService {
getChoices: (fields: string[]) => Promise;
- getIncident: (id: string) => Promise;
+ getIncident: (id: string) => Promise;
getFields: () => Promise;
- createIncident: (params: ExternalServiceParams) => Promise;
- updateIncident: (params: ExternalServiceParams) => Promise;
- findIncidents: (params?: Record) => Promise;
+ createIncident: (params: ExternalServiceParamsCreate) => Promise;
+ updateIncident: (params: ExternalServiceParamsUpdate) => Promise;
+ findIncidents: (params?: Record) => Promise;
+ getUrl: () => string;
+ checkInstance: (res: AxiosResponse) => void;
+ getApplicationInformation: () => Promise;
+ checkIfApplicationIsInstalled: () => Promise;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
@@ -115,10 +130,9 @@ export type ServiceNowSIRIncident = Omit<
'externalId'
>;
-export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident;
-
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
+ config: Record;
secrets: Record;
logger: Logger;
commentFieldKey: string;
@@ -158,12 +172,20 @@ export interface GetChoicesHandlerArgs {
params: ExecutorSubActionGetChoicesParams;
}
-export interface ExternalServiceApi {
+export interface ServiceNowIncident {
+ sys_id: string;
+ number: string;
+ sys_created_on: string;
+ sys_updated_on: string;
+ [x: string]: unknown;
+}
+
+export interface ExternalServiceAPI {
getChoices: (args: GetChoicesHandlerArgs) => Promise;
getFields: (args: GetCommonFieldsHandlerArgs) => Promise;
handshake: (args: HandshakeApiHandlerArgs) => Promise;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise;
- getIncident: (args: GetIncidentApiHandlerArgs) => Promise;
+ getIncident: (args: GetIncidentApiHandlerArgs) => Promise;
}
export interface ExternalServiceCommentResponse {
@@ -173,10 +195,90 @@ export interface ExternalServiceCommentResponse {
}
type TypeNullOrUndefined = T | null | undefined;
-export interface ResponseError {
+
+export interface ServiceNowError {
error: TypeNullOrUndefined<{
message: TypeNullOrUndefined;
detail: TypeNullOrUndefined;
}>;
status: TypeNullOrUndefined;
}
+
+export type ResponseError = AxiosError;
+
+export interface ImportSetApiResponseSuccess {
+ import_set: string;
+ staging_table: string;
+ result: Array<{
+ display_name: string;
+ display_value: string;
+ record_link: string;
+ status: string;
+ sys_id: string;
+ table: string;
+ transform_map: string;
+ }>;
+}
+
+export interface ImportSetApiResponseError {
+ import_set: string;
+ staging_table: string;
+ result: Array<{
+ error_message: string;
+ status_message: string;
+ status: string;
+ transform_map: string;
+ }>;
+}
+
+export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError;
+export interface GetApplicationInfoResponse {
+ id: string;
+ name: string;
+ scope: string;
+ version: string;
+}
+
+export interface SNProductsConfigValue {
+ table: string;
+ appScope: string;
+ useImportAPI: boolean;
+ importSetTable: string;
+ commentFieldKey: string;
+}
+
+export type SNProductsConfig = Record;
+
+export enum ObservableTypes {
+ ip4 = 'ipv4-addr',
+ url = 'URL',
+ sha256 = 'SHA256',
+}
+
+export interface Observable {
+ value: string;
+ type: ObservableTypes;
+}
+
+export interface ObservableResponse {
+ value: string;
+ observable_sys_id: ObservableTypes;
+}
+
+export interface ExternalServiceSIR extends ExternalService {
+ addObservableToIncident: (
+ observable: Observable,
+ incidentID: string
+ ) => Promise;
+ bulkAddObservableToIncident: (
+ observables: Observable[],
+ incidentID: string
+ ) => Promise;
+}
+
+export type ServiceFactory = (
+ credentials: ExternalServiceCredentials,
+ logger: Logger,
+ configurationUtilities: ActionsConfigurationUtilities,
+ serviceConfig: SNProductsConfigValue
+) => ExternalServiceSIR | ExternalService;
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts
new file mode 100644
index 0000000000000..87f27da6d213f
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts
@@ -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 { AxiosError } from 'axios';
+import { prepareIncident, createServiceError, getPushedDate } from './utils';
+
+/**
+ * The purpose of this test is to
+ * prevent developers from accidentally
+ * change important configuration values
+ * such as the scope or the import set table
+ * of our ServiceNow application
+ */
+
+describe('utils', () => {
+ describe('prepareIncident', () => {
+ test('it prepares the incident correctly when useOldApi=false', async () => {
+ const incident = { short_description: 'title', description: 'desc' };
+ const newIncident = prepareIncident(false, incident);
+ expect(newIncident).toEqual({ u_short_description: 'title', u_description: 'desc' });
+ });
+
+ test('it prepares the incident correctly when useOldApi=true', async () => {
+ const incident = { short_description: 'title', description: 'desc' };
+ const newIncident = prepareIncident(true, incident);
+ expect(newIncident).toEqual(incident);
+ });
+ });
+
+ describe('createServiceError', () => {
+ test('it creates an error when the response is null', async () => {
+ const error = new Error('An error occurred');
+ // @ts-expect-error
+ expect(createServiceError(error, 'Unable to do action').message).toBe(
+ '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: errorResponse was null'
+ );
+ });
+
+ test('it creates an error with response correctly', async () => {
+ const axiosError = {
+ message: 'An error occurred',
+ response: { data: { error: { message: 'Denied', detail: 'no access' } } },
+ } as AxiosError;
+
+ expect(createServiceError(axiosError, 'Unable to do action').message).toBe(
+ '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access'
+ );
+ });
+
+ test('it creates an error correctly when the ServiceNow error is null', async () => {
+ const axiosError = {
+ message: 'An error occurred',
+ response: { data: { error: null } },
+ } as AxiosError;
+
+ expect(createServiceError(axiosError, 'Unable to do action').message).toBe(
+ '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response'
+ );
+ });
+ });
+
+ describe('getPushedDate', () => {
+ beforeAll(() => {
+ jest.useFakeTimers('modern');
+ jest.setSystemTime(new Date('2021-10-04 11:15:06 GMT'));
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ test('it formats the date correctly if timestamp is provided', async () => {
+ expect(getPushedDate('2021-10-04 11:15:06')).toBe('2021-10-04T11:15:06.000Z');
+ });
+
+ test('it formats the date correctly if timestamp is not provided', async () => {
+ expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z');
+ });
+ });
+});
diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts
new file mode 100644
index 0000000000000..5b7ca99ffc709
--- /dev/null
+++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 { Incident, PartialIncident, ResponseError, ServiceNowError } from './types';
+import { FIELD_PREFIX } from './config';
+import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils';
+import * as i18n from './translations';
+
+export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident =>
+ useOldApi
+ ? incident
+ : Object.entries(incident).reduce(
+ (acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }),
+ {} as Incident
+ );
+
+const createErrorMessage = (errorResponse?: ServiceNowError): string => {
+ if (errorResponse == null) {
+ return 'unknown: errorResponse was null';
+ }
+
+ const { error } = errorResponse;
+ return error != null
+ ? `${error?.message}: ${error?.detail}`
+ : 'unknown: no error in error response';
+};
+
+export const createServiceError = (error: ResponseError, message: string) =>
+ new Error(
+ getErrorMessage(
+ i18n.SERVICENOW,
+ `${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}`
+ )
+ );
+
+export const getPushedDate = (timestamp?: string) => {
+ if (timestamp != null) {
+ return new Date(addTimeZoneToDate(timestamp)).toISOString();
+ }
+
+ return new Date().toISOString();
+};
diff --git a/x-pack/plugins/actions/server/constants/connectors.ts b/x-pack/plugins/actions/server/constants/connectors.ts
new file mode 100644
index 0000000000000..f20d499716cf0
--- /dev/null
+++ b/x-pack/plugins/actions/server/constants/connectors.ts
@@ -0,0 +1,12 @@
+/*
+ * 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.
+ */
+
+// TODO: Remove when Elastic for ITSM is published.
+export const ENABLE_NEW_SN_ITSM_CONNECTOR = true;
+
+// TODO: Remove when Elastic for Security Operations is published.
+export const ENABLE_NEW_SN_SIR_CONNECTOR = true;
diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts
index c094109a43d97..9f8e62c77e3a7 100644
--- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts
+++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.test.ts
@@ -165,6 +165,47 @@ describe('successful migrations', () => {
});
expect(migratedAction).toEqual(action);
});
+
+ test('set isLegacy config property for .servicenow', () => {
+ const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
+ const action = getMockDataForServiceNow();
+ const migratedAction = migration716(action, context);
+
+ expect(migratedAction).toEqual({
+ ...action,
+ attributes: {
+ ...action.attributes,
+ config: {
+ apiUrl: 'https://example.com',
+ isLegacy: true,
+ },
+ },
+ });
+ });
+
+ test('set isLegacy config property for .servicenow-sir', () => {
+ const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
+ const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' });
+ const migratedAction = migration716(action, context);
+
+ expect(migratedAction).toEqual({
+ ...action,
+ attributes: {
+ ...action.attributes,
+ config: {
+ apiUrl: 'https://example.com',
+ isLegacy: true,
+ },
+ },
+ });
+ });
+
+ test('it does not set isLegacy config for other connectors', () => {
+ const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
+ const action = getMockData();
+ const migratedAction = migration716(action, context);
+ expect(migratedAction).toEqual(action);
+ });
});
describe('8.0.0', () => {
@@ -306,3 +347,19 @@ function getMockData(
type: 'action',
};
}
+
+function getMockDataForServiceNow(
+ overwrites: Record = {}
+): SavedObjectUnsanitizedDoc> {
+ return {
+ attributes: {
+ name: 'abc',
+ actionTypeId: '.servicenow',
+ config: { apiUrl: 'https://example.com' },
+ secrets: { user: 'test', password: '123' },
+ ...overwrites,
+ },
+ id: uuid.v4(),
+ type: 'action',
+ };
+}
diff --git a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts
index e75f3eb41f2df..688839eb89858 100644
--- a/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts
+++ b/x-pack/plugins/actions/server/saved_objects/actions_migrations.ts
@@ -59,13 +59,16 @@ export function getActionsMigrations(
const migrationActionsFourteen = createEsoMigration(
encryptedSavedObjects,
(doc): doc is SavedObjectUnsanitizedDoc => true,
- pipeMigrations(addisMissingSecretsField)
+ pipeMigrations(addIsMissingSecretsField)
);
- const migrationEmailActionsSixteen = createEsoMigration(
+ const migrationActionsSixteen = createEsoMigration(
encryptedSavedObjects,
- (doc): doc is SavedObjectUnsanitizedDoc => doc.attributes.actionTypeId === '.email',
- pipeMigrations(setServiceConfigIfNotSet)
+ (doc): doc is SavedObjectUnsanitizedDoc =>
+ doc.attributes.actionTypeId === '.servicenow' ||
+ doc.attributes.actionTypeId === '.servicenow-sir' ||
+ doc.attributes.actionTypeId === '.email',
+ pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet)
);
const migrationActions800 = createEsoMigration(
@@ -79,7 +82,7 @@ export function getActionsMigrations(
'7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'),
'7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'),
'7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'),
- '7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'),
+ '7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'),
'8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'),
};
}
@@ -182,7 +185,7 @@ const setServiceConfigIfNotSet = (
};
};
-const addisMissingSecretsField = (
+const addIsMissingSecretsField = (
doc: SavedObjectUnsanitizedDoc
): SavedObjectUnsanitizedDoc => {
return {
@@ -194,6 +197,28 @@ const addisMissingSecretsField = (
};
};
+const markOldServiceNowITSMConnectorAsLegacy = (
+ doc: SavedObjectUnsanitizedDoc
+): SavedObjectUnsanitizedDoc => {
+ if (
+ doc.attributes.actionTypeId !== '.servicenow' &&
+ doc.attributes.actionTypeId !== '.servicenow-sir'
+ ) {
+ return doc;
+ }
+
+ return {
+ ...doc,
+ attributes: {
+ ...doc.attributes,
+ config: {
+ ...doc.attributes.config,
+ isLegacy: true,
+ },
+ },
+ };
+};
+
function pipeMigrations(...migrations: ActionMigration[]): ActionMigration {
return (doc: SavedObjectUnsanitizedDoc) =>
migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc);
diff --git a/x-pack/plugins/apm/dev_docs/apm_queries.md b/x-pack/plugins/apm/dev_docs/apm_queries.md
index 8508e5a173c85..0fbcd4fc1c8a8 100644
--- a/x-pack/plugins/apm/dev_docs/apm_queries.md
+++ b/x-pack/plugins/apm/dev_docs/apm_queries.md
@@ -1,7 +1,17 @@
-# Data model
+### Table of Contents
+ - [Transactions](#transactions)
+ - [System metrics](#system-metrics)
+ - [Transaction breakdown metrics](#transaction-breakdown-metrics)
+ - [Span breakdown metrics](#span-breakdown-metrics)
+ - [Service destination metrics](#service-destination-metrics)
+ - [Common filters](#common-filters)
+
+---
+
+### Data model
Elastic APM agents capture different types of information from within their instrumented applications. These are known as events, and can be spans, transactions, errors, or metrics. You can find more information [here](https://www.elastic.co/guide/en/apm/get-started/current/apm-data-model.html).
-# Running examples
+### Running examples
You can run the example queries on the [edge cluster](https://edge-oblt.elastic.dev/) or any another cluster that contains APM data.
# Transactions
@@ -307,7 +317,7 @@ The above example is overly simplified. In reality [we do a bit more](https://gi
-# Transaction breakdown metrics (`transaction_breakdown`)
+# Transaction breakdown metrics
A pre-aggregations of transaction documents where `transaction.breakdown.count` is the number of original transactions.
@@ -327,7 +337,7 @@ Noteworthy fields: `transaction.name`, `transaction.type`
}
```
-# Span breakdown metrics (`span_breakdown`)
+# Span breakdown metrics
A pre-aggregations of span documents where `span.self_time.count` is the number of original spans. Measures the "self-time" for a span type, and optional subtype, within a transaction group.
@@ -482,7 +492,7 @@ GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000
}
```
-## Common filters
+# Common filters
Most Elasticsearch queries will need to have one or more filters. There are a couple of reasons for adding filters:
diff --git a/x-pack/plugins/apm/dev_docs/testing.md b/x-pack/plugins/apm/dev_docs/testing.md
index 4d0edc27fe644..ba48e7e229e27 100644
--- a/x-pack/plugins/apm/dev_docs/testing.md
+++ b/x-pack/plugins/apm/dev_docs/testing.md
@@ -64,3 +64,13 @@ node scripts/functional_test_runner --config x-pack/test/functional/config.js --
APM tests are located in `x-pack/test/functional/apps/apm`.
For debugging access Elasticsearch on http://localhost:9220` (elastic/changeme)
diff --git a/x-pack/plugins/apm/scripts/test/README.md b/x-pack/plugins/apm/scripts/test/README.md
+
+
+## Storybook
+
+### Start
+```
+yarn storybook apm
+```
+
+All files with a .stories.tsx extension will be loaded. You can access the development environment at http://localhost:9001.
diff --git a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx
index 8a54c76df0f69..ee6a58b0dbb76 100644
--- a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx
+++ b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx
@@ -96,7 +96,7 @@ export function ChartPreview({
position={Position.Left}
tickFormat={yTickFormat}
ticks={5}
- domain={{ max: yMax }}
+ domain={{ max: yMax, min: NaN }}
/>
{
+ const onBrushEnd = ({ x }: XYBrushEvent) => {
if (!x) {
return;
}
@@ -99,7 +100,7 @@ export function PageLoadDistChart({
diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx
index a2a92b7e16f8e..12fa1c955ccc8 100644
--- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx
+++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/List.test.tsx
@@ -15,12 +15,6 @@ import props from './__fixtures__/props.json';
import { MemoryRouter } from 'react-router-dom';
import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common';
-jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => {
- return {
- htmlIdGenerator: () => () => `generated-id`,
- };
-});
-
describe('ErrorGroupOverview -> List', () => {
beforeAll(() => {
mockMoment();
diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap
index 890c692096a66..c8c7bf82dff04 100644
--- a/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap
+++ b/x-pack/plugins/apm/public/components/app/error_group_overview/List/__snapshots__/List.test.tsx.snap
@@ -56,7 +56,7 @@ exports[`ErrorGroupOverview -> List should render empty state 1`] = `
List should render with data 1`] = `
List should render with data 1`] = `
className="euiPagination__item"
>