diff --git a/package.json b/package.json index 8a4af324e8cd1..80593dd449da3 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,8 @@ "examples/*", "test/plugin_functional/plugins/*", "test/interpreter_functional/plugins/*", - "x-pack/test/functional_with_es_ssl/fixtures/plugins/*" + "x-pack/test/functional_with_es_ssl/fixtures/plugins/*", + "x-pack/test/plugin_api_integration/plugins/*" ], "nohoist": [ "**/@types/*", diff --git a/test/scripts/jenkins_xpack_build_kibana.sh b/test/scripts/jenkins_xpack_build_kibana.sh index 808df0df73591..8d3ef525e60e5 100755 --- a/test/scripts/jenkins_xpack_build_kibana.sh +++ b/test/scripts/jenkins_xpack_build_kibana.sh @@ -7,6 +7,7 @@ echo " -> building kibana platform plugins" node scripts/build_kibana_platform_plugins \ --scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \ --scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \ + --scan-dir "$XPACK_DIR/test/plugin_api_integration/plugins" \ --verbose; # doesn't persist, also set in kibanaPipeline.groovy diff --git a/x-pack/plugins/encrypted_saved_objects/README.md b/x-pack/plugins/encrypted_saved_objects/README.md index a352989870079..6085b52d392a4 100644 --- a/x-pack/plugins/encrypted_saved_objects/README.md +++ b/x-pack/plugins/encrypted_saved_objects/README.md @@ -100,10 +100,10 @@ $ node scripts/jest.js In one shell, from `kibana-root-folder/x-pack`: ```bash -$ node scripts/functional_tests_server.js --config test/plugin_api_integration/config.js +$ node scripts/functional_tests_server.js --config test/encrypted_saved_objects_api_integration/config.ts ``` In another shell, from `kibana-root-folder/x-pack`: ```bash -$ node ../scripts/functional_test_runner.js --config test/plugin_api_integration/config.js --grep="{TEST_NAME}" +$ node ../scripts/functional_test_runner.js --config test/encrypted_saved_objects_api_integration/config.ts --grep="{TEST_NAME}" ``` diff --git a/x-pack/plugins/event_log/common/index.ts b/x-pack/plugins/event_log/common/index.ts new file mode 100644 index 0000000000000..3ee274916c127 --- /dev/null +++ b/x-pack/plugins/event_log/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const BASE_EVENT_LOG_API_PATH = '/api/event_log'; diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts index 87e8fb0f521a9..bd57958b0cb88 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.mock.ts @@ -15,6 +15,7 @@ const createClusterClientMock = () => { createIndexTemplate: jest.fn(), doesAliasExist: jest.fn(), createIndex: jest.fn(), + queryEventsBySavedObject: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts index b61196439ee4f..ae26d7a7ece07 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.test.ts @@ -7,6 +7,8 @@ import { ClusterClient, Logger } from '../../../../../src/core/server'; import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; import { ClusterClientAdapter, IClusterClientAdapter } from './cluster_client_adapter'; +import moment from 'moment'; +import { findOptionsSchema } from '../event_log_client'; type EsClusterClient = Pick, 'callAsInternalUser' | 'asScoped'>; @@ -195,3 +197,230 @@ describe('createIndex', () => { await clusterClientAdapter.createIndex('foo'); }); }); + +describe('queryEventsBySavedObject', () => { + const DEFAULT_OPTIONS = findOptionsSchema.validate({}); + + test('should call cluster with proper arguments', async () => { + clusterClient.callAsInternalUser.mockResolvedValue({ + hits: { + hits: [], + total: { value: 0 }, + }, + }); + await clusterClientAdapter.queryEventsBySavedObject( + 'index-name', + 'saved-object-type', + 'saved-object-id', + DEFAULT_OPTIONS + ); + + const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(query).toMatchObject({ + index: 'index-name', + body: { + from: 0, + size: 10, + sort: { 'event.start': { order: 'asc' } }, + query: { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + term: { + 'kibana.saved_objects.id': { + value: 'saved-object-id', + }, + }, + }, + ], + }, + }, + }, + }, + ], + }, + }, + }, + }); + }); + + test('should call cluster with sort', async () => { + clusterClient.callAsInternalUser.mockResolvedValue({ + hits: { + hits: [], + total: { value: 0 }, + }, + }); + await clusterClientAdapter.queryEventsBySavedObject( + 'index-name', + 'saved-object-type', + 'saved-object-id', + { ...DEFAULT_OPTIONS, sort_field: 'event.end', sort_order: 'desc' } + ); + + const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(query).toMatchObject({ + index: 'index-name', + body: { + sort: { 'event.end': { order: 'desc' } }, + }, + }); + }); + + test('supports open ended date', async () => { + clusterClient.callAsInternalUser.mockResolvedValue({ + hits: { + hits: [], + total: { value: 0 }, + }, + }); + + const start = moment() + .subtract(1, 'days') + .toISOString(); + + await clusterClientAdapter.queryEventsBySavedObject( + 'index-name', + 'saved-object-type', + 'saved-object-id', + { ...DEFAULT_OPTIONS, start } + ); + + const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(query).toMatchObject({ + index: 'index-name', + body: { + query: { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + term: { + 'kibana.saved_objects.id': { + value: 'saved-object-id', + }, + }, + }, + ], + }, + }, + }, + }, + { + range: { + 'event.start': { + gte: start, + }, + }, + }, + ], + }, + }, + }, + }); + }); + + test('supports optional date range', async () => { + clusterClient.callAsInternalUser.mockResolvedValue({ + hits: { + hits: [], + total: { value: 0 }, + }, + }); + + const start = moment() + .subtract(1, 'days') + .toISOString(); + const end = moment() + .add(1, 'days') + .toISOString(); + + await clusterClientAdapter.queryEventsBySavedObject( + 'index-name', + 'saved-object-type', + 'saved-object-id', + { ...DEFAULT_OPTIONS, start, end } + ); + + const [method, query] = clusterClient.callAsInternalUser.mock.calls[0]; + expect(method).toEqual('search'); + expect(query).toMatchObject({ + index: 'index-name', + body: { + query: { + bool: { + must: [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.type': { + value: 'saved-object-type', + }, + }, + }, + { + term: { + 'kibana.saved_objects.id': { + value: 'saved-object-id', + }, + }, + }, + ], + }, + }, + }, + }, + { + range: { + 'event.start': { + gte: start, + }, + }, + }, + { + range: { + 'event.end': { + lte: end, + }, + }, + }, + ], + }, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts index d585fd4f539b5..36bc94edfca4e 100644 --- a/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts +++ b/x-pack/plugins/event_log/server/es/cluster_client_adapter.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { reject, isUndefined } from 'lodash'; import { Logger, ClusterClient } from '../../../../../src/core/server'; +import { IEvent } from '../types'; +import { FindOptionsType } from '../event_log_client'; export type EsClusterClient = Pick; export type IClusterClientAdapter = PublicMethodsOf; @@ -14,6 +17,13 @@ export interface ConstructorOpts { clusterClient: EsClusterClient; } +export interface QueryEventsBySavedObjectResult { + page: number; + per_page: number; + total: number; + data: IEvent[]; +} + export class ClusterClientAdapter { private readonly logger: Logger; private readonly clusterClient: EsClusterClient; @@ -107,6 +117,87 @@ export class ClusterClientAdapter { } } + public async queryEventsBySavedObject( + index: string, + type: string, + id: string, + { page, per_page: perPage, start, end, sort_field, sort_order }: FindOptionsType + ): Promise { + try { + const { + hits: { + hits, + total: { value: total }, + }, + } = await this.callEs('search', { + index, + body: { + size: perPage, + from: (page - 1) * perPage, + sort: { [sort_field]: { order: sort_order } }, + query: { + bool: { + must: reject( + [ + { + nested: { + path: 'kibana.saved_objects', + query: { + bool: { + must: [ + { + term: { + 'kibana.saved_objects.type': { + value: type, + }, + }, + }, + { + term: { + 'kibana.saved_objects.id': { + value: id, + }, + }, + }, + ], + }, + }, + }, + }, + start && { + range: { + 'event.start': { + gte: start, + }, + }, + }, + end && { + range: { + 'event.end': { + lte: end, + }, + }, + }, + ], + isUndefined + ), + }, + }, + }, + }); + return { + page, + per_page: perPage, + total, + data: hits.map((hit: any) => hit._source) as IEvent[], + }; + } catch (err) { + throw new Error( + `querying for Event Log by for type "${type}" and id "${id}" failed with: ${err.message}` + ); + } + } + private async callEs(operation: string, body?: any): Promise { try { this.debug(`callEs(${operation}) calls:`, body); diff --git a/x-pack/plugins/event_log/server/event_log_client.mock.ts b/x-pack/plugins/event_log/server/event_log_client.mock.ts new file mode 100644 index 0000000000000..31cab802555d0 --- /dev/null +++ b/x-pack/plugins/event_log/server/event_log_client.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEventLogClient } from './types'; + +const createEventLogClientMock = () => { + const mock: jest.Mocked = { + findEventsBySavedObject: jest.fn(), + }; + return mock; +}; + +export const eventLogClientMock = { + create: createEventLogClientMock, +}; diff --git a/x-pack/plugins/event_log/server/event_log_client.test.ts b/x-pack/plugins/event_log/server/event_log_client.test.ts new file mode 100644 index 0000000000000..6d4c9b67abc1b --- /dev/null +++ b/x-pack/plugins/event_log/server/event_log_client.test.ts @@ -0,0 +1,292 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventLogClient } from './event_log_client'; +import { contextMock } from './es/context.mock'; +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { merge } from 'lodash'; +import moment from 'moment'; + +describe('EventLogStart', () => { + describe('findEventsBySavedObject', () => { + test('verifies that the user can access the specified saved object', async () => { + const esContext = contextMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const eventLogClient = new EventLogClient({ + esContext, + savedObjectsClient, + }); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'saved-object-id', + type: 'saved-object-type', + attributes: {}, + references: [], + }); + + await eventLogClient.findEventsBySavedObject('saved-object-type', 'saved-object-id'); + + expect(savedObjectsClient.get).toHaveBeenCalledWith('saved-object-type', 'saved-object-id'); + }); + + test('throws when the user doesnt have permission to access the specified saved object', async () => { + const esContext = contextMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const eventLogClient = new EventLogClient({ + esContext, + savedObjectsClient, + }); + + savedObjectsClient.get.mockRejectedValue(new Error('Fail')); + + expect( + eventLogClient.findEventsBySavedObject('saved-object-type', 'saved-object-id') + ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + }); + + test('fetches all event that reference the saved object', async () => { + const esContext = contextMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const eventLogClient = new EventLogClient({ + esContext, + savedObjectsClient, + }); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'saved-object-id', + type: 'saved-object-type', + attributes: {}, + references: [], + }); + + const expectedEvents = [ + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '1', + }, + ], + }, + }), + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '2', + }, + ], + }, + }), + ]; + + const result = { + page: 0, + per_page: 10, + total: expectedEvents.length, + data: expectedEvents, + }; + esContext.esAdapter.queryEventsBySavedObject.mockResolvedValue(result); + + expect( + await eventLogClient.findEventsBySavedObject('saved-object-type', 'saved-object-id') + ).toEqual(result); + + expect(esContext.esAdapter.queryEventsBySavedObject).toHaveBeenCalledWith( + esContext.esNames.alias, + 'saved-object-type', + 'saved-object-id', + { + page: 1, + per_page: 10, + sort_field: 'event.start', + sort_order: 'asc', + } + ); + }); + + test('fetches all events in time frame that reference the saved object', async () => { + const esContext = contextMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const eventLogClient = new EventLogClient({ + esContext, + savedObjectsClient, + }); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'saved-object-id', + type: 'saved-object-type', + attributes: {}, + references: [], + }); + + const expectedEvents = [ + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '1', + }, + ], + }, + }), + fakeEvent({ + kibana: { + saved_objects: [ + { + id: 'saved-object-id', + type: 'saved-object-type', + }, + { + type: 'action', + id: '2', + }, + ], + }, + }), + ]; + + const result = { + page: 0, + per_page: 10, + total: expectedEvents.length, + data: expectedEvents, + }; + esContext.esAdapter.queryEventsBySavedObject.mockResolvedValue(result); + + const start = moment() + .subtract(1, 'days') + .toISOString(); + const end = moment() + .add(1, 'days') + .toISOString(); + + expect( + await eventLogClient.findEventsBySavedObject('saved-object-type', 'saved-object-id', { + start, + end, + }) + ).toEqual(result); + + expect(esContext.esAdapter.queryEventsBySavedObject).toHaveBeenCalledWith( + esContext.esNames.alias, + 'saved-object-type', + 'saved-object-id', + { + page: 1, + per_page: 10, + sort_field: 'event.start', + sort_order: 'asc', + start, + end, + } + ); + }); + + test('validates that the start date is valid', async () => { + const esContext = contextMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const eventLogClient = new EventLogClient({ + esContext, + savedObjectsClient, + }); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'saved-object-id', + type: 'saved-object-type', + attributes: {}, + references: [], + }); + + esContext.esAdapter.queryEventsBySavedObject.mockResolvedValue({ + page: 0, + per_page: 0, + total: 0, + data: [], + }); + + expect( + eventLogClient.findEventsBySavedObject('saved-object-type', 'saved-object-id', { + start: 'not a date string', + }) + ).rejects.toMatchInlineSnapshot(`[Error: [start]: Invalid Date]`); + }); + + test('validates that the end date is valid', async () => { + const esContext = contextMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + const eventLogClient = new EventLogClient({ + esContext, + savedObjectsClient, + }); + + savedObjectsClient.get.mockResolvedValueOnce({ + id: 'saved-object-id', + type: 'saved-object-type', + attributes: {}, + references: [], + }); + + esContext.esAdapter.queryEventsBySavedObject.mockResolvedValue({ + page: 0, + per_page: 0, + total: 0, + data: [], + }); + + expect( + eventLogClient.findEventsBySavedObject('saved-object-type', 'saved-object-id', { + end: 'not a date string', + }) + ).rejects.toMatchInlineSnapshot(`[Error: [end]: Invalid Date]`); + }); + }); +}); + +function fakeEvent(overrides = {}) { + return merge( + { + event: { + provider: 'actions', + action: 'execute', + start: '2020-03-30T14:55:47.054Z', + end: '2020-03-30T14:55:47.055Z', + duration: 1000000, + }, + kibana: { + namespace: 'default', + saved_objects: [ + { + type: 'action', + id: '968f1b82-0414-4a10-becc-56b6473e4a29', + }, + ], + server_uuid: '5b2de169-2785-441b-ae8c-186a1936b17d', + }, + message: 'action executed: .server-log:968f1b82-0414-4a10-becc-56b6473e4a29: logger', + '@timestamp': '2020-03-30T14:55:47.055Z', + ecs: { + version: '1.3.1', + }, + }, + overrides + ); +} diff --git a/x-pack/plugins/event_log/server/event_log_client.ts b/x-pack/plugins/event_log/server/event_log_client.ts new file mode 100644 index 0000000000000..765f0895f8e0d --- /dev/null +++ b/x-pack/plugins/event_log/server/event_log_client.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { ClusterClient, SavedObjectsClientContract } from 'src/core/server'; + +import { schema, TypeOf } from '@kbn/config-schema'; +import { EsContext } from './es'; +import { IEventLogClient } from './types'; +import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; +export type PluginClusterClient = Pick; +export type AdminClusterClient$ = Observable; + +interface EventLogServiceCtorParams { + esContext: EsContext; + savedObjectsClient: SavedObjectsClientContract; +} + +const optionalDateFieldSchema = schema.maybe( + schema.string({ + validate(value) { + if (isNaN(Date.parse(value))) { + return 'Invalid Date'; + } + }, + }) +); + +export const findOptionsSchema = schema.object({ + per_page: schema.number({ defaultValue: 10, min: 0 }), + page: schema.number({ defaultValue: 1, min: 1 }), + start: optionalDateFieldSchema, + end: optionalDateFieldSchema, + sort_field: schema.oneOf( + [ + schema.literal('event.start'), + schema.literal('event.end'), + schema.literal('event.provider'), + schema.literal('event.duration'), + schema.literal('event.action'), + schema.literal('message'), + ], + { + defaultValue: 'event.start', + } + ), + sort_order: schema.oneOf([schema.literal('asc'), schema.literal('desc')], { + defaultValue: 'asc', + }), +}); +// page & perPage are required, other fields are optional +// using schema.maybe allows us to set undefined, but not to make the field optional +export type FindOptionsType = Pick< + TypeOf, + 'page' | 'per_page' | 'sort_field' | 'sort_order' +> & + Partial>; + +// note that clusterClient may be null, indicating we can't write to ES +export class EventLogClient implements IEventLogClient { + private esContext: EsContext; + private savedObjectsClient: SavedObjectsClientContract; + + constructor({ esContext, savedObjectsClient }: EventLogServiceCtorParams) { + this.esContext = esContext; + this.savedObjectsClient = savedObjectsClient; + } + + async findEventsBySavedObject( + type: string, + id: string, + options?: Partial + ): Promise { + // verify the user has the required permissions to view this saved object + await this.savedObjectsClient.get(type, id); + return await this.esContext.esAdapter.queryEventsBySavedObject( + this.esContext.esNames.alias, + type, + id, + findOptionsSchema.validate(options ?? {}) + ); + } +} diff --git a/x-pack/plugins/event_log/server/event_log_start_service.mock.ts b/x-pack/plugins/event_log/server/event_log_start_service.mock.ts new file mode 100644 index 0000000000000..e99ec777b473b --- /dev/null +++ b/x-pack/plugins/event_log/server/event_log_start_service.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEventLogClientService } from './types'; + +const createEventLogServiceMock = () => { + const mock: jest.Mocked = { + getClient: jest.fn(), + }; + return mock; +}; + +export const eventLogStartServiceMock = { + create: createEventLogServiceMock, +}; diff --git a/x-pack/plugins/event_log/server/event_log_start_service.test.ts b/x-pack/plugins/event_log/server/event_log_start_service.test.ts new file mode 100644 index 0000000000000..a8d75bc6c2e5a --- /dev/null +++ b/x-pack/plugins/event_log/server/event_log_start_service.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EventLogClientService } from './event_log_start_service'; +import { contextMock } from './es/context.mock'; +import { KibanaRequest } from 'kibana/server'; +import { savedObjectsServiceMock } from 'src/core/server/saved_objects/saved_objects_service.mock'; +import { savedObjectsClientMock } from 'src/core/server/mocks'; + +jest.mock('./event_log_client'); + +describe('EventLogClientService', () => { + const esContext = contextMock.create(); + + describe('getClient', () => { + test('creates a client with a scoped SavedObjects client', () => { + const savedObjectsService = savedObjectsServiceMock.createStartContract(); + const request = fakeRequest(); + + const eventLogStartService = new EventLogClientService({ + esContext, + savedObjectsService, + }); + + eventLogStartService.getClient(request); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request); + + const [{ value: savedObjectsClient }] = savedObjectsService.getScopedClient.mock.results; + + expect(jest.requireMock('./event_log_client').EventLogClient).toHaveBeenCalledWith({ + esContext, + savedObjectsClient, + }); + }); + }); +}); + +function fakeRequest(): KibanaRequest { + const savedObjectsClient = savedObjectsClientMock.create(); + return { + headers: {}, + getBasePath: () => '', + path: '/', + route: { settings: {} }, + url: { + href: '/', + }, + raw: { + req: { + url: '/', + }, + }, + getSavedObjectsClient: () => savedObjectsClient, + } as any; +} diff --git a/x-pack/plugins/event_log/server/event_log_start_service.ts b/x-pack/plugins/event_log/server/event_log_start_service.ts new file mode 100644 index 0000000000000..5938f7a2e614e --- /dev/null +++ b/x-pack/plugins/event_log/server/event_log_start_service.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { Observable } from 'rxjs'; +import { + ClusterClient, + KibanaRequest, + SavedObjectsServiceStart, + SavedObjectsClientContract, +} from 'src/core/server'; + +import { EsContext } from './es'; +import { IEventLogClientService } from './types'; +import { EventLogClient } from './event_log_client'; +export type PluginClusterClient = Pick; +export type AdminClusterClient$ = Observable; + +interface EventLogServiceCtorParams { + esContext: EsContext; + savedObjectsService: SavedObjectsServiceStart; +} + +// note that clusterClient may be null, indicating we can't write to ES +export class EventLogClientService implements IEventLogClientService { + private esContext: EsContext; + private savedObjectsService: SavedObjectsServiceStart; + + constructor({ esContext, savedObjectsService }: EventLogServiceCtorParams) { + this.esContext = esContext; + this.savedObjectsService = savedObjectsService; + } + + getClient( + request: KibanaRequest, + savedObjectsClient: SavedObjectsClientContract = this.savedObjectsService.getScopedClient( + request + ) + ) { + return new EventLogClient({ + esContext: this.esContext, + savedObjectsClient, + }); + } +} diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts index 81a56faa49964..b7fa25cb6eb9c 100644 --- a/x-pack/plugins/event_log/server/index.ts +++ b/x-pack/plugins/event_log/server/index.ts @@ -8,6 +8,6 @@ import { PluginInitializerContext } from 'src/core/server'; import { ConfigSchema } from './types'; import { Plugin } from './plugin'; -export { IEventLogService, IEventLogger, IEvent } from './types'; +export { IEventLogService, IEventLogger, IEventLogClientService, IEvent } from './types'; export const config = { schema: ConfigSchema }; export const plugin = (context: PluginInitializerContext) => new Plugin(context); diff --git a/x-pack/plugins/event_log/server/mocks.ts b/x-pack/plugins/event_log/server/mocks.ts index aad6cf3e24561..2f632a52d2f36 100644 --- a/x-pack/plugins/event_log/server/mocks.ts +++ b/x-pack/plugins/event_log/server/mocks.ts @@ -5,8 +5,9 @@ */ import { eventLogServiceMock } from './event_log_service.mock'; +import { eventLogStartServiceMock } from './event_log_start_service.mock'; -export { eventLogServiceMock }; +export { eventLogServiceMock, eventLogStartServiceMock }; export { eventLoggerMock } from './event_logger.mock'; const createSetupMock = () => { @@ -14,7 +15,7 @@ const createSetupMock = () => { }; const createStartMock = () => { - return undefined; + return eventLogStartServiceMock.create(); }; export const eventLogMock = { diff --git a/x-pack/plugins/event_log/server/plugin.ts b/x-pack/plugins/event_log/server/plugin.ts index fdb08b2d090a6..2cc41354b4fbc 100644 --- a/x-pack/plugins/event_log/server/plugin.ts +++ b/x-pack/plugins/event_log/server/plugin.ts @@ -14,11 +14,21 @@ import { PluginInitializerContext, ClusterClient, SharedGlobalConfig, + IContextProvider, + RequestHandler, } from 'src/core/server'; -import { IEventLogConfig, IEventLogService, IEventLogger, IEventLogConfig$ } from './types'; +import { + IEventLogConfig, + IEventLogService, + IEventLogger, + IEventLogConfig$, + IEventLogClientService, +} from './types'; +import { findRoute } from './routes'; import { EventLogService } from './event_log_service'; import { createEsContext, EsContext } from './es'; +import { EventLogClientService } from './event_log_start_service'; export type PluginClusterClient = Pick; @@ -29,13 +39,14 @@ const ACTIONS = { stopping: 'stopping', }; -export class Plugin implements CorePlugin { +export class Plugin implements CorePlugin { private readonly config$: IEventLogConfig$; private systemLogger: Logger; private eventLogService?: IEventLogService; private esContext?: EsContext; private eventLogger?: IEventLogger; private globalConfig$: Observable; + private eventLogClientService?: EventLogClientService; constructor(private readonly context: PluginInitializerContext) { this.systemLogger = this.context.logger.get(); @@ -71,10 +82,17 @@ export class Plugin implements CorePlugin { event: { provider: PROVIDER }, }); + core.http.registerRouteHandlerContext('eventLog', this.createRouteHandlerContext()); + + // Routes + const router = core.http.createRouter(); + // Register routes + findRoute(router); + return this.eventLogService; } - async start(core: CoreStart) { + async start(core: CoreStart): Promise { this.systemLogger.debug('starting plugin'); if (!this.esContext) throw new Error('esContext not initialized'); @@ -91,8 +109,26 @@ export class Plugin implements CorePlugin { event: { action: ACTIONS.starting }, message: 'eventLog starting', }); + + this.eventLogClientService = new EventLogClientService({ + esContext: this.esContext, + savedObjectsService: core.savedObjects, + }); + return this.eventLogClientService; } + private createRouteHandlerContext = (): IContextProvider< + RequestHandler, + 'eventLog' + > => { + return async (context, request) => { + return { + getEventLogClient: () => + this.eventLogClientService!.getClient(request, context.core.savedObjects.client), + }; + }; + }; + stop() { this.systemLogger.debug('stopping plugin'); diff --git a/x-pack/plugins/event_log/server/routes/_mock_handler_arguments.ts b/x-pack/plugins/event_log/server/routes/_mock_handler_arguments.ts new file mode 100644 index 0000000000000..6640683bf6005 --- /dev/null +++ b/x-pack/plugins/event_log/server/routes/_mock_handler_arguments.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext, KibanaRequest, KibanaResponseFactory } from 'kibana/server'; +import { identity, merge } from 'lodash'; +import { httpServerMock } from '../../../../../src/core/server/mocks'; +import { IEventLogClient } from '../types'; + +export function mockHandlerArguments( + eventLogClient: IEventLogClient, + req: any, + res?: Array> +): [RequestHandlerContext, KibanaRequest, KibanaResponseFactory] { + return [ + ({ + eventLog: { + getEventLogClient() { + return eventLogClient; + }, + }, + } as unknown) as RequestHandlerContext, + req as KibanaRequest, + mockResponseFactory(res), + ]; +} + +export const mockResponseFactory = (resToMock: Array> = []) => { + const factory: jest.Mocked = httpServerMock.createResponseFactory(); + resToMock.forEach((key: string) => { + if (key in factory) { + Object.defineProperty(factory, key, { + value: jest.fn(identity), + }); + } + }); + return (factory as unknown) as KibanaResponseFactory; +}; + +export function fakeEvent(overrides = {}) { + return merge( + { + event: { + provider: 'actions', + action: 'execute', + start: '2020-03-30T14:55:47.054Z', + end: '2020-03-30T14:55:47.055Z', + duration: 1000000, + }, + kibana: { + namespace: 'default', + saved_objects: [ + { + type: 'action', + id: '968f1b82-0414-4a10-becc-56b6473e4a29', + }, + ], + server_uuid: '5b2de169-2785-441b-ae8c-186a1936b17d', + }, + message: 'action executed: .server-log:968f1b82-0414-4a10-becc-56b6473e4a29: logger', + '@timestamp': '2020-03-30T14:55:47.055Z', + ecs: { + version: '1.3.1', + }, + }, + overrides + ); +} diff --git a/x-pack/plugins/event_log/server/routes/find.test.ts b/x-pack/plugins/event_log/server/routes/find.test.ts new file mode 100644 index 0000000000000..844a84dc117a9 --- /dev/null +++ b/x-pack/plugins/event_log/server/routes/find.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { findRoute } from './find'; +import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock'; +import { mockHandlerArguments, fakeEvent } from './_mock_handler_arguments'; +import { eventLogClientMock } from '../event_log_client.mock'; + +const eventLogClient = eventLogClientMock.create(); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('find', () => { + it('finds events with proper parameters', async () => { + const router: RouterMock = mockRouter.create(); + + findRoute(router); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/event_log/{type}/{id}/_find"`); + + const events = [fakeEvent(), fakeEvent()]; + const result = { + page: 0, + per_page: 10, + total: events.length, + data: events, + }; + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(result); + + const [context, req, res] = mockHandlerArguments( + eventLogClient, + { + params: { id: '1', type: 'action' }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + + const [type, id] = eventLogClient.findEventsBySavedObject.mock.calls[0]; + expect(type).toEqual(`action`); + expect(id).toEqual(`1`); + + expect(res.ok).toHaveBeenCalledWith({ + body: result, + }); + }); + + it('supports optional pagination parameters', async () => { + const router: RouterMock = mockRouter.create(); + + findRoute(router); + + const [, handler] = router.get.mock.calls[0]; + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce({ + page: 0, + per_page: 10, + total: 0, + data: [], + }); + + const [context, req, res] = mockHandlerArguments( + eventLogClient, + { + params: { id: '1', type: 'action' }, + query: { page: 3, per_page: 10 }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + + const [type, id, options] = eventLogClient.findEventsBySavedObject.mock.calls[0]; + expect(type).toEqual(`action`); + expect(id).toEqual(`1`); + expect(options).toMatchObject({}); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + page: 0, + per_page: 10, + total: 0, + data: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/event_log/server/routes/find.ts b/x-pack/plugins/event_log/server/routes/find.ts new file mode 100644 index 0000000000000..cb170e50fb447 --- /dev/null +++ b/x-pack/plugins/event_log/server/routes/find.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; +import { BASE_EVENT_LOG_API_PATH } from '../../common'; +import { findOptionsSchema, FindOptionsType } from '../event_log_client'; + +const paramSchema = schema.object({ + type: schema.string(), + id: schema.string(), +}); + +export const findRoute = (router: IRouter) => { + router.get( + { + path: `${BASE_EVENT_LOG_API_PATH}/{type}/{id}/_find`, + validate: { + params: paramSchema, + query: findOptionsSchema, + }, + }, + router.handleLegacyErrors(async function( + context: RequestHandlerContext, + req: KibanaRequest, FindOptionsType, any, any>, + res: KibanaResponseFactory + ): Promise> { + if (!context.eventLog) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for eventLog' }); + } + const eventLogClient = context.eventLog.getEventLogClient(); + const { + params: { id, type }, + query, + } = req; + return res.ok({ + body: await eventLogClient.findEventsBySavedObject(type, id, query), + }); + }) + ); +}; diff --git a/x-pack/plugins/event_log/server/routes/index.ts b/x-pack/plugins/event_log/server/routes/index.ts new file mode 100644 index 0000000000000..85d9b3e0db8cd --- /dev/null +++ b/x-pack/plugins/event_log/server/routes/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { findRoute } from './find'; diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index f606bb2be6c6c..baf53ef447914 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -8,7 +8,10 @@ import { Observable } from 'rxjs'; import { schema, TypeOf } from '@kbn/config-schema'; export { IEvent, IValidatedEvent, EventSchema, ECS_VERSION } from '../generated/schemas'; +import { KibanaRequest } from 'kibana/server'; import { IEvent } from '../generated/schemas'; +import { FindOptionsType } from './event_log_client'; +import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -19,6 +22,14 @@ export const ConfigSchema = schema.object({ export type IEventLogConfig = TypeOf; export type IEventLogConfig$ = Observable>; +declare module 'src/core/server' { + interface RequestHandlerContext { + eventLog?: { + getEventLogClient: () => IEventLogClient; + }; + } +} + // the object exposed by plugin.setup() export interface IEventLogService { isEnabled(): boolean; @@ -31,6 +42,18 @@ export interface IEventLogService { getLogger(properties: IEvent): IEventLogger; } +export interface IEventLogClientService { + getClient(request: KibanaRequest): IEventLogClient; +} + +export interface IEventLogClient { + findEventsBySavedObject( + type: string, + id: string, + options?: Partial + ): Promise; +} + export interface IEventLogger { logEvent(properties: IEvent): void; startTiming(event: IEvent): void; diff --git a/x-pack/plugins/task_manager/server/README.md b/x-pack/plugins/task_manager/server/README.md index a4154f3ecf212..c3d45be5d8f22 100644 --- a/x-pack/plugins/task_manager/server/README.md +++ b/x-pack/plugins/task_manager/server/README.md @@ -456,6 +456,6 @@ The task manager's public API is create / delete / list. Updates aren't directly ``` - Integration tests: ``` - node scripts/functional_tests_server.js --config x-pack/test/plugin_api_integration/config.js - node scripts/functional_test_runner --config x-pack/test/plugin_api_integration/config.js + node scripts/functional_tests_server.js --config x-pack/test/plugin_api_integration/config.ts + node scripts/functional_test_runner --config x-pack/test/plugin_api_integration/config.ts ``` diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index f4daa801fa5da..757c8e0eaea7f 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -18,7 +18,7 @@ require('@kbn/test').runTestsCli([ require.resolve('../test/alerting_api_integration/spaces_only/config.ts'), require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'), require.resolve('../test/detection_engine_api_integration/security_and_spaces/config.ts'), - require.resolve('../test/plugin_api_integration/config.js'), + require.resolve('../test/plugin_api_integration/config.ts'), require.resolve('../test/plugin_functional/config'), require.resolve('../test/kerberos_api_integration/config'), require.resolve('../test/kerberos_api_integration/anonymous_access.config'), diff --git a/x-pack/test/plugin_api_integration/config.js b/x-pack/test/plugin_api_integration/config.ts similarity index 77% rename from x-pack/test/plugin_api_integration/config.js rename to x-pack/test/plugin_api_integration/config.ts index 83e8b1f84a9e0..c581e0c246e13 100644 --- a/x-pack/test/plugin_api_integration/config.js +++ b/x-pack/test/plugin_api_integration/config.ts @@ -6,9 +6,10 @@ import path from 'path'; import fs from 'fs'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { services } from './services'; -export default async function({ readConfigFile }) { +export default async function({ readConfigFile }: FtrConfigProviderContext) { const integrationConfig = await readConfigFile(require.resolve('../api_integration/config')); // Find all folders in ./plugins since we treat all them as plugin folder @@ -18,7 +19,10 @@ export default async function({ readConfigFile }) { ); return { - testFiles: [require.resolve('./test_suites/task_manager')], + testFiles: [ + require.resolve('./test_suites/task_manager'), + require.resolve('./test_suites/event_log'), + ], services, servers: integrationConfig.get('servers'), esTestCluster: integrationConfig.get('esTestCluster'), @@ -34,6 +38,9 @@ export default async function({ readConfigFile }) { ...integrationConfig.get('kbnTestServer'), serverArgs: [ ...integrationConfig.get('kbnTestServer.serverArgs'), + '--xpack.eventLog.enabled=true', + '--xpack.eventLog.logEntries=true', + '--xpack.eventLog.indexEntries=true', ...plugins.map( pluginDir => `--plugin-path=${path.resolve(__dirname, 'plugins', pluginDir)}` ), diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json new file mode 100644 index 0000000000000..4b467ce975012 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/event_log/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "event_log_fixture", + "version": "1.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack"], + "requiredPlugins": ["eventLog"], + "server": true, + "ui": false +} diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/package.json b/x-pack/test/plugin_api_integration/plugins/event_log/package.json new file mode 100644 index 0000000000000..222dfb2338e20 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/event_log/package.json @@ -0,0 +1,15 @@ +{ + "name": "event_log_fixture", + "version": "0.0.0", + "kibana": { + "version": "kibana" + }, + "main": "target/test/plugin_api_integration/plugins/event_log", + "scripts": { + "kbn": "node ../../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/server/index.ts b/x-pack/test/plugin_api_integration/plugins/event_log/server/index.ts new file mode 100644 index 0000000000000..3d794a7c80fa3 --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/event_log/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'kibana/server'; +import { EventLogFixturePlugin } from './plugin'; + +export const plugin = (initContext: PluginInitializerContext) => + new EventLogFixturePlugin(initContext); diff --git a/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts b/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts new file mode 100644 index 0000000000000..eccbd4fb7f90b --- /dev/null +++ b/x-pack/test/plugin_api_integration/plugins/event_log/server/plugin.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Plugin, + CoreSetup, + RequestHandlerContext, + KibanaRequest, + KibanaResponseFactory, + IKibanaResponse, + IRouter, + Logger, + PluginInitializerContext, + RouteValidationResultFactory, +} from 'kibana/server'; +import { + IEventLogService, + IEventLogClientService, + IEventLogger, +} from '../../../../../plugins/event_log/server'; +import { IValidatedEvent } from '../../../../../plugins/event_log/server/types'; + +// this plugin's dependendencies +export interface EventLogFixtureSetupDeps { + eventLog: IEventLogService; +} +export interface EventLogFixtureStartDeps { + eventLog: IEventLogClientService; +} + +export class EventLogFixturePlugin + implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get('plugins', 'eventLogFixture'); + } + + public setup(core: CoreSetup, { eventLog }: EventLogFixtureSetupDeps) { + const router = core.http.createRouter(); + + eventLog.registerProviderActions('event_log_fixture', ['test']); + const eventLogger = eventLog.getLogger({ + event: { provider: 'event_log_fixture' }, + }); + + core.savedObjects.registerType({ + name: 'event_log_test', + hidden: false, + namespaceAgnostic: true, + mappings: { + properties: {}, + }, + }); + + logEventRoute(router, eventLogger, this.logger); + } + + public start() {} + public stop() {} +} + +const logEventRoute = (router: IRouter, eventLogger: IEventLogger, logger: Logger) => { + router.post( + { + path: `/api/log_event_fixture/{id}/_log`, + validate: { + // removed validation as schema is currently broken in tests + // blocked by: https://github.com/elastic/kibana/issues/61652 + params: (value: any, { ok }: RouteValidationResultFactory) => ok(value), + body: (value: any, { ok }: RouteValidationResultFactory) => ok(value), + }, + }, + async function( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + const { id } = req.params as { id: string }; + const event: IValidatedEvent = req.body; + logger.info(`test fixture: log event: ${id} ${JSON.stringify(event)}`); + try { + await context.core.savedObjects.client.get('event_log_test', id); + logger.info(`found existing saved object`); + } catch (ex) { + logger.info(`log event error: ${ex}`); + await context.core.savedObjects.client.create('event_log_test', {}, { id }); + logger.info(`created saved object`); + } + eventLogger.logEvent(event); + logger.info(`logged`); + return res.ok({}); + } + ); +}; diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/index.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/index.ts new file mode 100644 index 0000000000000..a68378decb1fd --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('event_log', function taskManagerSuite() { + this.tags('ciGroup2'); + loadTestFile(require.resolve('./public_api_integration')); + loadTestFile(require.resolve('./service_api_integration')); + }); +} diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts new file mode 100644 index 0000000000000..c440971225d78 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/public_api_integration.ts @@ -0,0 +1,236 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { merge, omit, times, chunk, isEmpty } from 'lodash'; +import uuid from 'uuid'; +import expect from '@kbn/expect/expect.js'; +import moment, { Moment } from 'moment'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { IEvent } from '../../../../plugins/event_log/server'; +import { IValidatedEvent } from '../../../../plugins/event_log/server/types'; + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export default function({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const log = getService('log'); + const retry = getService('retry'); + + describe('Event Log public API', () => { + it('should allow querying for events by Saved Object', async () => { + const id = uuid.v4(); + + const expectedEvents = [fakeEvent(id), fakeEvent(id)]; + + await logTestEvent(id, expectedEvents[0]); + await logTestEvent(id, expectedEvents[1]); + + await retry.try(async () => { + const { + body: { data, total }, + } = await findEvents(id, {}); + + expect(data.length).to.be(2); + expect(total).to.be(2); + + assertEventsFromApiMatchCreatedEvents(data, expectedEvents); + }); + }); + + it('should support pagination for events', async () => { + const id = uuid.v4(); + + const timestamp = moment(); + const [firstExpectedEvent, ...expectedEvents] = times(6, () => + fakeEvent(id, fakeEventTiming(timestamp.add(1, 's'))) + ); + // run one first to create the SO and avoid clashes + await logTestEvent(id, firstExpectedEvent); + await Promise.all(expectedEvents.map(event => logTestEvent(id, event))); + + await retry.try(async () => { + const { + body: { data: foundEvents }, + } = await findEvents(id, {}); + + expect(foundEvents.length).to.be(6); + }); + + const [expectedFirstPage, expectedSecondPage] = chunk( + [firstExpectedEvent, ...expectedEvents], + 3 + ); + + const { + body: { data: firstPage }, + } = await findEvents(id, { per_page: 3 }); + + expect(firstPage.length).to.be(3); + assertEventsFromApiMatchCreatedEvents(firstPage, expectedFirstPage); + + const { + body: { data: secondPage }, + } = await findEvents(id, { per_page: 3, page: 2 }); + + expect(secondPage.length).to.be(3); + assertEventsFromApiMatchCreatedEvents(secondPage, expectedSecondPage); + }); + + it('should support sorting by event end', async () => { + const id = uuid.v4(); + + const timestamp = moment(); + const [firstExpectedEvent, ...expectedEvents] = times(6, () => + fakeEvent(id, fakeEventTiming(timestamp.add(1, 's'))) + ); + // run one first to create the SO and avoid clashes + await logTestEvent(id, firstExpectedEvent); + await Promise.all(expectedEvents.map(event => logTestEvent(id, event))); + + await retry.try(async () => { + const { + body: { data: foundEvents }, + } = await findEvents(id, { sort_field: 'event.end', sort_order: 'desc' }); + + expect(foundEvents.length).to.be(6); + assertEventsFromApiMatchCreatedEvents( + foundEvents, + [firstExpectedEvent, ...expectedEvents].reverse() + ); + }); + }); + + it('should support date ranges for events', async () => { + const id = uuid.v4(); + + const timestamp = moment(); + + const firstEvent = fakeEvent(id, fakeEventTiming(timestamp)); + await logTestEvent(id, firstEvent); + await delay(100); + + const start = timestamp.add(1, 's').toISOString(); + + const expectedEvents = times(6, () => fakeEvent(id, fakeEventTiming(timestamp.add(1, 's')))); + await Promise.all(expectedEvents.map(event => logTestEvent(id, event))); + + const end = timestamp.add(1, 's').toISOString(); + + await delay(100); + const lastEvent = fakeEvent(id, fakeEventTiming(timestamp.add(1, 's'))); + await logTestEvent(id, lastEvent); + + await retry.try(async () => { + const { + body: { data: foundEvents, total }, + } = await findEvents(id, {}); + + expect(foundEvents.length).to.be(8); + expect(total).to.be(8); + }); + + const { + body: { data: eventsWithinRange }, + } = await findEvents(id, { start, end }); + + expect(eventsWithinRange.length).to.be(expectedEvents.length); + assertEventsFromApiMatchCreatedEvents(eventsWithinRange, expectedEvents); + + const { + body: { data: eventsFrom }, + } = await findEvents(id, { start }); + + expect(eventsFrom.length).to.be(expectedEvents.length + 1); + assertEventsFromApiMatchCreatedEvents(eventsFrom, [...expectedEvents, lastEvent]); + + const { + body: { data: eventsUntil }, + } = await findEvents(id, { end }); + + expect(eventsUntil.length).to.be(expectedEvents.length + 1); + assertEventsFromApiMatchCreatedEvents(eventsUntil, [firstEvent, ...expectedEvents]); + }); + }); + + async function findEvents(id: string, query: Record = {}) { + const uri = `/api/event_log/event_log_test/${id}/_find${ + isEmpty(query) + ? '' + : `?${Object.entries(query) + .map(([key, val]) => `${key}=${val}`) + .join('&')}` + }`; + log.debug(`calling ${uri}`); + return await supertest + .get(uri) + .set('kbn-xsrf', 'foo') + .expect(200); + } + + function assertEventsFromApiMatchCreatedEvents( + foundEvents: IValidatedEvent[], + expectedEvents: IEvent[] + ) { + try { + foundEvents.forEach((foundEvent: IValidatedEvent, index: number) => { + expect(foundEvent!.event).to.eql(expectedEvents[index]!.event); + expect(omit(foundEvent!.kibana ?? {}, 'server_uuid')).to.eql(expectedEvents[index]!.kibana); + expect(foundEvent!.message).to.eql(expectedEvents[index]!.message); + }); + } catch (ex) { + log.debug(`failed to match ${JSON.stringify({ foundEvents, expectedEvents })}`); + throw ex; + } + } + + async function logTestEvent(id: string, event: IEvent) { + log.debug(`Logging Event for Saved Object ${id}`); + return await supertest + .post(`/api/log_event_fixture/${id}/_log`) + .set('kbn-xsrf', 'foo') + .send(event) + .expect(200); + } + + function fakeEventTiming(start: Moment): Partial { + return { + event: { + start: start.toISOString(), + end: start + .clone() + .add(500, 'milliseconds') + .toISOString(), + }, + }; + } + + function fakeEvent(id: string, overrides: Partial = {}): IEvent { + const start = moment().toISOString(); + const end = moment().toISOString(); + return merge( + { + event: { + provider: 'event_log_fixture', + action: 'test', + start, + end, + duration: 1000000, + }, + kibana: { + namespace: 'default', + saved_objects: [ + { + type: 'event_log_test', + id, + }, + ], + }, + message: `test ${moment().toISOString()}`, + }, + overrides + ); + } +} diff --git a/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts new file mode 100644 index 0000000000000..b055b22879bf9 --- /dev/null +++ b/x-pack/test/plugin_api_integration/test_suites/event_log/service_api_integration.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function() { + describe('Event Log service API', () => { + it('should allow logging an event', async () => {}); + }); +}