[Security Solution][Timelines] - Timeline resolve api (#113157) (#113887)

Co-authored-by: Michael Olorunnisola <michael.olorunnisola@elastic.co>
This commit is contained in:
Kibana Machine 2021-10-05 00:40:51 -04:00 committed by GitHub
parent 5ce7267567
commit 8236569ffb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 475 additions and 93 deletions

View file

@ -246,6 +246,7 @@ export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/
export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`;
export const DETECTION_ENGINE_RULES_BULK_ACTION = `${DETECTION_ENGINE_RULES_URL}/_bulk_action`;
export const TIMELINE_RESOLVE_URL = '/api/timeline/resolve';
export const TIMELINE_URL = '/api/timeline';
export const TIMELINES_URL = '/api/timelines';
export const TIMELINE_FAVORITE_URL = '/api/timeline/_favorite';

View file

@ -337,21 +337,6 @@ export const TimelineIdLiteralRt = runtimeTypes.union([
export type TimelineIdLiteral = runtimeTypes.TypeOf<typeof TimelineIdLiteralRt>;
/**
* Timeline Saved object type with metadata
*/
export const TimelineSavedObjectRuntimeType = runtimeTypes.intersection([
runtimeTypes.type({
id: runtimeTypes.string,
attributes: SavedTimelineRuntimeType,
version: runtimeTypes.string,
}),
runtimeTypes.partial({
savedObjectId: runtimeTypes.string,
}),
]);
export const TimelineSavedToReturnObjectRuntimeType = runtimeTypes.intersection([
SavedTimelineRuntimeType,
runtimeTypes.type({
@ -379,6 +364,33 @@ export const SingleTimelineResponseType = runtimeTypes.type({
export type SingleTimelineResponse = runtimeTypes.TypeOf<typeof SingleTimelineResponseType>;
/** Resolved Timeline Response */
export const ResolvedTimelineSavedObjectToReturnObjectRuntimeType = runtimeTypes.intersection([
runtimeTypes.type({
timeline: TimelineSavedToReturnObjectRuntimeType,
outcome: runtimeTypes.union([
runtimeTypes.literal('exactMatch'),
runtimeTypes.literal('aliasMatch'),
runtimeTypes.literal('conflict'),
]),
}),
runtimeTypes.partial({
alias_target_id: runtimeTypes.string,
}),
]);
export type ResolvedTimelineWithOutcomeSavedObject = runtimeTypes.TypeOf<
typeof ResolvedTimelineSavedObjectToReturnObjectRuntimeType
>;
export const ResolvedSingleTimelineResponseType = runtimeTypes.type({
data: ResolvedTimelineSavedObjectToReturnObjectRuntimeType,
});
export type SingleTimelineResolveResponse = runtimeTypes.TypeOf<
typeof ResolvedSingleTimelineResponseType
>;
/**
* All Timeline Saved object type with metadata
*/

View file

@ -22,11 +22,13 @@ import {
ResponseFavoriteTimeline,
AllTimelinesResponse,
SingleTimelineResponse,
SingleTimelineResolveResponse,
allTimelinesResponse,
responseFavoriteTimeline,
GetTimelinesArgs,
SingleTimelineResponseType,
TimelineType,
ResolvedSingleTimelineResponseType,
} from '../../../common/types/timeline';
import {
TIMELINE_URL,
@ -34,6 +36,7 @@ import {
TIMELINE_IMPORT_URL,
TIMELINE_EXPORT_URL,
TIMELINE_PREPACKAGED_URL,
TIMELINE_RESOLVE_URL,
TIMELINES_URL,
TIMELINE_FAVORITE_URL,
} from '../../../common/constants';
@ -71,6 +74,12 @@ const decodeSingleTimelineResponse = (respTimeline?: SingleTimelineResponse) =>
fold(throwErrors(createToasterPlainError), identity)
);
const decodeResolvedSingleTimelineResponse = (respTimeline?: SingleTimelineResolveResponse) =>
pipe(
ResolvedSingleTimelineResponseType.decode(respTimeline),
fold(throwErrors(createToasterPlainError), identity)
);
const decodeAllTimelinesResponse = (respTimeline: AllTimelinesResponse) =>
pipe(
allTimelinesResponse.decode(respTimeline),
@ -305,6 +314,19 @@ export const getTimeline = async (id: string) => {
return decodeSingleTimelineResponse(response);
};
export const resolveTimeline = async (id: string) => {
const response = await KibanaServices.get().http.get<SingleTimelineResolveResponse>(
TIMELINE_RESOLVE_URL,
{
query: {
id,
},
}
);
return decodeResolvedSingleTimelineResponse(response);
};
export const getTimelineTemplate = async (templateTimelineId: string) => {
const response = await KibanaServices.get().http.get<SingleTimelineResponse>(TIMELINE_URL, {
query: {
@ -315,6 +337,19 @@ export const getTimelineTemplate = async (templateTimelineId: string) => {
return decodeSingleTimelineResponse(response);
};
export const getResolvedTimelineTemplate = async (templateTimelineId: string) => {
const response = await KibanaServices.get().http.get<SingleTimelineResolveResponse>(
TIMELINE_RESOLVE_URL,
{
query: {
template_timeline_id: templateTimelineId,
},
}
);
return decodeResolvedSingleTimelineResponse(response);
};
export const getAllTimelines = async (args: GetTimelinesArgs, abortSignal: AbortSignal) => {
const response = await KibanaServices.get().http.fetch<AllTimelinesResponse>(TIMELINES_URL, {
method: 'GET',

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TimelineStatus, TimelineType } from '../../../../common/types/timeline';
import { ResolvedTimelineWithOutcomeSavedObject } from './../../../../common/types/timeline/index';
export const mockResolvedSavedObject = {
saved_object: {
id: '760d3d20-2142-11ec-a46f-051cb8e3154c',
type: 'siem-ui-timeline',
namespaces: ['default'],
updated_at: '2021-09-29T16:29:48.478Z',
version: 'WzYxNzc0LDFd',
attributes: {
columns: [],
dataProviders: [],
description: '',
eventType: 'all',
filters: [],
kqlMode: 'filter',
timelineType: 'default',
kqlQuery: {
filterQuery: null,
},
title: 'Test Timeline',
sort: [
{
columnType: 'date',
sortDirection: 'desc',
columnId: '@timestamp',
},
],
status: 'active',
created: 1632932987378,
createdBy: 'tester',
updated: 1632932988422,
updatedBy: 'tester',
templateTimelineId: null,
templateTimelineVersion: null,
excludedRowRendererIds: [],
dateRange: {
start: '2021-09-29T04:00:00.000Z',
end: '2021-09-30T03:59:59.999Z',
},
indexNames: [],
eqlOptions: {
tiebreakerField: '',
size: 100,
query: '',
eventCategoryField: 'event.category',
timestampField: '@timestamp',
},
},
references: [],
migrationVersion: {
'siem-ui-timeline': '7.16.0',
},
coreMigrationVersion: '8.0.0',
},
outcome: 'aliasMatch',
alias_target_id: 'new-saved-object-id',
};
export const mockResolvedTimeline = {
savedObjectId: '760d3d20-2142-11ec-a46f-051cb8e3154c',
version: 'WzY1NDcxLDFd',
columns: [],
dataProviders: [],
description: '',
eventType: 'all',
filters: [],
kqlMode: 'filter',
timelineType: TimelineType.default,
kqlQuery: { filterQuery: null },
title: 'Test Timeline',
sort: [
{
columnType: 'date',
sortDirection: 'desc',
columnId: '@timestamp',
},
],
status: TimelineStatus.active,
created: 1632932987378,
createdBy: 'tester',
updated: 1632932988422,
updatedBy: 'tester',
templateTimelineId: null,
templateTimelineVersion: null,
excludedRowRendererIds: [],
dateRange: {
start: '2021-09-29T04:00:00.000Z',
end: '2021-09-30T03:59:59.999Z',
},
indexNames: [],
eqlOptions: {
tiebreakerField: '',
size: 100,
query: '',
eventCategoryField: 'event.category',
timestampField: '@timestamp',
},
savedQueryId: null,
};
export const mockPopulatedTimeline = {
...mockResolvedTimeline,
eventIdToNoteIds: [],
favorite: [],
noteIds: [],
notes: [],
pinnedEventIds: [],
pinnedEventsSaveObject: [],
};
export const mockResolveTimelineResponse: ResolvedTimelineWithOutcomeSavedObject = {
timeline: mockPopulatedTimeline,
outcome: 'aliasMatch',
alias_target_id: 'new-saved-object-id',
};

View file

@ -12,3 +12,4 @@ export { getTimelinesRoute } from './get_timelines';
export { importTimelinesRoute } from './import_timelines';
export { patchTimelinesRoute } from './patch_timelines';
export { persistFavoriteRoute } from './persist_favorite';
export { resolveTimelineRoute } from './resolve_timeline';

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { transformError } from '@kbn/securitysolution-es-utils';
import type { SecuritySolutionPluginRouter } from '../../../../../types';
import { TIMELINE_RESOLVE_URL } from '../../../../../../common/constants';
import { ConfigType } from '../../../../..';
import { SetupPlugins } from '../../../../../plugin';
import { buildRouteValidationWithExcess } from '../../../../../utils/build_validation/route_validation';
import { buildSiemResponse } from '../../../../detection_engine/routes/utils';
import { buildFrameworkRequest } from '../../../utils/common';
import { getTimelineQuerySchema } from '../../../schemas/timelines';
import { getTimelineTemplateOrNull, resolveTimelineOrNull } from '../../../saved_object/timelines';
export const resolveTimelineRoute = (
router: SecuritySolutionPluginRouter,
config: ConfigType,
security: SetupPlugins['security']
) => {
router.get(
{
path: TIMELINE_RESOLVE_URL,
validate: {
query: buildRouteValidationWithExcess(getTimelineQuerySchema),
},
options: {
tags: ['access:securitySolution'],
},
},
async (context, request, response) => {
try {
const frameworkRequest = await buildFrameworkRequest(context, security, request);
const query = request.query ?? {};
const { template_timeline_id: templateTimelineId, id } = query;
let res = null;
if (templateTimelineId != null && id == null) {
// Template timelineId is not a SO id, so it does not need to be updated to use resolve
res = await getTimelineTemplateOrNull(frameworkRequest, templateTimelineId);
} else if (templateTimelineId == null && id != null) {
// In the event the objectId is defined, run the resolve call
res = await resolveTimelineOrNull(frameworkRequest, id);
} else {
throw new Error('please provide id or template_timeline_id');
}
return response.ok({ body: res ? { data: res } : {} });
} catch (err) {
const error = transformError(err);
const siemResponse = buildSiemResponse(response);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};

View file

@ -8,11 +8,24 @@
import { FrameworkRequest } from '../../../framework';
import { mockGetTimelineValue, mockSavedObject } from '../../__mocks__/import_timelines';
import { convertStringToBase64, getExistingPrepackagedTimelines, getAllTimeline } from '.';
import {
convertStringToBase64,
getExistingPrepackagedTimelines,
getAllTimeline,
resolveTimelineOrNull,
} from '.';
import { convertSavedObjectToSavedTimeline } from './convert_saved_object_to_savedtimeline';
import { getNotesByTimelineId } from '../notes/saved_object';
import { getAllPinnedEventsByTimelineId } from '../pinned_events';
import { AllTimelinesResponse } from '../../../../../common/types/timeline';
import {
AllTimelinesResponse,
ResolvedTimelineWithOutcomeSavedObject,
} from '../../../../../common/types/timeline';
import {
mockResolvedSavedObject,
mockResolvedTimeline,
mockResolveTimelineResponse,
} from '../../__mocks__/resolve_timeline';
jest.mock('./convert_saved_object_to_savedtimeline', () => ({
convertSavedObjectToSavedTimeline: jest.fn(),
@ -151,7 +164,7 @@ describe('saved_object', () => {
(getAllPinnedEventsByTimelineId as jest.Mock).mockClear();
});
test('should send correct options if no filters applys', async () => {
test('should send correct options if no filters applies', async () => {
expect(mockFindSavedObject.mock.calls[0][0]).toEqual({
filter: 'not siem-ui-timeline.attributes.status: draft',
page: pageInfo.pageIndex,
@ -226,7 +239,7 @@ describe('saved_object', () => {
);
});
test('should retuen correct result', async () => {
test('should return correct result', async () => {
expect(result).toEqual({
totalCount: 1,
customTemplateTimelineCount: 0,
@ -248,4 +261,52 @@ describe('saved_object', () => {
});
});
});
describe('resolveTimelineOrNull', () => {
let mockResolveSavedObject: jest.Mock;
let mockRequest: FrameworkRequest;
let result: ResolvedTimelineWithOutcomeSavedObject | null = null;
beforeEach(async () => {
(convertSavedObjectToSavedTimeline as jest.Mock).mockReturnValue(mockResolvedTimeline);
mockResolveSavedObject = jest.fn().mockReturnValue(mockResolvedSavedObject);
mockRequest = {
user: {
username: 'username',
},
context: {
core: {
savedObjects: {
client: {
resolve: mockResolveSavedObject,
},
},
},
},
} as unknown as FrameworkRequest;
result = await resolveTimelineOrNull(mockRequest, '760d3d20-2142-11ec-a46f-051cb8e3154c');
});
afterEach(() => {
mockResolveSavedObject.mockClear();
(getNotesByTimelineId as jest.Mock).mockClear();
(getAllPinnedEventsByTimelineId as jest.Mock).mockClear();
});
test('should call getNotesByTimelineId', async () => {
expect((getNotesByTimelineId as jest.Mock).mock.calls[0][1]).toEqual(
mockResolvedSavedObject.saved_object.id
);
});
test('should call getAllPinnedEventsByTimelineId', async () => {
expect((getAllPinnedEventsByTimelineId as jest.Mock).mock.calls[0][1]).toEqual(
mockResolvedSavedObject.saved_object.id
);
});
test('should return the timeline with resolve attributes', async () => {
expect(result).toEqual(mockResolveTimelineResponse);
});
});
});

View file

@ -30,6 +30,7 @@ import {
TimelineStatus,
TimelineResult,
TimelineWithoutExternalRefs,
ResolvedTimelineWithOutcomeSavedObject,
} from '../../../../../common/types/timeline';
import { FrameworkRequest } from '../../../framework';
import * as note from '../notes/saved_object';
@ -52,49 +53,6 @@ export interface ResponseTemplateTimeline {
templateTimeline: TimelineResult;
}
export interface Timeline {
getTimeline: (
request: FrameworkRequest,
timelineId: string,
timelineType?: TimelineTypeLiteralWithNull
) => Promise<TimelineSavedObject>;
getAllTimeline: (
request: FrameworkRequest,
onlyUserFavorite: boolean | null,
pageInfo: PageInfoTimeline,
search: string | null,
sort: SortTimeline | null,
status: TimelineStatusLiteralWithNull,
timelineType: TimelineTypeLiteralWithNull
) => Promise<AllTimelinesResponse>;
persistFavorite: (
request: FrameworkRequest,
timelineId: string | null,
templateTimelineId: string | null,
templateTimelineVersion: number | null,
timelineType: TimelineType
) => Promise<ResponseFavoriteTimeline>;
persistTimeline: (
request: FrameworkRequest,
timelineId: string | null,
version: string | null,
timeline: SavedTimeline,
isImmutable?: boolean
) => Promise<ResponseTimeline>;
deleteTimeline: (request: FrameworkRequest, timelineIds: string[]) => Promise<void>;
convertStringToBase64: (text: string) => string;
timelineWithReduxProperties: (
notes: NoteSavedObject[],
pinnedEvents: PinnedEventSavedObject[],
timeline: TimelineSavedObject,
userName: string
) => TimelineSavedObject;
}
export const getTimeline = async (
request: FrameworkRequest,
timelineId: string,
@ -132,6 +90,18 @@ export const getTimelineOrNull = async (
return timeline;
};
export const resolveTimelineOrNull = async (
frameworkRequest: FrameworkRequest,
savedObjectId: string
): Promise<ResolvedTimelineWithOutcomeSavedObject | null> => {
let resolvedTimeline = null;
try {
resolvedTimeline = await resolveSavedTimeline(frameworkRequest, savedObjectId);
// eslint-disable-next-line no-empty
} catch (e) {}
return resolvedTimeline;
};
export const getTimelineByTemplateTimelineId = async (
request: FrameworkRequest,
templateTimelineId: string
@ -584,6 +554,44 @@ export const deleteTimeline = async (request: FrameworkRequest, timelineIds: str
);
};
const resolveBasicSavedTimeline = async (request: FrameworkRequest, timelineId: string) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const { saved_object: savedObject, ...resolveAttributes } =
await savedObjectsClient.resolve<TimelineWithoutExternalRefs>(
timelineSavedObjectType,
timelineId
);
const populatedTimeline = timelineFieldsMigrator.populateFieldsFromReferences(savedObject);
return {
resolvedTimelineSavedObject: convertSavedObjectToSavedTimeline(populatedTimeline),
...resolveAttributes,
};
};
const resolveSavedTimeline = async (request: FrameworkRequest, timelineId: string) => {
const userName = request.user?.username ?? UNAUTHENTICATED_USER;
const { resolvedTimelineSavedObject, ...resolveAttributes } = await resolveBasicSavedTimeline(
request,
timelineId
);
const timelineWithNotesAndPinnedEvents = await Promise.all([
note.getNotesByTimelineId(request, resolvedTimelineSavedObject.savedObjectId),
pinnedEvent.getAllPinnedEventsByTimelineId(request, resolvedTimelineSavedObject.savedObjectId),
resolvedTimelineSavedObject,
]);
const [notes, pinnedEvents, timeline] = timelineWithNotesAndPinnedEvents;
return {
timeline: timelineWithReduxProperties(notes, pinnedEvents, timeline, userName),
...resolveAttributes,
};
};
const getBasicSavedTimeline = async (request: FrameworkRequest, timelineId: string) => {
const savedObjectsClient = request.context.core.savedObjects.client;
const savedObject = await savedObjectsClient.get<TimelineWithoutExternalRefs>(
@ -646,13 +654,6 @@ const getAllSavedTimeline = async (request: FrameworkRequest, options: SavedObje
export const convertStringToBase64 = (text: string): string => Buffer.from(text).toString('base64');
// we have to use any here because the SavedObjectAttributes interface is like below
// export interface SavedObjectAttributes {
// [key: string]: SavedObjectAttributes | string | number | boolean | null;
// }
// then this interface does not allow types without index signature
// this is limiting us with our type for now so the easy way was to use any
export const timelineWithReduxProperties = (
notes: NoteSavedObject[],
pinnedEvents: PinnedEventSavedObject[],

View file

@ -45,6 +45,7 @@ import {
importTimelinesRoute,
patchTimelinesRoute,
persistFavoriteRoute,
resolveTimelineRoute,
} from '../lib/timeline/routes/timelines';
import { getDraftTimelinesRoute } from '../lib/timeline/routes/draft_timelines/get_draft_timelines';
import { cleanDraftTimelinesRoute } from '../lib/timeline/routes/draft_timelines/clean_draft_timelines';
@ -99,6 +100,7 @@ export const initRoutes = (
exportTimelinesRoute(router, config, security);
getDraftTimelinesRoute(router, config, security);
getTimelineRoute(router, config, security);
resolveTimelineRoute(router, config, security);
getTimelinesRoute(router, config, security);
cleanDraftTimelinesRoute(router, config, security);
deleteTimelinesRoute(router, config, security);

View file

@ -16,44 +16,121 @@ import { createBasicTimeline, createBasicTimelineTemplate } from './saved_object
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('Timeline', () => {
it('Make sure that we get Timeline data', async () => {
const titleToSaved = 'hello timeline';
await createBasicTimeline(supertest, titleToSaved);
describe('timelines', () => {
it('Make sure that we get Timeline data', async () => {
const titleToSaved = 'hello timeline';
await createBasicTimeline(supertest, titleToSaved);
const resp = await supertest.get('/api/timelines').set('kbn-xsrf', 'true');
const resp = await supertest.get('/api/timelines').set('kbn-xsrf', 'true');
const timelines = resp.body.timeline;
const timelines = resp.body.timeline;
expect(timelines.length).to.greaterThan(0);
expect(timelines.length).to.greaterThan(0);
});
it('Make sure that pagination is working in Timeline query', async () => {
const titleToSaved = 'hello timeline';
await createBasicTimeline(supertest, titleToSaved);
const resp = await supertest
.get('/api/timelines?page_size=1&page_index=1')
.set('kbn-xsrf', 'true');
const timelines = resp.body.timeline;
expect(timelines.length).to.equal(1);
});
it('Make sure that we get Timeline template data', async () => {
const titleToSaved = 'hello timeline template';
await createBasicTimelineTemplate(supertest, titleToSaved);
const resp = await supertest
.get('/api/timelines?timeline_type=template')
.set('kbn-xsrf', 'true');
const templates: SavedTimeline[] = resp.body.timeline;
expect(templates.length).to.greaterThan(0);
expect(templates.filter((t) => t.timelineType === TimelineType.default).length).to.equal(0);
});
});
describe('resolve timeline', () => {
before(async () => {
await esArchiver.load(
'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0'
);
});
it('Make sure that pagination is working in Timeline query', async () => {
const titleToSaved = 'hello timeline';
await createBasicTimeline(supertest, titleToSaved);
after(async () => {
await esArchiver.unload(
'x-pack/test/functional/es_archives/security_solution/timelines/7.15.0'
);
});
const resp = await supertest
.get('/api/timelines?page_size=1&page_index=1')
.set('kbn-xsrf', 'true');
it('should return outcome exactMatch when the id is unchanged', async () => {
const resp = await supertest
.get('/api/timeline/resolve')
.query({ id: '8dc70950-1012-11ec-9ad3-2d7c6600c0f7' });
const timelines = resp.body.timeline;
expect(resp.body.data.outcome).to.be('exactMatch');
expect(resp.body.data.alias_target_id).to.be(undefined);
expect(resp.body.data.timeline.title).to.be('Awesome Timeline');
});
expect(timelines.length).to.equal(1);
});
describe('notes', () => {
it('should return notes with eventId', async () => {
const resp = await supertest
.get('/api/timeline/resolve')
.query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' });
it('Make sure that we get Timeline template data', async () => {
const titleToSaved = 'hello timeline template';
await createBasicTimelineTemplate(supertest, titleToSaved);
expect(resp.body.data.timeline.notes[0].eventId).to.be('Edo00XsBEVtyvU-8LGNe');
});
const resp = await supertest
.get('/api/timelines?timeline_type=template')
.set('kbn-xsrf', 'true');
it('should return notes with the timelineId matching request id', async () => {
const resp = await supertest
.get('/api/timeline/resolve')
.query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' });
const templates: SavedTimeline[] = resp.body.timeline;
expect(resp.body.data.timeline.notes[0].timelineId).to.be(
'6484cc90-126e-11ec-83d2-db1096c73738'
);
expect(resp.body.data.timeline.notes[1].timelineId).to.be(
'6484cc90-126e-11ec-83d2-db1096c73738'
);
});
});
expect(templates.length).to.greaterThan(0);
expect(templates.filter((t) => t.timelineType === TimelineType.default).length).to.equal(0);
describe('pinned events', () => {
it('should pinned events with eventId', async () => {
const resp = await supertest
.get('/api/timeline/resolve')
.query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' });
expect(resp.body.data.timeline.pinnedEventsSaveObject[0].eventId).to.be(
'DNo00XsBEVtyvU-8LGNe'
);
expect(resp.body.data.timeline.pinnedEventsSaveObject[1].eventId).to.be(
'Edo00XsBEVtyvU-8LGNe'
);
});
it('should return pinned events with the timelineId matching request id', async () => {
const resp = await supertest
.get('/api/timeline/resolve')
.query({ id: '6484cc90-126e-11ec-83d2-db1096c73738' });
expect(resp.body.data.timeline.pinnedEventsSaveObject[0].timelineId).to.be(
'6484cc90-126e-11ec-83d2-db1096c73738'
);
expect(resp.body.data.timeline.pinnedEventsSaveObject[1].timelineId).to.be(
'6484cc90-126e-11ec-83d2-db1096c73738'
);
});
});
});
});
}