From 66164c361456376f928a2dfae232b350b7ec2a3e Mon Sep 17 00:00:00 2001 From: Zhongnan Su Date: Wed, 6 Jan 2021 15:09:03 -0800 Subject: [PATCH] Support creating report for saved objects with custom id (#283) --- kibana-reports/server/routes/report.ts | 7 +- .../server/routes/reportDefinition.ts | 12 +- .../utils/__tests__/validationHelper.test.ts | 111 ++++++++++++++++++ .../server/utils/validationHelper.ts | 81 ++++++++++++- 4 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 kibana-reports/server/utils/__tests__/validationHelper.test.ts diff --git a/kibana-reports/server/routes/report.ts b/kibana-reports/server/routes/report.ts index a6b79a4f..1672a5d9 100644 --- a/kibana-reports/server/routes/report.ts +++ b/kibana-reports/server/routes/report.ts @@ -23,7 +23,6 @@ import { } from '../../../../src/core/server'; import { API_PREFIX } from '../../common'; import { createReport } from './lib/createReport'; -import { reportSchema } from '../model'; import { checkErrorType, errorResponse } from './utils/helpers'; import { DEFAULT_MAX_SIZE, DELIVERY_TYPE } from './utils/constants'; import { @@ -31,6 +30,7 @@ import { backendToUiReportsList, } from './utils/converters/backendToUi'; import { addToMetric } from './utils/metricHelper'; +import { validateReport } from '../../server/utils/validationHelper'; export default function (router: IRouter) { // generate report (with provided metadata) @@ -58,7 +58,10 @@ export default function (router: IRouter) { try { report.report_definition.report_params.core_params.origin = request.headers.origin; - report = reportSchema.validate(report); + report = await validateReport( + context.core.elasticsearch.legacy.client, + report + ); } catch (error) { logger.error(`Failed input validation for create report ${error}`); addToMetric('report', 'create', 'user_error'); diff --git a/kibana-reports/server/routes/reportDefinition.ts b/kibana-reports/server/routes/reportDefinition.ts index aa2727e4..9014d5ea 100644 --- a/kibana-reports/server/routes/reportDefinition.ts +++ b/kibana-reports/server/routes/reportDefinition.ts @@ -21,7 +21,6 @@ import { ILegacyScopedClusterClient, } from '../../../../src/core/server'; import { API_PREFIX } from '../../common'; -import { reportDefinitionSchema } from '../model'; import { checkErrorType, errorResponse } from './utils/helpers'; import { createReportDefinition } from './lib/createReportDefinition'; import { @@ -31,6 +30,7 @@ import { import { updateReportDefinition } from './lib/updateReportDefinition'; import { DEFAULT_MAX_SIZE } from './utils/constants'; import { addToMetric } from './utils/metricHelper'; +import { validateReportDefinition } from '../../server/utils/validationHelper'; export default function (router: IRouter) { // Create report Definition @@ -54,7 +54,10 @@ export default function (router: IRouter) { try { reportDefinition.report_params.core_params.origin = request.headers.origin; - reportDefinition = reportDefinitionSchema.validate(reportDefinition); + reportDefinition = await validateReportDefinition( + context.core.elasticsearch.legacy.client, + reportDefinition + ); } catch (error) { logger.error( `Failed input validation for create report definition ${error}` @@ -109,7 +112,10 @@ export default function (router: IRouter) { try { reportDefinition.report_params.core_params.origin = request.headers.origin; - reportDefinition = reportDefinitionSchema.validate(reportDefinition); + reportDefinition = await validateReportDefinition( + context.core.elasticsearch.legacy.client, + reportDefinition + ); } catch (error) { logger.error( `Failed input validation for update report definition ${error}` diff --git a/kibana-reports/server/utils/__tests__/validationHelper.test.ts b/kibana-reports/server/utils/__tests__/validationHelper.test.ts new file mode 100644 index 00000000..f01c4cf4 --- /dev/null +++ b/kibana-reports/server/utils/__tests__/validationHelper.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import { ReportDefinitionSchemaType, ReportSchemaType } from '../../model'; +import { + DELIVERY_TYPE, + FORMAT, + REPORT_TYPE, + TRIGGER_TYPE, +} from '../../routes/utils/constants'; +import { validateReport, validateReportDefinition } from '../validationHelper'; + +const SAMPLE_SAVED_OBJECT_ID = '3ba638e0-b894-11e8-a6d9-e546fe2bba5f'; +const createReportDefinitionInput: ReportDefinitionSchemaType = { + report_params: { + report_name: 'test visual report', + report_source: REPORT_TYPE.dashboard, + description: 'Hi this is your Dashboard on demand', + core_params: { + base_url: `/app/dashboards#/view/${SAMPLE_SAVED_OBJECT_ID}`, + window_width: 1300, + window_height: 900, + report_format: FORMAT.pdf, + time_duration: 'PT5M', + origin: 'http://localhost:5601', + }, + }, + delivery: { + delivery_type: DELIVERY_TYPE.kibanaUser, + delivery_params: { + kibana_recipients: [], + }, + }, + trigger: { + trigger_type: TRIGGER_TYPE.onDemand, + }, +}; +const createReportInput: ReportSchemaType = { + query_url: `/app/dashboards#/view/${SAMPLE_SAVED_OBJECT_ID}`, + time_from: 1343576635300, + time_to: 1596037435301, + report_definition: createReportDefinitionInput, +}; + +describe('test input validation', () => { + test('create report with correct saved object id', async () => { + const savedObjectIds = [`dashboard:${SAMPLE_SAVED_OBJECT_ID}`]; + const client = mockEsClient(savedObjectIds); + const report = await validateReport(client, createReportInput); + expect(report).toBeDefined(); + }); + + test('create report with non-exist saved object id', async () => { + const savedObjectIds = ['dashboard:fake-id']; + const client = mockEsClient(savedObjectIds); + await expect( + validateReport(client, createReportInput) + ).rejects.toThrowError( + `saved object with id dashboard:${SAMPLE_SAVED_OBJECT_ID} does not exist` + ); + }); + + test('create report definition with correct saved object id', async () => { + const savedObjectIds = [`dashboard:${SAMPLE_SAVED_OBJECT_ID}`]; + const client = mockEsClient(savedObjectIds); + const report = await validateReportDefinition( + client, + createReportDefinitionInput + ); + expect(report).toBeDefined(); + }); + + test('create report definition with non-exist saved object id', async () => { + const savedObjectIds = ['dashboard:fake-id']; + const client = mockEsClient(savedObjectIds); + await expect( + validateReportDefinition(client, createReportDefinitionInput) + ).rejects.toThrowError( + `saved object with id dashboard:${SAMPLE_SAVED_OBJECT_ID} does not exist` + ); + }); +}); +// TODO: merge this with other mock clients used in testing, to create some mock helpers file +const mockEsClient = (mockSavedObjectIds: string[]) => { + const client = { + callAsCurrentUser: jest + .fn() + .mockImplementation((endpoint: string, params: any) => { + switch (endpoint) { + case 'exists': + return mockSavedObjectIds.includes(params.id); + default: + fail('Fail due to unexpected function call on client'); + } + }), + }; + + return client; +}; diff --git a/kibana-reports/server/utils/validationHelper.ts b/kibana-reports/server/utils/validationHelper.ts index 34cf1f63..77334d13 100644 --- a/kibana-reports/server/utils/validationHelper.ts +++ b/kibana-reports/server/utils/validationHelper.ts @@ -13,7 +13,16 @@ * permissions and limitations under the License. */ +import { RequestParams } from '@elastic/elasticsearch'; import path from 'path'; +import { ILegacyScopedClusterClient } from '../../../../src/core/server'; +import { + reportDefinitionSchema, + ReportDefinitionSchemaType, + reportSchema, + ReportSchemaType, +} from '../../server/model'; +import { REPORT_TYPE } from '../../server/routes/utils/constants'; export const isValidRelativeUrl = (relativeUrl: string) => { const normalizedRelativeUrl = path.posix.normalize(relativeUrl); @@ -32,4 +41,74 @@ export const isValidRelativeUrl = (relativeUrl: string) => { export const regexDuration = /^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/; export const regexEmailAddress = /\S+@\S+\.\S+/; export const regexReportName = /^[\w\-\s\(\)\[\]\,\_\-+]+$/; -export const regexRelativeUrl = /^\/(_plugin\/kibana\/app|app)\/(dashboards|visualize|discover)#\/(view|edit)\/([a-f0-9-]+)($|\?\S+$)/; +export const regexRelativeUrl = /^\/(_plugin\/kibana\/app|app)\/(dashboards|visualize|discover)#\/(view|edit)\/[^\/]+$/; + +export const validateReport = async ( + client: ILegacyScopedClusterClient, + report: ReportSchemaType +) => { + // validate basic schema + report = reportSchema.validate(report); + // parse to retrieve data + const { + query_url: queryUrl, + report_definition: { + report_params: { report_source: reportSource }, + }, + } = report; + // Check if saved object actually exists + await validateSavedObject(client, queryUrl, reportSource); + return report; +}; + +export const validateReportDefinition = async ( + client: ILegacyScopedClusterClient, + reportDefinition: ReportDefinitionSchemaType +) => { + // validate basic schema + reportDefinition = reportDefinitionSchema.validate(reportDefinition); + // parse to retrieve data + const { + report_params: { + report_source: reportSource, + core_params: { base_url: baseUrl }, + }, + } = reportDefinition; + // Check if saved object actually exists + await validateSavedObject(client, baseUrl, reportSource); + return reportDefinition; +}; + +const validateSavedObject = async ( + client: ILegacyScopedClusterClient, + url: string, + source: REPORT_TYPE +) => { + const getId = (url: string) => { + return url + .split('/') + .pop() + ?.replace(/\?\S+$/, ''); + }; + const getType = (source: REPORT_TYPE) => { + switch (source) { + case REPORT_TYPE.dashboard: + return 'dashboard'; + case REPORT_TYPE.savedSearch: + return 'search'; + case REPORT_TYPE.visualization: + return 'visualization'; + } + }; + + const savedObjectId = `${getType(source)}:${getId(url)}`; + const params: RequestParams.Exists = { + index: '.kibana', + id: savedObjectId, + }; + + const exist = await client.callAsCurrentUser('exists', params); + if (!exist) { + throw Error(`saved object with id ${savedObjectId} does not exist`); + } +};