[eventLog] search for actions/alerts as hidden saved objects (#70395)

resolves https://github.com/elastic/kibana/issues/70086

Configures the saved object client for the event log to access the recently
hidden action and alert saved objects.

We didn't have tests for action/alert event log activity, so added some now.

Also found a buglet that was preventing access to event log data from actions
and alerts in non-default spaces.
This commit is contained in:
Patrick Mueller 2020-07-16 09:10:51 -04:00 committed by GitHub
parent fbf54f0023
commit b167d77e3e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1248 additions and 393 deletions

View file

@ -3,6 +3,7 @@
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["xpack", "eventLog"],
"optionalPlugins": ["spaces"],
"server": true,
"ui": false
}

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { LegacyClusterClient, Logger } from '../../../../../src/core/server';
import { elasticsearchServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks';
import { LegacyClusterClient, Logger } from 'src/core/server';
import { elasticsearchServiceMock, loggingSystemMock } 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<jest.Mocked<LegacyClusterClient>, 'callAsInternalUser' | 'asScoped'>;
@ -205,7 +204,7 @@ describe('createIndex', () => {
describe('queryEventsBySavedObject', () => {
const DEFAULT_OPTIONS = findOptionsSchema.validate({});
test('should call cluster with proper arguments', async () => {
test('should call cluster with proper arguments with non-default namespace', async () => {
clusterClient.callAsInternalUser.mockResolvedValue({
hits: {
hits: [],
@ -214,6 +213,7 @@ describe('queryEventsBySavedObject', () => {
});
await clusterClientAdapter.queryEventsBySavedObject(
'index-name',
'namespace',
'saved-object-type',
'saved-object-id',
DEFAULT_OPTIONS
@ -221,52 +221,147 @@ describe('queryEventsBySavedObject', () => {
const [method, query] = clusterClient.callAsInternalUser.mock.calls[0];
expect(method).toEqual('search');
expect(query).toMatchObject({
index: 'index-name',
body: {
from: 0,
size: 10,
sort: { '@timestamp': { order: 'asc' } },
query: {
bool: {
must: [
{
nested: {
path: 'kibana.saved_objects',
query: {
bool: {
must: [
{
term: {
'kibana.saved_objects.rel': {
value: 'primary',
expect(query).toMatchInlineSnapshot(`
Object {
"body": Object {
"from": 0,
"query": Object {
"bool": Object {
"must": Array [
Object {
"nested": Object {
"path": "kibana.saved_objects",
"query": Object {
"bool": Object {
"must": Array [
Object {
"term": Object {
"kibana.saved_objects.rel": Object {
"value": "primary",
},
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
Object {
"term": Object {
"kibana.saved_objects.type": Object {
"value": "saved-object-type",
},
},
},
},
{
term: {
'kibana.saved_objects.id': {
value: 'saved-object-id',
Object {
"term": Object {
"kibana.saved_objects.id": Object {
"value": "saved-object-id",
},
},
},
},
],
Object {
"term": Object {
"kibana.saved_objects.namespace": Object {
"value": "namespace",
},
},
},
],
},
},
},
},
},
],
],
},
},
"size": 10,
"sort": Object {
"@timestamp": Object {
"order": "asc",
},
},
},
"index": "index-name",
"rest_total_hits_as_int": true,
}
`);
});
test('should call cluster with proper arguments with default namespace', async () => {
clusterClient.callAsInternalUser.mockResolvedValue({
hits: {
hits: [],
total: { value: 0 },
},
});
await clusterClientAdapter.queryEventsBySavedObject(
'index-name',
undefined,
'saved-object-type',
'saved-object-id',
DEFAULT_OPTIONS
);
const [method, query] = clusterClient.callAsInternalUser.mock.calls[0];
expect(method).toEqual('search');
expect(query).toMatchInlineSnapshot(`
Object {
"body": Object {
"from": 0,
"query": Object {
"bool": Object {
"must": Array [
Object {
"nested": Object {
"path": "kibana.saved_objects",
"query": Object {
"bool": Object {
"must": Array [
Object {
"term": Object {
"kibana.saved_objects.rel": Object {
"value": "primary",
},
},
},
Object {
"term": Object {
"kibana.saved_objects.type": Object {
"value": "saved-object-type",
},
},
},
Object {
"term": Object {
"kibana.saved_objects.id": Object {
"value": "saved-object-id",
},
},
},
Object {
"bool": Object {
"must_not": Object {
"exists": Object {
"field": "kibana.saved_objects.namespace",
},
},
},
},
],
},
},
},
},
],
},
},
"size": 10,
"sort": Object {
"@timestamp": Object {
"order": "asc",
},
},
},
"index": "index-name",
"rest_total_hits_as_int": true,
}
`);
});
test('should call cluster with sort', async () => {
@ -278,6 +373,7 @@ describe('queryEventsBySavedObject', () => {
});
await clusterClientAdapter.queryEventsBySavedObject(
'index-name',
'namespace',
'saved-object-type',
'saved-object-id',
{ ...DEFAULT_OPTIONS, sort_field: 'event.end', sort_order: 'desc' }
@ -301,10 +397,11 @@ describe('queryEventsBySavedObject', () => {
},
});
const start = moment().subtract(1, 'days').toISOString();
const start = '2020-07-08T00:52:28.350Z';
await clusterClientAdapter.queryEventsBySavedObject(
'index-name',
'namespace',
'saved-object-type',
'saved-object-id',
{ ...DEFAULT_OPTIONS, start }
@ -312,56 +409,73 @@ describe('queryEventsBySavedObject', () => {
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.rel': {
value: 'primary',
expect(query).toMatchInlineSnapshot(`
Object {
"body": Object {
"from": 0,
"query": Object {
"bool": Object {
"must": Array [
Object {
"nested": Object {
"path": "kibana.saved_objects",
"query": Object {
"bool": Object {
"must": Array [
Object {
"term": Object {
"kibana.saved_objects.rel": Object {
"value": "primary",
},
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
Object {
"term": Object {
"kibana.saved_objects.type": Object {
"value": "saved-object-type",
},
},
},
},
{
term: {
'kibana.saved_objects.id': {
value: 'saved-object-id',
Object {
"term": Object {
"kibana.saved_objects.id": Object {
"value": "saved-object-id",
},
},
},
},
],
Object {
"term": Object {
"kibana.saved_objects.namespace": Object {
"value": "namespace",
},
},
},
],
},
},
},
},
},
{
range: {
'@timestamp': {
gte: start,
Object {
"range": Object {
"@timestamp": Object {
"gte": "2020-07-08T00:52:28.350Z",
},
},
},
},
],
],
},
},
"size": 10,
"sort": Object {
"@timestamp": Object {
"order": "asc",
},
},
},
},
});
"index": "index-name",
"rest_total_hits_as_int": true,
}
`);
});
test('supports optional date range', async () => {
@ -372,11 +486,12 @@ describe('queryEventsBySavedObject', () => {
},
});
const start = moment().subtract(1, 'days').toISOString();
const end = moment().add(1, 'days').toISOString();
const start = '2020-07-08T00:52:28.350Z';
const end = '2020-07-08T00:00:00.000Z';
await clusterClientAdapter.queryEventsBySavedObject(
'index-name',
'namespace',
'saved-object-type',
'saved-object-id',
{ ...DEFAULT_OPTIONS, start, end }
@ -384,62 +499,79 @@ describe('queryEventsBySavedObject', () => {
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.rel': {
value: 'primary',
expect(query).toMatchInlineSnapshot(`
Object {
"body": Object {
"from": 0,
"query": Object {
"bool": Object {
"must": Array [
Object {
"nested": Object {
"path": "kibana.saved_objects",
"query": Object {
"bool": Object {
"must": Array [
Object {
"term": Object {
"kibana.saved_objects.rel": Object {
"value": "primary",
},
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: 'saved-object-type',
Object {
"term": Object {
"kibana.saved_objects.type": Object {
"value": "saved-object-type",
},
},
},
},
{
term: {
'kibana.saved_objects.id': {
value: 'saved-object-id',
Object {
"term": Object {
"kibana.saved_objects.id": Object {
"value": "saved-object-id",
},
},
},
},
],
Object {
"term": Object {
"kibana.saved_objects.namespace": Object {
"value": "namespace",
},
},
},
],
},
},
},
},
},
{
range: {
'@timestamp': {
gte: start,
Object {
"range": Object {
"@timestamp": Object {
"gte": "2020-07-08T00:52:28.350Z",
},
},
},
},
{
range: {
'@timestamp': {
lte: end,
Object {
"range": Object {
"@timestamp": Object {
"lte": "2020-07-08T00:00:00.000Z",
},
},
},
},
],
],
},
},
"size": 10,
"sort": Object {
"@timestamp": Object {
"order": "asc",
},
},
},
},
});
"index": "index-name",
"rest_total_hits_as_int": true,
}
`);
});
});

View file

@ -6,8 +6,9 @@
import { reject, isUndefined } from 'lodash';
import { SearchResponse, Client } from 'elasticsearch';
import { Logger, LegacyClusterClient } from '../../../../../src/core/server';
import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '../types';
import { Logger, LegacyClusterClient } from 'src/core/server';
import { IValidatedEvent, SAVED_OBJECT_REL_PRIMARY } from '../types';
import { FindOptionsType } from '../event_log_client';
export type EsClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>;
@ -22,7 +23,7 @@ export interface QueryEventsBySavedObjectResult {
page: number;
per_page: number;
total: number;
data: IEvent[];
data: IValidatedEvent[];
}
export class ClusterClientAdapter {
@ -129,10 +130,91 @@ export class ClusterClientAdapter {
public async queryEventsBySavedObject(
index: string,
namespace: string | undefined,
type: string,
id: string,
{ page, per_page: perPage, start, end, sort_field, sort_order }: FindOptionsType
): Promise<QueryEventsBySavedObjectResult> {
const defaultNamespaceQuery = {
bool: {
must_not: {
exists: {
field: 'kibana.saved_objects.namespace',
},
},
},
};
const namedNamespaceQuery = {
term: {
'kibana.saved_objects.namespace': {
value: namespace,
},
},
};
const namespaceQuery = namespace === undefined ? defaultNamespaceQuery : namedNamespaceQuery;
const 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.rel': {
value: SAVED_OBJECT_REL_PRIMARY,
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: type,
},
},
},
{
term: {
'kibana.saved_objects.id': {
value: id,
},
},
},
namespaceQuery,
],
},
},
},
},
start && {
range: {
'@timestamp': {
gte: start,
},
},
},
end && {
range: {
'@timestamp': {
lte: end,
},
},
},
],
isUndefined
),
},
},
};
try {
const {
hits: { hits, total },
@ -141,72 +223,13 @@ export class ClusterClientAdapter {
// The SearchResponse type only supports total as an int,
// so we're forced to explicitly request that it return as an int
rest_total_hits_as_int: true,
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.rel': {
value: SAVED_OBJECT_REL_PRIMARY,
},
},
},
{
term: {
'kibana.saved_objects.type': {
value: type,
},
},
},
{
term: {
'kibana.saved_objects.id': {
value: id,
},
},
},
],
},
},
},
},
start && {
range: {
'@timestamp': {
gte: start,
},
},
},
end && {
range: {
'@timestamp': {
lte: end,
},
},
},
],
isUndefined
),
},
},
},
body,
});
return {
page,
per_page: perPage,
total,
data: hits.map((hit) => hit._source) as IEvent[],
data: hits.map((hit) => hit._source) as IValidatedEvent[],
};
} catch (err) {
throw new Error(

View file

@ -4,10 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { loggingSystemMock } from 'src/core/server/mocks';
import { EsContext } from './context';
import { namesMock } from './names.mock';
import { IClusterClientAdapter } from './cluster_client_adapter';
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
import { clusterClientAdapterMock } from './cluster_client_adapter.mock';
const createContextMock = () => {

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaRequest } from 'src/core/server';
import { EventLogClient } from './event_log_client';
import { contextMock } from './es/context.mock';
import { savedObjectsClientMock } from 'src/core/server/mocks';
@ -18,6 +19,7 @@ describe('EventLogStart', () => {
const eventLogClient = new EventLogClient({
esContext,
savedObjectsClient,
request: FakeRequest(),
});
savedObjectsClient.get.mockResolvedValueOnce({
@ -38,6 +40,7 @@ describe('EventLogStart', () => {
const eventLogClient = new EventLogClient({
esContext,
savedObjectsClient,
request: FakeRequest(),
});
savedObjectsClient.get.mockRejectedValue(new Error('Fail'));
@ -53,6 +56,7 @@ describe('EventLogStart', () => {
const eventLogClient = new EventLogClient({
esContext,
savedObjectsClient,
request: FakeRequest(),
});
savedObjectsClient.get.mockResolvedValueOnce({
@ -107,6 +111,7 @@ describe('EventLogStart', () => {
expect(esContext.esAdapter.queryEventsBySavedObject).toHaveBeenCalledWith(
esContext.esNames.alias,
undefined,
'saved-object-type',
'saved-object-id',
{
@ -124,6 +129,7 @@ describe('EventLogStart', () => {
const eventLogClient = new EventLogClient({
esContext,
savedObjectsClient,
request: FakeRequest(),
});
savedObjectsClient.get.mockResolvedValueOnce({
@ -184,6 +190,7 @@ describe('EventLogStart', () => {
expect(esContext.esAdapter.queryEventsBySavedObject).toHaveBeenCalledWith(
esContext.esNames.alias,
undefined,
'saved-object-type',
'saved-object-id',
{
@ -203,6 +210,7 @@ describe('EventLogStart', () => {
const eventLogClient = new EventLogClient({
esContext,
savedObjectsClient,
request: FakeRequest(),
});
savedObjectsClient.get.mockResolvedValueOnce({
@ -232,6 +240,7 @@ describe('EventLogStart', () => {
const eventLogClient = new EventLogClient({
esContext,
savedObjectsClient,
request: FakeRequest(),
});
savedObjectsClient.get.mockResolvedValueOnce({
@ -286,3 +295,22 @@ function fakeEvent(overrides = {}) {
overrides
);
}
function FakeRequest(): KibanaRequest {
const savedObjectsClient = savedObjectsClientMock.create();
return ({
headers: {},
getBasePath: () => '',
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
getSavedObjectsClient: () => savedObjectsClient,
} as unknown) as KibanaRequest;
}

View file

@ -5,20 +5,16 @@
*/
import { Observable } from 'rxjs';
import { LegacyClusterClient, SavedObjectsClientContract } from 'src/core/server';
import { schema, TypeOf } from '@kbn/config-schema';
import { LegacyClusterClient, SavedObjectsClientContract, KibanaRequest } from 'src/core/server';
import { SpacesServiceSetup } from '../../spaces/server';
import { EsContext } from './es';
import { IEventLogClient } from './types';
import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter';
export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>;
export type AdminClusterClient$ = Observable<PluginClusterClient>;
interface EventLogServiceCtorParams {
esContext: EsContext;
savedObjectsClient: SavedObjectsClientContract;
}
const optionalDateFieldSchema = schema.maybe(
schema.string({
validate(value) {
@ -60,14 +56,30 @@ export type FindOptionsType = Pick<
> &
Partial<TypeOf<typeof findOptionsSchema>>;
interface EventLogServiceCtorParams {
esContext: EsContext;
savedObjectsClient: SavedObjectsClientContract;
spacesService?: SpacesServiceSetup;
request: KibanaRequest;
}
// note that clusterClient may be null, indicating we can't write to ES
export class EventLogClient implements IEventLogClient {
private esContext: EsContext;
private savedObjectsClient: SavedObjectsClientContract;
private spacesService?: SpacesServiceSetup;
private request: KibanaRequest;
constructor({ esContext, savedObjectsClient }: EventLogServiceCtorParams) {
constructor({
esContext,
savedObjectsClient,
spacesService,
request,
}: EventLogServiceCtorParams) {
this.esContext = esContext;
this.savedObjectsClient = savedObjectsClient;
this.spacesService = spacesService;
this.request = request;
}
async findEventsBySavedObject(
@ -75,13 +87,20 @@ export class EventLogClient implements IEventLogClient {
id: string,
options?: Partial<FindOptionsType>
): Promise<QueryEventsBySavedObjectResult> {
const findOptions = findOptionsSchema.validate(options ?? {});
const space = await this.spacesService?.getActiveSpace(this.request);
const namespace = space && this.spacesService?.spaceIdToNamespace(space.id);
// 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,
namespace,
type,
id,
findOptionsSchema.validate(options ?? {})
findOptions
);
}
}

View file

@ -4,10 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { KibanaRequest } from 'src/core/server';
import { savedObjectsClientMock, savedObjectsServiceMock } from 'src/core/server/mocks';
import { EventLogClientService } from './event_log_start_service';
import { contextMock } from './es/context.mock';
import { KibanaRequest } from 'kibana/server';
import { savedObjectsClientMock, savedObjectsServiceMock } from 'src/core/server/mocks';
jest.mock('./event_log_client');
@ -26,13 +27,8 @@ describe('EventLogClientService', () => {
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,
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
includedHiddenTypes: ['action', 'alert'],
});
});
});

View file

@ -11,6 +11,7 @@ import {
SavedObjectsServiceStart,
SavedObjectsClientContract,
} from 'src/core/server';
import { SpacesServiceSetup } from '../../spaces/server';
import { EsContext } from './es';
import { IEventLogClientService } from './types';
@ -18,30 +19,37 @@ import { EventLogClient } from './event_log_client';
export type PluginClusterClient = Pick<LegacyClusterClient, 'callAsInternalUser' | 'asScoped'>;
export type AdminClusterClient$ = Observable<PluginClusterClient>;
const includedHiddenTypes = ['action', 'alert'];
interface EventLogServiceCtorParams {
esContext: EsContext;
savedObjectsService: SavedObjectsServiceStart;
spacesService?: SpacesServiceSetup;
}
// note that clusterClient may be null, indicating we can't write to ES
export class EventLogClientService implements IEventLogClientService {
private esContext: EsContext;
private savedObjectsService: SavedObjectsServiceStart;
private spacesService?: SpacesServiceSetup;
constructor({ esContext, savedObjectsService }: EventLogServiceCtorParams) {
constructor({ esContext, savedObjectsService, spacesService }: EventLogServiceCtorParams) {
this.esContext = esContext;
this.savedObjectsService = savedObjectsService;
this.spacesService = spacesService;
}
getClient(
request: KibanaRequest,
savedObjectsClient: SavedObjectsClientContract = this.savedObjectsService.getScopedClient(
request
)
) {
getClient(request: KibanaRequest) {
const savedObjectsClient: SavedObjectsClientContract = this.savedObjectsService.getScopedClient(
request,
{ includedHiddenTypes }
);
return new EventLogClient({
esContext: this.esContext,
savedObjectsClient,
spacesService: this.spacesService,
request,
});
}
}

View file

@ -13,6 +13,7 @@ export {
IEventLogger,
IEventLogClientService,
IEvent,
IValidatedEvent,
SAVED_OBJECT_REL_PRIMARY,
} from './types';
export const config = { schema: ConfigSchema };

View file

@ -17,6 +17,7 @@ import {
IContextProvider,
RequestHandler,
} from 'src/core/server';
import { SpacesPluginSetup, SpacesServiceSetup } from '../../spaces/server';
import {
IEventLogConfig,
@ -39,14 +40,19 @@ const ACTIONS = {
stopping: 'stopping',
};
interface PluginSetupDeps {
spaces?: SpacesPluginSetup;
}
export class Plugin implements CorePlugin<IEventLogService, IEventLogClientService> {
private readonly config$: IEventLogConfig$;
private systemLogger: Logger;
private eventLogService?: IEventLogService;
private eventLogService?: EventLogService;
private esContext?: EsContext;
private eventLogger?: IEventLogger;
private globalConfig$: Observable<SharedGlobalConfig>;
private eventLogClientService?: EventLogClientService;
private spacesService?: SpacesServiceSetup;
constructor(private readonly context: PluginInitializerContext) {
this.systemLogger = this.context.logger.get();
@ -54,13 +60,14 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi
this.globalConfig$ = this.context.config.legacy.globalConfig$;
}
async setup(core: CoreSetup): Promise<IEventLogService> {
async setup(core: CoreSetup, { spaces }: PluginSetupDeps): Promise<IEventLogService> {
const globalConfig = await this.globalConfig$.pipe(first()).toPromise();
const kibanaIndex = globalConfig.kibana.index;
this.systemLogger.debug('setting up plugin');
const config = await this.config$.pipe(first()).toPromise();
this.spacesService = spaces?.spacesService;
this.esContext = createEsContext({
logger: this.systemLogger,
@ -89,7 +96,7 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi
// Routes
const router = core.http.createRouter();
// Register routes
findRoute(router);
findRoute(router, this.systemLogger);
return this.eventLogService;
}
@ -115,6 +122,7 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi
this.eventLogClientService = new EventLogClientService({
esContext: this.esContext,
savedObjectsService: core.savedObjects,
spacesService: this.spacesService,
});
return this.eventLogClientService;
}
@ -125,8 +133,7 @@ export class Plugin implements CorePlugin<IEventLogService, IEventLogClientServi
> => {
return async (context, request) => {
return {
getEventLogClient: () =>
this.eventLogClientService!.getClient(request, context.core.savedObjects.client),
getEventLogClient: () => this.eventLogClientService!.getClient(request),
};
};
};

View file

@ -4,9 +4,10 @@
* 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 { RequestHandlerContext, KibanaRequest, KibanaResponseFactory } from 'src/core/server';
import { httpServerMock } from 'src/core/server/mocks';
import { IEventLogClient } from '../types';
export function mockHandlerArguments(

View file

@ -8,8 +8,10 @@ import { findRoute } from './find';
import { httpServiceMock } from 'src/core/server/mocks';
import { mockHandlerArguments, fakeEvent } from './_mock_handler_arguments';
import { eventLogClientMock } from '../event_log_client.mock';
import { loggingSystemMock } from 'src/core/server/mocks';
const eventLogClient = eventLogClientMock.create();
const systemLogger = loggingSystemMock.createLogger();
beforeEach(() => {
jest.resetAllMocks();
@ -19,7 +21,7 @@ describe('find', () => {
it('finds events with proper parameters', async () => {
const router = httpServiceMock.createRouter();
findRoute(router);
findRoute(router, systemLogger);
const [config, handler] = router.get.mock.calls[0];
@ -58,7 +60,7 @@ describe('find', () => {
it('supports optional pagination parameters', async () => {
const router = httpServiceMock.createRouter();
findRoute(router);
findRoute(router, systemLogger);
const [, handler] = router.get.mock.calls[0];
eventLogClient.findEventsBySavedObject.mockResolvedValueOnce({
@ -95,4 +97,29 @@ describe('find', () => {
},
});
});
it('logs a warning when the query throws an error', async () => {
const router = httpServiceMock.createRouter();
findRoute(router, systemLogger);
const [, handler] = router.get.mock.calls[0];
eventLogClient.findEventsBySavedObject.mockRejectedValueOnce(new Error('oof!'));
const [context, req, res] = mockHandlerArguments(
eventLogClient,
{
params: { id: '1', type: 'action' },
query: { page: 3, per_page: 10 },
},
['ok']
);
await handler(context, req, res);
expect(systemLogger.debug).toHaveBeenCalledTimes(1);
expect(systemLogger.debug).toHaveBeenCalledWith(
'error calling eventLog findEventsBySavedObject(action, 1, {"page":3,"per_page":10}): oof!'
);
});
});

View file

@ -11,7 +11,9 @@ import {
KibanaRequest,
IKibanaResponse,
KibanaResponseFactory,
} from 'kibana/server';
Logger,
} from 'src/core/server';
import { BASE_EVENT_LOG_API_PATH } from '../../common';
import { findOptionsSchema, FindOptionsType } from '../event_log_client';
@ -20,7 +22,7 @@ const paramSchema = schema.object({
id: schema.string(),
});
export const findRoute = (router: IRouter) => {
export const findRoute = (router: IRouter, systemLogger: Logger) => {
router.get(
{
path: `${BASE_EVENT_LOG_API_PATH}/{type}/{id}/_find`,
@ -42,9 +44,16 @@ export const findRoute = (router: IRouter) => {
params: { id, type },
query,
} = req;
return res.ok({
body: await eventLogClient.findEventsBySavedObject(type, id, query),
});
try {
return res.ok({
body: await eventLogClient.findEventsBySavedObject(type, id, query),
});
} catch (err) {
const call = `findEventsBySavedObject(${type}, ${id}, ${JSON.stringify(query)})`;
systemLogger.debug(`error calling eventLog ${call}: ${err.message}`);
return res.notFound();
}
})
);
};

View file

@ -6,9 +6,9 @@
import { Observable } from 'rxjs';
import { schema, TypeOf } from '@kbn/config-schema';
import { KibanaRequest } from 'src/core/server';
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';

View file

@ -9,11 +9,8 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function serverLogTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('server-log action', () => {
after(() => esArchiver.unload('empty_kibana'));
it('should return 200 when creating a server-log action', async () => {
await supertest
.post('/api/actions/action')

View file

@ -33,6 +33,7 @@ const enabledActionTypes = [
'test.index-record',
'test.noop',
'test.rate-limit',
'test.throw',
];
// eslint-disable-next-line import/no-default-export

View file

@ -24,6 +24,14 @@ export function defineActionTypes(
return { status: 'ok', actionId: '' };
},
};
const throwActionType: ActionType = {
id: 'test.throw',
name: 'Test: Throw',
minimumLicenseRequired: 'gold',
async executor() {
throw new Error('this action is intended to fail');
},
};
const indexRecordActionType: ActionType = {
id: 'test.index-record',
name: 'Test: Index Record',
@ -193,6 +201,7 @@ export function defineActionTypes(
},
};
actions.registerType(noopActionType);
actions.registerType(throwActionType);
actions.registerType(indexRecordActionType);
actions.registerType(failingActionType);
actions.registerType(rateLimitedActionType);

View file

@ -286,6 +286,50 @@ export function defineAlertTypes(
},
async executor(opts: AlertExecutorOptions) {},
};
const throwAlertType: AlertType = {
id: 'test.throw',
name: 'Test: Throw',
actionGroups: [
{
id: 'default',
name: 'Default',
},
],
producer: 'alerting',
defaultActionGroupId: 'default',
async executor({ services, params, state }: AlertExecutorOptions) {
throw new Error('this alert is intended to fail');
},
};
const patternFiringAlertType: AlertType = {
id: 'test.patternFiring',
name: 'Test: Firing on a Pattern',
actionGroups: [{ id: 'default', name: 'Default' }],
producer: 'alerting',
defaultActionGroupId: 'default',
async executor(alertExecutorOptions: AlertExecutorOptions) {
const { services, state, params } = alertExecutorOptions;
const pattern = params.pattern;
if (!Array.isArray(pattern)) throw new Error('pattern is not an array');
if (pattern.length === 0) throw new Error('pattern is empty');
// get the pattern index, return if past it
const patternIndex = state.patternIndex ?? 0;
if (patternIndex > pattern.length) {
return { patternIndex };
}
// fire if pattern says to
if (pattern[patternIndex]) {
services.alertInstanceFactory('instance').scheduleActions('default');
}
return {
patternIndex: (patternIndex + 1) % pattern.length,
};
},
};
alerts.registerType(alwaysFiringAlertType);
alerts.registerType(cumulativeFiringAlertType);
alerts.registerType(neverFiringAlertType);
@ -295,4 +339,6 @@ export function defineAlertTypes(
alerts.registerType(noopAlertType);
alerts.registerType(onlyContextVariablesAlertType);
alerts.registerType(onlyStateVariablesAlertType);
alerts.registerType(patternFiringAlertType);
alerts.registerType(throwAlertType);
}

View file

@ -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 { IValidatedEvent } from '../../../../plugins/event_log/server';
import { getUrlPrefix } from '.';
import { FtrProviderContext } from '../ftr_provider_context';
interface GetEventLogParams {
getService: FtrProviderContext['getService'];
spaceId: string;
type: string;
id: string;
provider: string;
actions: string[];
}
// Return event log entries given the specified parameters; for the `actions`
// parameter, at least one event of each action must be in the returned entries.
export async function getEventLog(params: GetEventLogParams): Promise<IValidatedEvent[]> {
const { getService, spaceId, type, id, provider, actions } = params;
const supertest = getService('supertest');
const spacePrefix = getUrlPrefix(spaceId);
const url = `${spacePrefix}/api/event_log/${type}/${id}/_find`;
const { body: result } = await supertest.get(url).expect(200);
if (!result.total) {
throw new Error('no events found yet');
}
const events: IValidatedEvent[] = (result.data as IValidatedEvent[]).filter(
(event) => event?.event?.provider === provider
);
const foundActions = new Set(
events.map((event) => event?.event?.action).filter((event) => !!event)
);
for (const action of actions) {
if (!foundActions.has(action)) {
throw new Error(`no event found with action "${action}"`);
}
}
return events;
}

View file

@ -12,3 +12,4 @@ export { AlertUtils } from './alert_utils';
export { TaskManagerUtils } from './task_manager_utils';
export * from './test_assertions';
export { checkAAD } from './check_aad';
export { getEventLog } from './get_event_log';

View file

@ -11,11 +11,8 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function emailTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('create email action', () => {
after(() => esArchiver.unload('empty_kibana'));
let createdActionId = '';
it('should return 200 when creating an email action successfully', async () => {

View file

@ -14,10 +14,8 @@ const ES_TEST_INDEX_NAME = 'functional-test-actions-index';
export default function indexTest({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('index action', () => {
after(() => esArchiver.unload('empty_kibana'));
beforeEach(() => clearTestIndex(es));
let createdActionID: string;

View file

@ -16,10 +16,8 @@ const ES_TEST_INDEX_NAME = 'functional-test-actions-index-preconfigured';
export default function indexTest({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('preconfigured index action', () => {
after(() => esArchiver.unload('empty_kibana'));
beforeEach(() => clearTestIndex(es));
it('should execute successfully when expected for a single body', async () => {

View file

@ -34,7 +34,6 @@ const mapping = [
// eslint-disable-next-line import/no-default-export
export default function jiraTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const mockJira = {
@ -82,8 +81,6 @@ export default function jiraTest({ getService }: FtrProviderContext) {
);
});
after(() => esArchiver.unload('empty_kibana'));
describe('Jira - Action Creation', () => {
it('should return 200 when creating a jira action successfully', async () => {
const { body: createdAction } = await supertest

View file

@ -16,7 +16,6 @@ import {
// eslint-disable-next-line import/no-default-export
export default function pagerdutyTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
describe('pagerduty action', () => {
@ -30,8 +29,6 @@ export default function pagerdutyTest({ getService }: FtrProviderContext) {
);
});
after(() => esArchiver.unload('empty_kibana'));
it('should return successfully when passed valid create parameters', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/action')

View file

@ -34,7 +34,6 @@ const mapping = [
// eslint-disable-next-line import/no-default-export
export default function resilientTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const mockResilient = {
@ -82,8 +81,6 @@ export default function resilientTest({ getService }: FtrProviderContext) {
);
});
after(() => esArchiver.unload('empty_kibana'));
describe('IBM Resilient - Action Creation', () => {
it('should return 200 when creating a ibm resilient action successfully', async () => {
const { body: createdAction } = await supertest

View file

@ -11,11 +11,8 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function serverLogTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('server-log action', () => {
after(() => esArchiver.unload('empty_kibana'));
let serverLogActionId: string;
it('should return 200 when creating a builtin server-log action', async () => {

View file

@ -34,7 +34,6 @@ const mapping = [
// eslint-disable-next-line import/no-default-export
export default function servicenowTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const mockServiceNow = {
@ -81,8 +80,6 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
);
});
after(() => esArchiver.unload('empty_kibana'));
describe('ServiceNow - Action Creation', () => {
it('should return 200 when creating a servicenow action successfully', async () => {
const { body: createdAction } = await supertest

View file

@ -16,7 +16,6 @@ import {
// eslint-disable-next-line import/no-default-export
export default function slackTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
describe('slack action', () => {
@ -30,8 +29,6 @@ export default function slackTest({ getService }: FtrProviderContext) {
);
});
after(() => esArchiver.unload('empty_kibana'));
it('should return 200 when creating a slack action successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/action')

View file

@ -27,7 +27,6 @@ function parsePort(url: Record<string, string>): Record<string, string | null |
// eslint-disable-next-line import/no-default-export
export default function webhookTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
async function createWebhookAction(
@ -71,8 +70,6 @@ export default function webhookTest({ getService }: FtrProviderContext) {
);
});
after(() => esArchiver.unload('empty_kibana'));
it('should return 200 when creating a webhook action successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/action')

View file

@ -11,8 +11,12 @@ import {
ES_TEST_INDEX_NAME,
getUrlPrefix,
ObjectRemover,
getEventLog,
} from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { IValidatedEvent } from '../../../../../plugins/event_log/server';
const NANOS_IN_MILLIS = 1000 * 1000;
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
@ -107,6 +111,13 @@ export default function ({ getService }: FtrProviderContext) {
reference,
source: 'action:test.index-record',
});
await validateEventLog({
spaceId: space.id,
actionId: createdAction.id,
outcome: 'success',
message: `action executed: test.index-record:${createdAction.id}: My action`,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
@ -480,4 +491,66 @@ export default function ({ getService }: FtrProviderContext) {
});
}
});
interface ValidateEventLogParams {
spaceId: string;
actionId: string;
outcome: string;
message: string;
errorMessage?: string;
}
async function validateEventLog(params: ValidateEventLogParams): Promise<void> {
const { spaceId, actionId, outcome, message, errorMessage } = params;
const events: IValidatedEvent[] = await retry.try(async () => {
return await getEventLog({
getService,
spaceId,
type: 'action',
id: actionId,
provider: 'actions',
actions: ['execute'],
});
});
expect(events.length).to.equal(1);
const event = events[0];
const duration = event?.event?.duration;
const eventStart = Date.parse(event?.event?.start || 'undefined');
const eventEnd = Date.parse(event?.event?.end || 'undefined');
const dateNow = Date.now();
expect(typeof duration).to.be('number');
expect(eventStart).to.be.ok();
expect(eventEnd).to.be.ok();
const durationDiff = Math.abs(
Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart)
);
// account for rounding errors
expect(durationDiff < 1).to.equal(true);
expect(eventStart <= eventEnd).to.equal(true);
expect(eventEnd <= dateNow).to.equal(true);
expect(event?.event?.outcome).to.equal(outcome);
expect(event?.kibana?.saved_objects).to.eql([
{
rel: 'primary',
type: 'action',
id: actionId,
namespace: spaceId,
},
]);
expect(event?.message).to.eql(message);
if (errorMessage) {
expect(event?.error?.message).to.eql(errorMessage);
}
}
}

View file

@ -15,7 +15,11 @@ import {
ObjectRemover,
AlertUtils,
TaskManagerUtils,
getEventLog,
} from '../../../common/lib';
import { IValidatedEvent } from '../../../../../plugins/event_log/server';
const NANOS_IN_MILLIS = 1000 * 1000;
// eslint-disable-next-line import/no-default-export
export default function alertTests({ getService }: FtrProviderContext) {
@ -159,6 +163,13 @@ instanceStateValue: true
});
await taskManagerUtils.waitForActionTaskParamsToBeCleanedUp(testStart);
await validateEventLog({
spaceId: space.id,
alertId,
outcome: 'success',
message: `alert executed: test.always-firing:${alertId}: 'abc'`,
});
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
@ -927,4 +938,66 @@ instanceStateValue: true
});
}
});
interface ValidateEventLogParams {
spaceId: string;
alertId: string;
outcome: string;
message: string;
errorMessage?: string;
}
async function validateEventLog(params: ValidateEventLogParams): Promise<void> {
const { spaceId, alertId, outcome, message, errorMessage } = params;
const events: IValidatedEvent[] = await retry.try(async () => {
return await getEventLog({
getService,
spaceId,
type: 'alert',
id: alertId,
provider: 'alerting',
actions: ['execute'],
});
});
expect(events.length).to.be.greaterThan(0);
const event = events[0];
const duration = event?.event?.duration;
const eventStart = Date.parse(event?.event?.start || 'undefined');
const eventEnd = Date.parse(event?.event?.end || 'undefined');
const dateNow = Date.now();
expect(typeof duration).to.be('number');
expect(eventStart).to.be.ok();
expect(eventEnd).to.be.ok();
const durationDiff = Math.abs(
Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart)
);
// account for rounding errors
expect(durationDiff < 1).to.equal(true);
expect(eventStart <= eventEnd).to.equal(true);
expect(eventEnd <= dateNow).to.equal(true);
expect(event?.event?.outcome).to.equal(outcome);
expect(event?.kibana?.saved_objects).to.eql([
{
rel: 'primary',
type: 'alert',
id: alertId,
namespace: spaceId,
},
]);
expect(event?.message).to.eql(message);
if (errorMessage) {
expect(event?.error?.message).to.eql(errorMessage);
}
}
}

View file

@ -14,10 +14,8 @@ const ES_TEST_INDEX_NAME = 'functional-test-actions-index';
export default function indexTest({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('index action', () => {
after(() => esArchiver.unload('empty_kibana'));
beforeEach(() => clearTestIndex(es));
let createdActionID: string;

View file

@ -15,7 +15,6 @@ import {
// eslint-disable-next-line import/no-default-export
export default function webhookTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
async function createWebhookAction(
@ -55,8 +54,6 @@ export default function webhookTest({ getService }: FtrProviderContext) {
);
});
after(() => esArchiver.unload('empty_kibana'));
it('webhook can be executed without username and password', async () => {
const webhookActionId = await createWebhookAction(webhookSimulatorURL);
const { body: result } = await supertest

View file

@ -11,8 +11,12 @@ import {
ES_TEST_INDEX_NAME,
getUrlPrefix,
ObjectRemover,
getEventLog,
} from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { IValidatedEvent } from '../../../../../plugins/event_log/server';
const NANOS_IN_MILLIS = 1000 * 1000;
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
@ -86,6 +90,13 @@ export default function ({ getService }: FtrProviderContext) {
reference,
source: 'action:test.index-record',
});
await validateEventLog({
spaceId: Spaces.space1.id,
actionId: createdAction.id,
outcome: 'success',
message: `action executed: test.index-record:${createdAction.id}: My action`,
});
});
it('should handle failed executions', async () => {
@ -118,6 +129,14 @@ export default function ({ getService }: FtrProviderContext) {
serviceMessage: `expected failure for ${ES_TEST_INDEX_NAME} ${reference}`,
retry: false,
});
await validateEventLog({
spaceId: Spaces.space1.id,
actionId: createdAction.id,
outcome: 'failure',
message: `action execution failure: test.failing:${createdAction.id}: failing action`,
errorMessage: `an error occurred while running the action executor: expected failure for .kibana-alerting-test-data actions-failure-1:space1`,
});
});
it(`shouldn't execute an action from another space`, async () => {
@ -198,4 +217,66 @@ export default function ({ getService }: FtrProviderContext) {
});
});
});
interface ValidateEventLogParams {
spaceId: string;
actionId: string;
outcome: string;
message: string;
errorMessage?: string;
}
async function validateEventLog(params: ValidateEventLogParams): Promise<void> {
const { spaceId, actionId, outcome, message, errorMessage } = params;
const events: IValidatedEvent[] = await retry.try(async () => {
return await getEventLog({
getService,
spaceId,
type: 'action',
id: actionId,
provider: 'actions',
actions: ['execute'],
});
});
expect(events.length).to.equal(1);
const event = events[0];
const duration = event?.event?.duration;
const eventStart = Date.parse(event?.event?.start || 'undefined');
const eventEnd = Date.parse(event?.event?.end || 'undefined');
const dateNow = Date.now();
expect(typeof duration).to.be('number');
expect(eventStart).to.be.ok();
expect(eventEnd).to.be.ok();
const durationDiff = Math.abs(
Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart)
);
// account for rounding errors
expect(durationDiff < 1).to.equal(true);
expect(eventStart <= eventEnd).to.equal(true);
expect(eventEnd <= dateNow).to.equal(true);
expect(event?.event?.outcome).to.equal(outcome);
expect(event?.kibana?.saved_objects).to.eql([
{
rel: 'primary',
type: 'action',
id: actionId,
namespace: 'space1',
},
]);
expect(event?.message).to.eql(message);
if (errorMessage) {
expect(event?.error?.message).to.eql(errorMessage);
}
}
}

View file

@ -0,0 +1,265 @@
/*
* 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 expect from '@kbn/expect';
import { Spaces } from '../../scenarios';
import { getUrlPrefix, getTestAlertData, ObjectRemover, getEventLog } from '../../../common/lib';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
import { IValidatedEvent } from '../../../../../plugins/event_log/server';
const NANOS_IN_MILLIS = 1000 * 1000;
// eslint-disable-next-line import/no-default-export
export default function eventLogTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const retry = getService('retry');
describe('eventLog', () => {
const objectRemover = new ObjectRemover(supertest);
after(() => objectRemover.removeAll());
it('should generate expected events for normal operation', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
actionTypeId: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
// pattern of when the alert should fire
const pattern = [false, true, true];
const response = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
alertTypeId: 'test.patternFiring',
schedule: { interval: '1s' },
throttle: null,
params: {
pattern,
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
})
);
expect(response.status).to.eql(200);
const alertId = response.body.id;
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
// get the events we're expecting
const events = await retry.try(async () => {
return await getEventLog({
getService,
spaceId: Spaces.space1.id,
type: 'alert',
id: alertId,
provider: 'alerting',
actions: ['execute', 'execute-action', 'new-instance', 'resolved-instance'],
});
});
// make sure the counts of the # of events per type are as expected
const executeEvents = getEventsByAction(events, 'execute');
const executeActionEvents = getEventsByAction(events, 'execute-action');
const newInstanceEvents = getEventsByAction(events, 'new-instance');
const resolvedInstanceEvents = getEventsByAction(events, 'resolved-instance');
expect(executeEvents.length >= 4).to.be(true);
expect(executeActionEvents.length).to.be(2);
expect(newInstanceEvents.length).to.be(1);
expect(resolvedInstanceEvents.length).to.be(1);
// make sure the events are in the right temporal order
const executeTimes = getTimestamps(executeEvents);
const executeActionTimes = getTimestamps(executeActionEvents);
const newInstanceTimes = getTimestamps(newInstanceEvents);
const resolvedInstanceTimes = getTimestamps(resolvedInstanceEvents);
expect(executeTimes[0] < newInstanceTimes[0]).to.be(true);
expect(executeTimes[1] <= newInstanceTimes[0]).to.be(true);
expect(executeTimes[2] > newInstanceTimes[0]).to.be(true);
expect(executeTimes[1] <= executeActionTimes[0]).to.be(true);
expect(executeTimes[2] > executeActionTimes[0]).to.be(true);
expect(resolvedInstanceTimes[0] > newInstanceTimes[0]).to.be(true);
// validate each event
for (const event of events) {
switch (event?.event?.action) {
case 'execute':
validateEvent(event, {
spaceId: Spaces.space1.id,
savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }],
outcome: 'success',
message: `alert executed: test.patternFiring:${alertId}: 'abc'`,
});
break;
case 'execute-action':
validateEvent(event, {
spaceId: Spaces.space1.id,
savedObjects: [
{ type: 'alert', id: alertId, rel: 'primary' },
{ type: 'action', id: createdAction.id },
],
message: `alert: test.patternFiring:${alertId}: 'abc' instanceId: 'instance' scheduled actionGroup: 'default' action: test.noop:${createdAction.id}`,
});
break;
case 'new-instance':
validateEvent(event, {
spaceId: Spaces.space1.id,
savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }],
message: `test.patternFiring:${alertId}: 'abc' created new instance: 'instance'`,
});
break;
case 'resolved-instance':
validateEvent(event, {
spaceId: Spaces.space1.id,
savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }],
message: `test.patternFiring:${alertId}: 'abc' resolved instance: 'instance'`,
});
break;
// this will get triggered as we add new event actions
default:
throw new Error(`unexpected event action "${event?.event?.action}"`);
}
}
});
it('should generate events for execution errors', async () => {
const response = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
alertTypeId: 'test.throw',
schedule: { interval: '1s' },
throttle: null,
})
);
expect(response.status).to.eql(200);
const alertId = response.body.id;
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
const events = await retry.try(async () => {
return await getEventLog({
getService,
spaceId: Spaces.space1.id,
type: 'alert',
id: alertId,
provider: 'alerting',
actions: ['execute'],
});
});
const event = events[0];
expect(event).to.be.ok();
validateEvent(event, {
spaceId: Spaces.space1.id,
savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }],
outcome: 'failure',
message: `alert execution failure: test.throw:${alertId}: 'abc'`,
errorMessage: 'this alert is intended to fail',
});
});
});
interface SavedObject {
type: string;
id: string;
rel?: string;
}
interface ValidateEventLogParams {
spaceId: string;
savedObjects: SavedObject[];
outcome?: string;
message: string;
errorMessage?: string;
}
function validateEvent(event: IValidatedEvent, params: ValidateEventLogParams): void {
const { spaceId, savedObjects, outcome, message, errorMessage } = params;
const duration = event?.event?.duration;
const eventStart = Date.parse(event?.event?.start || 'undefined');
const eventEnd = Date.parse(event?.event?.end || 'undefined');
const dateNow = Date.now();
if (duration !== undefined) {
expect(typeof duration).to.be('number');
expect(eventStart).to.be.ok();
expect(eventEnd).to.be.ok();
const durationDiff = Math.abs(
Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart)
);
// account for rounding errors
expect(durationDiff < 1).to.equal(true);
expect(eventStart <= eventEnd).to.equal(true);
expect(eventEnd <= dateNow).to.equal(true);
}
expect(event?.event?.outcome).to.equal(outcome);
for (const savedObject of savedObjects) {
expect(
isSavedObjectInEvent(event, spaceId, savedObject.type, savedObject.id, savedObject.rel)
).to.be(true);
}
expect(event?.message).to.eql(message);
if (errorMessage) {
expect(event?.error?.message).to.eql(errorMessage);
}
}
}
function getEventsByAction(events: IValidatedEvent[], action: string) {
return events.filter((event) => event?.event?.action === action);
}
function getTimestamps(events: IValidatedEvent[]) {
return events.map((event) => event?.['@timestamp'] ?? 'missing timestamp');
}
function isSavedObjectInEvent(
event: IValidatedEvent,
namespace: string,
type: string,
id: string,
rel?: string
): boolean {
const savedObjects = event?.kibana?.saved_objects ?? [];
for (const savedObject of savedObjects) {
if (
savedObject.namespace === namespace &&
savedObject.type === type &&
savedObject.id === id &&
savedObject.rel === rel
) {
return true;
}
}
return false;
}

View file

@ -17,6 +17,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./get'));
loadTestFile(require.resolve('./get_alert_state'));
loadTestFile(require.resolve('./list_alert_types'));
loadTestFile(require.resolve('./event_log'));
loadTestFile(require.resolve('./mute_all'));
loadTestFile(require.resolve('./mute_instance'));
loadTestFile(require.resolve('./unmute_all'));
@ -26,6 +27,8 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./alerts_space1'));
loadTestFile(require.resolve('./alerts_default_space'));
loadTestFile(require.resolve('./builtin_alert_types'));
// note that this test will destroy existing spaces
loadTestFile(require.resolve('./migrations'));
});
}

View file

@ -27,7 +27,7 @@ export default function alertingApiIntegrationTests({
}
});
after(() => esArchiver.unload('empty_kibana'));
after(async () => await esArchiver.unload('empty_kibana'));
loadTestFile(require.resolve('./actions'));
loadTestFile(require.resolve('./alerting'));

View file

@ -44,7 +44,7 @@ export class EventLogFixturePlugin
core.savedObjects.registerType({
name: 'event_log_test',
hidden: false,
namespaceType: 'agnostic',
namespaceType: 'single',
mappings: {
properties: {},
},

View file

@ -18,137 +18,162 @@ export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const log = getService('log');
const retry = getService('retry');
const spacesService = getService('spaces');
const esArchiver = getService('esArchiver');
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);
before(async () => {
await spacesService.create({
id: 'namespace-a',
name: 'Space A',
disabledFeatures: [],
});
});
it('should support pagination for events', async () => {
const id = uuid.v4();
const expectedEvents = await logFakeEvents(id, 6);
await retry.try(async () => {
const {
body: { data: foundEvents },
} = await findEvents(id, {});
expect(foundEvents.length).to.be(6);
});
const [expectedFirstPage, expectedSecondPage] = chunk(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);
after(async () => {
await esArchiver.unload('empty_kibana');
});
it('should support sorting by event end', async () => {
const id = uuid.v4();
for (const namespace of [undefined, 'namespace-a']) {
const namespaceName = namespace === undefined ? 'default' : namespace;
const expectedEvents = await logFakeEvents(id, 6);
describe(`namespace: ${namespaceName}`, () => {
it('should allow querying for events by Saved Object', async () => {
const id = uuid.v4();
await retry.try(async () => {
const {
body: { data: foundEvents },
} = await findEvents(id, { sort_field: 'event.end', sort_order: 'desc' });
const expectedEvents = [fakeEvent(namespace, id), fakeEvent(namespace, id)];
expect(foundEvents.length).to.be(expectedEvents.length);
assertEventsFromApiMatchCreatedEvents(foundEvents, expectedEvents.reverse());
await logTestEvent(namespace, id, expectedEvents[0]);
await logTestEvent(namespace, id, expectedEvents[1]);
await retry.try(async () => {
const {
body: { data, total },
} = await findEvents(namespace, 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 expectedEvents = await logFakeEvents(namespace, id, 6);
await retry.try(async () => {
const {
body: { data: foundEvents },
} = await findEvents(namespace, id, {});
expect(foundEvents.length).to.be(6);
});
const [expectedFirstPage, expectedSecondPage] = chunk(expectedEvents, 3);
const {
body: { data: firstPage },
} = await findEvents(namespace, id, { per_page: 3 });
expect(firstPage.length).to.be(3);
assertEventsFromApiMatchCreatedEvents(firstPage, expectedFirstPage);
const {
body: { data: secondPage },
} = await findEvents(namespace, 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 expectedEvents = await logFakeEvents(namespace, id, 6);
await retry.try(async () => {
const {
body: { data: foundEvents },
} = await findEvents(namespace, id, { sort_field: 'event.end', sort_order: 'desc' });
expect(foundEvents.length).to.be(expectedEvents.length);
assertEventsFromApiMatchCreatedEvents(foundEvents, expectedEvents.reverse());
});
});
it('should support date ranges for events', async () => {
const id = uuid.v4();
// write a document that shouldn't be found in the inclusive date range search
const firstEvent = fakeEvent(namespace, id);
await logTestEvent(namespace, id, firstEvent);
// wait a second, get the start time for the date range search
await delay(1000);
const start = new Date().toISOString();
// write the documents that we should be found in the date range searches
const expectedEvents = await logFakeEvents(namespace, id, 6);
// get the end time for the date range search
const end = new Date().toISOString();
// write a document that shouldn't be found in the inclusive date range search
await delay(1000);
const lastEvent = fakeEvent(namespace, id);
await logTestEvent(namespace, id, lastEvent);
await retry.try(async () => {
const {
body: { data: foundEvents, total },
} = await findEvents(namespace, id, {});
expect(foundEvents.length).to.be(8);
expect(total).to.be(8);
});
const {
body: { data: eventsWithinRange },
} = await findEvents(namespace, id, { start, end });
expect(eventsWithinRange.length).to.be(expectedEvents.length);
assertEventsFromApiMatchCreatedEvents(eventsWithinRange, expectedEvents);
const {
body: { data: eventsFrom },
} = await findEvents(namespace, id, { start });
expect(eventsFrom.length).to.be(expectedEvents.length + 1);
assertEventsFromApiMatchCreatedEvents(eventsFrom, [...expectedEvents, lastEvent]);
const {
body: { data: eventsUntil },
} = await findEvents(namespace, id, { end });
expect(eventsUntil.length).to.be(expectedEvents.length + 1);
assertEventsFromApiMatchCreatedEvents(eventsUntil, [firstEvent, ...expectedEvents]);
});
});
});
it('should support date ranges for events', async () => {
const id = uuid.v4();
// write a document that shouldn't be found in the inclusive date range search
const firstEvent = fakeEvent(id);
await logTestEvent(id, firstEvent);
// wait a second, get the start time for the date range search
await delay(1000);
const start = new Date().toISOString();
// write the documents that we should be found in the date range searches
const expectedEvents = await logFakeEvents(id, 6);
// get the end time for the date range search
const end = new Date().toISOString();
// write a document that shouldn't be found in the inclusive date range search
await delay(1000);
const lastEvent = fakeEvent(id);
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<string, any> = {}) {
const uri = `/api/event_log/event_log_test/${id}/_find${
async function findEvents(
namespace: string | undefined,
id: string,
query: Record<string, any> = {}
) {
const urlPrefix = urlPrefixFromNamespace(namespace);
const url = `${urlPrefix}/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);
log.debug(`Finding Events for Saved Object with ${url}`);
return await supertest.get(url).set('kbn-xsrf', 'foo').expect(200);
}
function assertEventsFromApiMatchCreatedEvents(
@ -169,16 +194,27 @@ export default function ({ getService }: FtrProviderContext) {
}
}
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);
async function logTestEvent(namespace: string | undefined, id: string, event: IEvent) {
const urlPrefix = urlPrefixFromNamespace(namespace);
const url = `${urlPrefix}/api/log_event_fixture/${id}/_log`;
log.debug(`Logging Event for Saved Object with ${url} - ${JSON.stringify(event)}`);
return await supertest.post(url).set('kbn-xsrf', 'foo').send(event).expect(200);
}
function fakeEvent(id: string, overrides: Partial<IEvent> = {}): IEvent {
function fakeEvent(
namespace: string | undefined,
id: string,
overrides: Partial<IEvent> = {}
): IEvent {
const savedObject: any = {
rel: 'primary',
type: 'event_log_test',
id,
};
if (namespace !== undefined) {
savedObject.namespace = namespace;
}
return merge(
{
event: {
@ -186,14 +222,7 @@ export default function ({ getService }: FtrProviderContext) {
action: 'test',
},
kibana: {
saved_objects: [
{
rel: 'primary',
namespace: 'default',
type: 'event_log_test',
id,
},
],
saved_objects: [savedObject],
},
message: `test ${moment().toISOString()}`,
},
@ -201,13 +230,22 @@ export default function ({ getService }: FtrProviderContext) {
);
}
async function logFakeEvents(savedObjectId: string, eventsToLog: number): Promise<IEvent[]> {
async function logFakeEvents(
namespace: string | undefined,
savedObjectId: string,
eventsToLog: number
): Promise<IEvent[]> {
const expectedEvents: IEvent[] = [];
for (let index = 0; index < eventsToLog; index++) {
const event = fakeEvent(savedObjectId);
await logTestEvent(savedObjectId, event);
const event = fakeEvent(namespace, savedObjectId);
await logTestEvent(namespace, savedObjectId, event);
expectedEvents.push(event);
}
return expectedEvents;
}
}
function urlPrefixFromNamespace(namespace: string | undefined): string {
if (namespace === undefined) return '';
return `/s/${namespace}`;
}