diff --git a/x-pack/legacy/plugins/reporting/common/constants.ts b/x-pack/legacy/plugins/reporting/common/constants.ts index f30a7cc87f318..48483c79d1af2 100644 --- a/x-pack/legacy/plugins/reporting/common/constants.ts +++ b/x-pack/legacy/plugins/reporting/common/constants.ts @@ -27,6 +27,7 @@ export const WHITELISTED_JOB_CONTENT_TYPES = [ 'application/pdf', CONTENT_TYPE_CSV, 'image/png', + 'text/plain', ]; // See: diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts index 8320cd05aa2e7..c76b4afe727da 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts @@ -4,14 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; -import { - ConditionalHeaders, - CreateJobFactory, - ESQueueCreateJobFn, - RequestFacade, -} from '../../../server/types'; +import { CreateJobFactory, ESQueueCreateJobFn } from '../../../server/types'; import { JobParamsDiscoverCsv } from '../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); + const setupDeps = reporting.getPluginSetupDeps(); return async function createJob( jobParams: JobParamsDiscoverCsv, - headers: ConditionalHeaders['headers'], - request: RequestFacade + context: RequestHandlerContext, + request: KibanaRequest ) { - const serializedEncryptedHeaders = await crypto.encrypt(headers); + const serializedEncryptedHeaders = await crypto.encrypt(request.headers); - const savedObjectsClient = request.getSavedObjectsClient(); + const savedObjectsClient = context.core.savedObjects.client; const indexPatternSavedObject = await savedObjectsClient.get( 'index-pattern', jobParams.indexPatternId! @@ -36,7 +33,7 @@ export const createJobFactory: CreateJobFactory = ( jobParams: JobParamsType, - headers: Record, - req: RequestFacade + headers: KibanaRequest['headers'], + context: RequestHandlerContext, + req: KibanaRequest ) => Promise<{ type: string | null; title: string; @@ -46,21 +48,21 @@ export const createJobFactory: CreateJobFactory { const { savedObjectType, savedObjectId } = jobParams; const serializedEncryptedHeaders = await crypto.encrypt(headers); - const client = req.getSavedObjectsClient(); const { panel, title, visType }: VisData = await Promise.resolve() - .then(() => client.get(savedObjectType, savedObjectId)) + .then(() => context.core.savedObjects.client.get(savedObjectType, savedObjectId)) .then(async (savedObject: SavedObject) => { const { attributes, references } = savedObject; const { kibanaSavedObjectMeta: kibanaSavedObjectMetaJSON, } = attributes as SavedSearchObjectAttributesJSON; - const { timerange } = req.payload as { timerange: TimeRangeParams }; + const { timerange } = req.body as { timerange: TimeRangeParams }; if (!kibanaSavedObjectMetaJSON) { throw new Error('Could not parse saved object data!'); diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts index 5761a98ed160c..4ef7b8514b363 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts @@ -5,15 +5,11 @@ */ import { i18n } from '@kbn/i18n'; +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory, LevelLogger } from '../../../server/lib'; -import { - ExecuteJobFactory, - JobDocOutput, - JobDocPayload, - RequestFacade, -} from '../../../server/types'; +import { ExecuteJobFactory, JobDocOutput, JobDocPayload } from '../../../server/types'; import { CsvResultFromSearch } from '../../csv/types'; import { FakeRequest, JobDocPayloadPanelCsv, JobParamsPanelCsv, SearchPanel } from '../types'; import { createGenerateCsv } from './lib'; @@ -25,7 +21,8 @@ import { createGenerateCsv } from './lib'; export type ImmediateExecuteFn = ( jobId: null, job: JobDocPayload, - request: RequestFacade + context: RequestHandlerContext, + req: KibanaRequest ) => Promise; export const executeJobFactory: ExecuteJobFactory { // There will not be a jobID for "immediate" generation. // jobID is only for "queued" jobs @@ -58,10 +56,11 @@ export const executeJobFactory: ExecuteJobFactory; @@ -103,6 +102,7 @@ export const executeJobFactory: ExecuteJobFactory { }; export async function generateCsvSearch( - req: RequestFacade, reporting: ReportingCore, - logger: LevelLogger, + context: RequestHandlerContext, + req: KibanaRequest, searchPanel: SearchPanel, - jobParams: JobParamsDiscoverCsv + jobParams: JobParamsDiscoverCsv, + logger: LevelLogger ): Promise { - const savedObjectsClient = await reporting.getSavedObjectsClient( - KibanaRequest.from(req.getRawRequest()) - ); + const savedObjectsClient = context.core.savedObjects.client; const { indexPatternSavedObjectId, timerange } = searchPanel; - const savedSearchObjectAttr = searchPanel.attributes as SavedSearchObjectAttributes; + const savedSearchObjectAttr = searchPanel.attributes; const { indexPatternSavedObject } = await getDataSource( savedObjectsClient, indexPatternSavedObjectId @@ -153,9 +149,7 @@ export async function generateCsvSearch( const config = reporting.getConfig(); const elasticsearch = await reporting.getElasticsearchService(); - const { callAsCurrentUser } = elasticsearch.dataClient.asScoped( - KibanaRequest.from(req.getRawRequest()) - ); + const { callAsCurrentUser } = elasticsearch.dataClient.asScoped(req); const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); const uiSettings = await getUiSettings(uiConfig); diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.ts index 071427f4dab64..4695bbd922581 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_filters.ts @@ -22,7 +22,7 @@ export function getFilters( let timezone: string | null; if (indexPatternTimeField) { - if (!timerange) { + if (!timerange || !timerange.min || !timerange.max) { throw badRequest( `Time range params are required for index pattern [${indexPatternId}], using time field [${indexPatternTimeField}]` ); diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts index 57d74ee0e1383..5aed02c10b961 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RequestFacade } from '../../../../server/types'; +import { KibanaRequest } from 'src/core/server'; import { JobParamsPanelCsv, JobParamsPostPayloadPanelCsv } from '../../types'; export function getJobParamsFromRequest( - request: RequestFacade, + request: KibanaRequest, opts: { isImmediate: boolean } ): JobParamsPanelCsv { - const { savedObjectType, savedObjectId } = request.params; - const { timerange, state } = request.payload as JobParamsPostPayloadPanelCsv; + const { savedObjectType, savedObjectId } = request.params as { + savedObjectType: string; + savedObjectId: string; + }; + const { timerange, state } = request.body as JobParamsPostPayloadPanelCsv; + const post = timerange || state ? { timerange, state } : undefined; return { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts index b19513de29eee..ab492c21256eb 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts @@ -5,28 +5,23 @@ */ import { validateUrls } from '../../../../common/validate_urls'; -import { ReportingCore } from '../../../../server'; import { cryptoFactory } from '../../../../server/lib'; -import { - ConditionalHeaders, - CreateJobFactory, - ESQueueCreateJobFn, - RequestFacade, -} from '../../../../server/types'; +import { CreateJobFactory, ESQueueCreateJobFn } from '../../../../server/types'; import { JobParamsPNG } from '../../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore) { +>> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); + const setupDeps = reporting.getPluginSetupDeps(); const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob( - { objectType, title, relativeUrl, browserTimezone, layout }: JobParamsPNG, - headers: ConditionalHeaders['headers'], - request: RequestFacade + { objectType, title, relativeUrl, browserTimezone, layout }, + context, + req ) { - const serializedEncryptedHeaders = await crypto.encrypt(headers); + const serializedEncryptedHeaders = await crypto.encrypt(req.headers); validateUrls([relativeUrl]); @@ -37,7 +32,7 @@ export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore) { +>> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); + const setupDeps = reporting.getPluginSetupDeps(); const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJobFn( { title, relativeUrls, browserTimezone, layout, objectType }: JobParamsPDF, - headers: ConditionalHeaders['headers'], - request: RequestFacade + context, + req ) { - const serializedEncryptedHeaders = await crypto.encrypt(headers); + const serializedEncryptedHeaders = await crypto.encrypt(req.headers); validateUrls(relativeUrls); return { - basePath: request.getBasePath(), + basePath: setupDeps.basePath(req), browserTimezone, forceNow: new Date().toISOString(), headers: serializedEncryptedHeaders, diff --git a/x-pack/legacy/plugins/reporting/server/core.ts b/x-pack/legacy/plugins/reporting/server/core.ts index 8fb948a253c16..b89ef9e06b961 100644 --- a/x-pack/legacy/plugins/reporting/server/core.ts +++ b/x-pack/legacy/plugins/reporting/server/core.ts @@ -5,33 +5,35 @@ */ import * as Rx from 'rxjs'; -import { first, mapTo } from 'rxjs/operators'; +import { first, mapTo, map } from 'rxjs/operators'; import { ElasticsearchServiceSetup, KibanaRequest, - SavedObjectsClient, SavedObjectsServiceStart, UiSettingsServiceStart, + IRouter, + SavedObjectsClientContract, + BasePath, } from 'src/core/server'; -import { ReportingPluginSpecOptions } from '../'; -// @ts-ignore no module definition -import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; -import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; -import { PLUGIN_ID } from '../common/constants'; +import { SecurityPluginSetup } from '../../../../plugins/security/server'; +import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; import { screenshotsObservableFactory } from '../export_types/common/lib/screenshots'; -import { ServerFacade } from '../server/types'; +import { ScreenshotsObservableFn } from '../server/types'; import { ReportingConfig } from './'; import { HeadlessChromiumDriverFactory } from './browsers/chromium/driver_factory'; -import { checkLicenseFactory, getExportTypesRegistry, LevelLogger } from './lib'; +import { checkLicense, getExportTypesRegistry } from './lib'; import { ESQueueInstance } from './lib/create_queue'; import { EnqueueJobFn } from './lib/enqueue_job'; -import { registerRoutes } from './routes'; -import { ReportingSetupDeps } from './types'; -interface ReportingInternalSetup { +export interface ReportingInternalSetup { browserDriverFactory: HeadlessChromiumDriverFactory; elasticsearch: ElasticsearchServiceSetup; + licensing: LicensingPluginSetup; + basePath: BasePath['get']; + router: IRouter; + security: SecurityPluginSetup; } + interface ReportingInternalStart { enqueueJob: EnqueueJobFn; esqueue: ESQueueInstance; @@ -46,30 +48,10 @@ export class ReportingCore { private readonly pluginStart$ = new Rx.ReplaySubject(); private exportTypesRegistry = getExportTypesRegistry(); - constructor(private logger: LevelLogger, private config: ReportingConfig) {} - - legacySetup( - xpackMainPlugin: XPackMainPlugin, - reporting: ReportingPluginSpecOptions, - __LEGACY: ServerFacade, - plugins: ReportingSetupDeps - ) { - // legacy plugin status - mirrorPluginStatus(xpackMainPlugin, reporting); - - // legacy license check - const checkLicense = checkLicenseFactory(this.exportTypesRegistry); - (xpackMainPlugin as any).status.once('green', () => { - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin - xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); - }); - - // legacy routes - registerRoutes(this, __LEGACY, plugins, this.logger); - } + constructor(private config: ReportingConfig) {} public pluginSetup(reportingSetupDeps: ReportingInternalSetup) { + this.pluginSetupDeps = reportingSetupDeps; this.pluginSetup$.next(reportingSetupDeps); } @@ -96,23 +78,35 @@ export class ReportingCore { return (await this.getPluginStartDeps()).enqueueJob; } - public getConfig() { + public async getLicenseInfo() { + const { licensing } = this.getPluginSetupDeps(); + return await licensing.license$ + .pipe( + map((license) => checkLicense(this.getExportTypesRegistry(), license)), + first() + ) + .toPromise(); + } + + public getConfig(): ReportingConfig { return this.config; } - public async getScreenshotsObservable() { - const { browserDriverFactory } = await this.getPluginSetupDeps(); + + public getScreenshotsObservable(): ScreenshotsObservableFn { + const { browserDriverFactory } = this.getPluginSetupDeps(); return screenshotsObservableFactory(this.config.get('capture'), browserDriverFactory); } + public getPluginSetupDeps() { + if (!this.pluginSetupDeps) { + throw new Error(`"pluginSetupDeps" dependencies haven't initialized yet`); + } + return this.pluginSetupDeps; + } + /* * Outside dependencies */ - private async getPluginSetupDeps() { - if (this.pluginSetupDeps) { - return this.pluginSetupDeps; - } - return await this.pluginSetup$.pipe(first()).toPromise(); - } private async getPluginStartDeps() { if (this.pluginStartDeps) { @@ -122,15 +116,15 @@ export class ReportingCore { } public async getElasticsearchService() { - return (await this.getPluginSetupDeps()).elasticsearch; + return this.getPluginSetupDeps().elasticsearch; } public async getSavedObjectsClient(fakeRequest: KibanaRequest) { const { savedObjects } = await this.getPluginStartDeps(); - return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClient; + return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClientContract; } - public async getUiSettingsServiceFactory(savedObjectsClient: SavedObjectsClient) { + public async getUiSettingsServiceFactory(savedObjectsClient: SavedObjectsClientContract) { const { uiSettings: uiSettingsService } = await this.getPluginStartDeps(); const scopedUiSettingsService = uiSettingsService.asScopedToClient(savedObjectsClient); return scopedUiSettingsService; diff --git a/x-pack/legacy/plugins/reporting/server/legacy.ts b/x-pack/legacy/plugins/reporting/server/legacy.ts index 37272325b97d0..14abd53cc83d9 100644 --- a/x-pack/legacy/plugins/reporting/server/legacy.ts +++ b/x-pack/legacy/plugins/reporting/server/legacy.ts @@ -7,8 +7,8 @@ import { Legacy } from 'kibana'; import { take } from 'rxjs/operators'; import { PluginInitializerContext } from 'src/core/server'; -import { ReportingPluginSpecOptions } from '../'; import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; +import { ReportingPluginSpecOptions } from '../'; import { PluginsSetup } from '../../../../plugins/reporting/server'; import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { buildConfig } from './config'; @@ -42,6 +42,7 @@ export const legacyInit = async ( server.newPlatform.coreContext as PluginInitializerContext, buildConfig(coreSetup, server, reportingConfig) ); + await pluginInstance.setup(coreSetup, { elasticsearch: coreSetup.elasticsearch, licensing: server.newPlatform.setup.plugins.licensing as LicensingPluginSetup, diff --git a/x-pack/legacy/plugins/reporting/server/lib/__tests__/check_license.js b/x-pack/legacy/plugins/reporting/server/lib/__tests__/check_license.js deleted file mode 100644 index 294a0df56756e..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/__tests__/check_license.js +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { set } from 'lodash'; -import { checkLicenseFactory } from '../check_license'; - -describe('check_license', function () { - let mockLicenseInfo; - let checkLicense; - - beforeEach(() => { - mockLicenseInfo = {}; - checkLicense = checkLicenseFactory({ - getAll: () => [ - { - id: 'test', - name: 'Test Export Type', - jobType: 'testJobType', - }, - ], - }); - }); - - describe('license information is not available', () => { - beforeEach(() => (mockLicenseInfo.isAvailable = () => false)); - - it('should set management.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).management.showLinks).to.be(true); - }); - - it('should set test.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).test.showLinks).to.be(true); - }); - - it('should set management.enableLinks to false', () => { - expect(checkLicense(mockLicenseInfo).management.enableLinks).to.be(false); - }); - - it('should set test.enableLinks to false', () => { - expect(checkLicense(mockLicenseInfo).test.enableLinks).to.be(false); - }); - - it('should set management.jobTypes to undefined', () => { - expect(checkLicense(mockLicenseInfo).management.jobTypes).to.be(undefined); - }); - }); - - describe('license information is available', () => { - beforeEach(() => { - mockLicenseInfo.isAvailable = () => true; - set(mockLicenseInfo, 'license.getType', () => 'basic'); - }); - - describe('& license is > basic', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isOneOf', () => true)); - - describe('& license is active', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => true)); - - it('should set management.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).management.showLinks).to.be(true); - }); - - it('should set test.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).test.showLinks).to.be(true); - }); - - it('should set management.enableLinks to true', () => { - expect(checkLicense(mockLicenseInfo).management.enableLinks).to.be(true); - }); - - it('should set test.enableLinks to true', () => { - expect(checkLicense(mockLicenseInfo).test.enableLinks).to.be(true); - }); - - it('should set management.jobTypes to contain testJobType', () => { - expect(checkLicense(mockLicenseInfo).management.jobTypes).to.contain('testJobType'); - }); - }); - - describe('& license is expired', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => false)); - - it('should set management.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).management.showLinks).to.be(true); - }); - - it('should set test.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).test.showLinks).to.be(true); - }); - - it('should set management.enableLinks to false', () => { - expect(checkLicense(mockLicenseInfo).management.enableLinks).to.be(false); - }); - - it('should set test.enableLinks to false', () => { - expect(checkLicense(mockLicenseInfo).test.enableLinks).to.be(false); - }); - - it('should set management.jobTypes to undefined', () => { - expect(checkLicense(mockLicenseInfo).management.jobTypes).to.be(undefined); - }); - }); - }); - - describe('& license is basic', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isOneOf', () => false)); - - describe('& license is active', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => true)); - - it('should set management.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).management.showLinks).to.be(false); - }); - - it('should set test.showLinks to false', () => { - expect(checkLicense(mockLicenseInfo).test.showLinks).to.be(false); - }); - - it('should set management.jobTypes to an empty array', () => { - expect(checkLicense(mockLicenseInfo).management.jobTypes).to.be.an(Array); - expect(checkLicense(mockLicenseInfo).management.jobTypes).to.have.length(0); - }); - }); - - describe('& license is expired', () => { - beforeEach(() => set(mockLicenseInfo, 'license.isActive', () => false)); - - it('should set management.showLinks to true', () => { - expect(checkLicense(mockLicenseInfo).management.showLinks).to.be(true); - }); - - it('should set test.showLinks to false', () => { - expect(checkLicense(mockLicenseInfo).test.showLinks).to.be(false); - }); - - it('should set management.jobTypes to undefined', () => { - expect(checkLicense(mockLicenseInfo).management.jobTypes).to.be(undefined); - }); - }); - }); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/check_license.test.ts b/x-pack/legacy/plugins/reporting/server/lib/check_license.test.ts new file mode 100644 index 0000000000000..366a8d94286f1 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/lib/check_license.test.ts @@ -0,0 +1,192 @@ +/* + * 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 { checkLicense } from './check_license'; +import { ILicense } from '../../../../../plugins/licensing/server'; +import { ExportTypesRegistry } from './export_types_registry'; + +describe('check_license', () => { + let exportTypesRegistry: ExportTypesRegistry; + let license: ILicense; + + beforeEach(() => { + exportTypesRegistry = ({ + getAll: () => [], + } as unknown) as ExportTypesRegistry; + }); + + describe('license information is not ready', () => { + beforeEach(() => { + exportTypesRegistry = ({ + getAll: () => [{ id: 'csv' }], + } as unknown) as ExportTypesRegistry; + }); + + it('should set management.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, undefined).management.showLinks).toEqual(true); + }); + + it('should set csv.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, undefined).csv.showLinks).toEqual(true); + }); + + it('should set management.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, undefined).management.enableLinks).toEqual(false); + }); + + it('should set csv.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, undefined).csv.enableLinks).toEqual(false); + }); + + it('should set management.jobTypes to undefined', () => { + expect(checkLicense(exportTypesRegistry, undefined).management.jobTypes).toEqual(undefined); + }); + }); + + describe('license information is not available', () => { + beforeEach(() => { + license = { + type: undefined, + } as ILicense; + exportTypesRegistry = ({ + getAll: () => [{ id: 'csv' }], + } as unknown) as ExportTypesRegistry; + }); + + it('should set management.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).management.showLinks).toEqual(true); + }); + + it('should set csv.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).csv.showLinks).toEqual(true); + }); + + it('should set management.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).management.enableLinks).toEqual(false); + }); + + it('should set csv.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).csv.enableLinks).toEqual(false); + }); + + it('should set management.jobTypes to undefined', () => { + expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined); + }); + }); + + describe('license information is available', () => { + beforeEach(() => { + license = {} as ILicense; + }); + + describe('& license is > basic', () => { + beforeEach(() => { + license.type = 'gold'; + exportTypesRegistry = ({ + getAll: () => [{ id: 'pdf', validLicenses: ['gold'], jobType: 'printable_pdf' }], + } as unknown) as ExportTypesRegistry; + }); + + describe('& license is active', () => { + beforeEach(() => (license.isActive = true)); + + it('should set management.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).management.showLinks).toEqual(true); + }); + + it('should setpdf.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).pdf.showLinks).toEqual(true); + }); + + it('should set management.enableLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).management.enableLinks).toEqual(true); + }); + + it('should setpdf.enableLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).pdf.enableLinks).toEqual(true); + }); + + it('should set management.jobTypes to contain testJobType', () => { + expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toContain( + 'printable_pdf' + ); + }); + }); + + describe('& license is expired', () => { + beforeEach(() => { + license.isActive = false; + }); + + it('should set management.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).management.showLinks).toEqual(true); + }); + + it('should set pdf.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).pdf.showLinks).toEqual(true); + }); + + it('should set management.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).management.enableLinks).toEqual(false); + }); + + it('should set pdf.enableLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).pdf.enableLinks).toEqual(false); + }); + + it('should set management.jobTypes to undefined', () => { + expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined); + }); + }); + }); + + describe('& license is basic', () => { + beforeEach(() => { + license.type = 'basic'; + exportTypesRegistry = ({ + getAll: () => [{ id: 'pdf', validLicenses: ['gold'], jobType: 'printable_pdf' }], + } as unknown) as ExportTypesRegistry; + }); + + describe('& license is active', () => { + beforeEach(() => { + license.isActive = true; + }); + + it('should set management.showLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).management.showLinks).toEqual(false); + }); + + it('should set test.showLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).pdf.showLinks).toEqual(false); + }); + + it('should set management.jobTypes to an empty array', () => { + expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual([]); + expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toHaveLength(0); + }); + }); + + describe('& license is expired', () => { + beforeEach(() => { + license.isActive = false; + }); + + it('should set management.showLinks to true', () => { + expect(checkLicense(exportTypesRegistry, license).management.showLinks).toEqual(true); + }); + + it('should set test.showLinks to false', () => { + expect(checkLicense(exportTypesRegistry, license).pdf.showLinks).toEqual(false); + }); + + it('should set management.jobTypes to undefined', () => { + expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined); + }); + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/check_license.ts b/x-pack/legacy/plugins/reporting/server/lib/check_license.ts index 80cf315539441..1b4eeaa0bae3e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/check_license.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/check_license.ts @@ -4,23 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { XPackInfo } from '../../../xpack_main/server/lib/xpack_info'; -import { XPackInfoLicense } from '../../../xpack_main/server/lib/xpack_info_license'; +import { ILicense } from '../../../../../plugins/licensing/server'; import { ExportTypeDefinition } from '../types'; import { ExportTypesRegistry } from './export_types_registry'; -interface LicenseCheckResult { +export interface LicenseCheckResult { showLinks: boolean; enableLinks: boolean; message?: string; + jobTypes?: string[]; } const messages = { getUnavailable: () => { return 'You cannot use Reporting because license information is not available at this time.'; }, - getExpired: (license: XPackInfoLicense) => { - return `You cannot use Reporting because your ${license.getType()} license has expired.`; + getExpired: (license: ILicense) => { + return `You cannot use Reporting because your ${license.type} license has expired.`; }, }; @@ -29,8 +29,8 @@ const makeManagementFeature = ( ) => { return { id: 'management', - checkLicense: (license: XPackInfoLicense | null) => { - if (!license) { + checkLicense: (license?: ILicense) => { + if (!license || !license.type) { return { showLinks: true, enableLinks: false, @@ -38,7 +38,7 @@ const makeManagementFeature = ( }; } - if (!license.isActive()) { + if (!license.isActive) { return { showLinks: true, enableLinks: false, @@ -47,7 +47,7 @@ const makeManagementFeature = ( } const validJobTypes = exportTypes - .filter((exportType) => license.isOneOf(exportType.validLicenses)) + .filter((exportType) => exportType.validLicenses.includes(license.type || '')) .map((exportType) => exportType.jobType); return { @@ -64,8 +64,8 @@ const makeExportTypeFeature = ( ) => { return { id: exportType.id, - checkLicense: (license: XPackInfoLicense | null) => { - if (!license) { + checkLicense: (license?: ILicense) => { + if (!license || !license.type) { return { showLinks: true, enableLinks: false, @@ -73,17 +73,15 @@ const makeExportTypeFeature = ( }; } - if (!license.isOneOf(exportType.validLicenses)) { + if (!exportType.validLicenses.includes(license.type)) { return { showLinks: false, enableLinks: false, - message: `Your ${license.getType()} license does not support ${ - exportType.name - } Reporting. Please upgrade your license.`, + message: `Your ${license.type} license does not support ${exportType.name} Reporting. Please upgrade your license.`, }; } - if (!license.isActive()) { + if (!license.isActive) { return { showLinks: true, enableLinks: false, @@ -99,18 +97,18 @@ const makeExportTypeFeature = ( }; }; -export function checkLicenseFactory(exportTypesRegistry: ExportTypesRegistry) { - return function checkLicense(xpackInfo: XPackInfo) { - const license = xpackInfo === null || !xpackInfo.isAvailable() ? null : xpackInfo.license; - const exportTypes = Array.from(exportTypesRegistry.getAll()); - const reportingFeatures = [ - ...exportTypes.map(makeExportTypeFeature), - makeManagementFeature(exportTypes), - ]; +export function checkLicense( + exportTypesRegistry: ExportTypesRegistry, + license: ILicense | undefined +) { + const exportTypes = Array.from(exportTypesRegistry.getAll()); + const reportingFeatures = [ + ...exportTypes.map(makeExportTypeFeature), + makeManagementFeature(exportTypes), + ]; - return reportingFeatures.reduce((result, feature) => { - result[feature.id] = feature.checkLicense(license); - return result; - }, {} as Record); - }; + return reportingFeatures.reduce((result, feature) => { + result[feature.id] = feature.checkLicense(license); + return result; + }, {} as Record); } diff --git a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts index 8ffb99f7a14c8..6367c8a1da98a 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts @@ -5,8 +5,9 @@ */ import { EventEmitter } from 'events'; -import { get } from 'lodash'; -import { ConditionalHeaders, ESQueueCreateJobFn, RequestFacade } from '../../server/types'; +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { AuthenticatedUser } from '../../../../../plugins/security/server'; +import { ESQueueCreateJobFn } from '../../server/types'; import { ReportingCore } from '../core'; // @ts-ignore import { events as esqueueEvents } from './esqueue'; @@ -29,9 +30,9 @@ export type Job = EventEmitter & { export type EnqueueJobFn = ( exportTypeId: string, jobParams: JobParamsType, - user: string, - headers: Record, - request: RequestFacade + user: AuthenticatedUser | null, + context: RequestHandlerContext, + request: KibanaRequest ) => Promise; export function enqueueJobFactory( @@ -42,18 +43,17 @@ export function enqueueJobFactory( const queueTimeout = config.get('queue', 'timeout'); const browserType = config.get('capture', 'browser', 'type'); const maxAttempts = config.get('capture', 'maxAttempts'); - const logger = parentLogger.clone(['queue-job']); return async function enqueueJob( exportTypeId: string, jobParams: JobParamsType, - user: string, - headers: ConditionalHeaders['headers'], - request: RequestFacade + user: AuthenticatedUser | null, + context: RequestHandlerContext, + request: KibanaRequest ): Promise { type CreateJobFn = ESQueueCreateJobFn; - + const username = user ? user.username : false; const esqueue = await reporting.getEsqueue(); const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); @@ -62,11 +62,11 @@ export function enqueueJobFactory( } const createJob = exportType.createJobFactory(reporting, logger) as CreateJobFn; - const payload = await createJob(jobParams, headers, request); + const payload = await createJob(jobParams, context, request); const options = { timeout: queueTimeout, - created_by: get(user, 'username', false), + created_by: username, browser_type: browserType, max_attempts: maxAttempts, }; diff --git a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts index 8e8b1c83d5a40..164ffc5742d04 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts @@ -4,16 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { SecurityPluginSetup } from '../../../../../plugins/security/server'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { ReportingSetupDeps } from '../types'; -import { LevelLogger } from './level_logger'; -export function getUserFactory(security: ReportingSetupDeps['security'], logger: LevelLogger) { - /* - * Legacy.Request because this is called from routing middleware - */ - return async (request: Legacy.Request) => { - return security?.authc.getCurrentUser(KibanaRequest.from(request)) ?? null; +export function getUserFactory(security?: SecurityPluginSetup) { + return (request: KibanaRequest) => { + return security?.authc.getCurrentUser(request) ?? null; }; } diff --git a/x-pack/legacy/plugins/reporting/server/lib/index.ts b/x-pack/legacy/plugins/reporting/server/lib/index.ts index 2a8fa45b6fcef..0e9c49b170887 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/index.ts @@ -5,7 +5,7 @@ */ export { LevelLogger } from './level_logger'; -export { checkLicenseFactory } from './check_license'; +export { checkLicense } from './check_license'; export { createQueueFactory } from './create_queue'; export { cryptoFactory } from './crypto'; export { enqueueJobFactory } from './enqueue_job'; diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index 1abf58c29b481..5153fd0f4e5b8 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -5,10 +5,10 @@ */ import { i18n } from '@kbn/i18n'; -import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; +import { AuthenticatedUser } from '../../../../../plugins/security/server'; import { ReportingConfig } from '../'; import { JobSource } from '../types'; @@ -40,6 +40,8 @@ interface CountAggResult { count: number; } +const getUsername = (user: AuthenticatedUser | null) => (user ? user.username : false); + export function jobsQueryFactory( config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup @@ -47,10 +49,6 @@ export function jobsQueryFactory( const index = config.get('index'); const { callAsInternalUser } = elasticsearch.adminClient; - function getUsername(user: any) { - return get(user, 'username', false); - } - function execQuery(queryType: string, body: QueryBody) { const defaultBody: Record = { search: { @@ -82,9 +80,14 @@ export function jobsQueryFactory( } return { - list(jobTypes: string[], user: any, page = 0, size = defaultSize, jobIds: string[] | null) { + list( + jobTypes: string[], + user: AuthenticatedUser | null, + page = 0, + size = defaultSize, + jobIds: string[] | null + ) { const username = getUsername(user); - const body: QueryBody = { size, from: size * page, @@ -108,9 +111,8 @@ export function jobsQueryFactory( return getHits(execQuery('search', body)); }, - count(jobTypes: string[], user: any) { + count(jobTypes: string[], user: AuthenticatedUser | null) { const username = getUsername(user); - const body: QueryBody = { query: { constant_score: { @@ -129,9 +131,12 @@ export function jobsQueryFactory( }); }, - get(user: any, id: string, opts: GetOpts = {}): Promise | void> { + get( + user: AuthenticatedUser | null, + id: string, + opts: GetOpts = {} + ): Promise | void> { if (!id) return Promise.resolve(); - const username = getUsername(user); const body: QueryBody = { @@ -164,14 +169,12 @@ export function jobsQueryFactory( const query = { id, index: deleteIndex }; return callAsInternalUser('delete', query); } catch (error) { - const wrappedError = new Error( + throw new Error( i18n.translate('xpack.reporting.jobsQuery.deleteError', { defaultMessage: 'Could not delete the report: {error}', values: { error: error.message }, }) ); - - throw Boom.boomify(wrappedError, { statusCode: error.status }); } }, }; diff --git a/x-pack/legacy/plugins/reporting/server/plugin.ts b/x-pack/legacy/plugins/reporting/server/plugin.ts index 78c2ce5b9b106..5a407ad3e4c4a 100644 --- a/x-pack/legacy/plugins/reporting/server/plugin.ts +++ b/x-pack/legacy/plugins/reporting/server/plugin.ts @@ -8,10 +8,13 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core import { createBrowserDriverFactory } from './browsers'; import { ReportingConfig } from './config'; import { ReportingCore } from './core'; +import { registerRoutes } from './routes'; import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } from './lib'; import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; +// @ts-ignore no module definition +import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; export class ReportingPlugin implements Plugin { @@ -22,24 +25,34 @@ export class ReportingPlugin constructor(context: PluginInitializerContext, config: ReportingConfig) { this.config = config; this.logger = new LevelLogger(context.logger.get('reporting')); - this.reportingCore = new ReportingCore(this.logger, this.config); + this.reportingCore = new ReportingCore(this.config); } public async setup(core: CoreSetup, plugins: ReportingSetupDeps) { const { config } = this; - const { elasticsearch, __LEGACY } = plugins; + const { elasticsearch, __LEGACY, licensing, security } = plugins; + const router = core.http.createRouter(); + const basePath = core.http.basePath.get; + const { xpack_main: xpackMainLegacy, reporting: reportingLegacy } = __LEGACY.plugins; - const browserDriverFactory = await createBrowserDriverFactory(config, this.logger); // required for validations :( - runValidations(config, elasticsearch, browserDriverFactory, this.logger); + // legacy plugin status + mirrorPluginStatus(xpackMainLegacy, reportingLegacy); - const { xpack_main: xpackMainLegacy, reporting: reportingLegacy } = __LEGACY.plugins; - this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, __LEGACY, plugins); + const browserDriverFactory = await createBrowserDriverFactory(config, this.logger); + const deps = { + browserDriverFactory, + elasticsearch, + licensing, + basePath, + router, + security, + }; - // Register a function with server to manage the collection of usage stats - registerReportingUsageCollector(this.reportingCore, plugins); + runValidations(config, elasticsearch, browserDriverFactory, this.logger); - // regsister setup internals - this.reportingCore.pluginSetup({ browserDriverFactory, elasticsearch }); + this.reportingCore.pluginSetup(deps); + registerReportingUsageCollector(this.reportingCore, plugins); + registerRoutes(this.reportingCore, this.logger); return {}; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts index 3f79d51382a81..2a12a64d67a35 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -4,73 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ -import boom from 'boom'; -import Joi from 'joi'; -import { Legacy } from 'kibana'; import rison from 'rison-node'; +import { schema } from '@kbn/config-schema'; +import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; +import { HandlerErrorFunction, HandlerFunction } from './types'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; -import { LevelLogger as Logger } from '../lib'; -import { ReportingSetupDeps, ServerFacade } from '../types'; -import { makeRequestFacade } from './lib/make_request_facade'; -import { - GetRouteConfigFactoryFn, - getRouteConfigFactoryReportingPre, - RouteConfigFactory, -} from './lib/route_config_factories'; -import { HandlerErrorFunction, HandlerFunction, ReportingResponseToolkit } from './types'; const BASE_GENERATE = `${API_BASE_URL}/generate`; export function registerGenerateFromJobParams( reporting: ReportingCore, - server: ServerFacade, - plugins: ReportingSetupDeps, handler: HandlerFunction, - handleError: HandlerErrorFunction, - logger: Logger + handleError: HandlerErrorFunction ) { - const config = reporting.getConfig(); - const getRouteConfig = () => { - const getOriginalRouteConfig: GetRouteConfigFactoryFn = getRouteConfigFactoryReportingPre( - config, - plugins, - logger - ); - const routeConfigFactory: RouteConfigFactory = getOriginalRouteConfig( - ({ params: { exportType } }) => exportType - ); + const setupDeps = reporting.getPluginSetupDeps(); + const userHandler = authorizedUserPreRoutingFactory(reporting); + const { router } = setupDeps; - return { - ...routeConfigFactory, + router.post( + { + path: `${BASE_GENERATE}/{exportType}`, validate: { - params: Joi.object({ - exportType: Joi.string().required(), - }).required(), - payload: Joi.object({ - jobParams: Joi.string().optional().default(null), - }).allow(null), // allow optional payload - query: Joi.object({ - jobParams: Joi.string().default(null), - }).default(), + params: schema.object({ + exportType: schema.string({ minLength: 2 }), + }), + body: schema.nullable( + schema.object({ + jobParams: schema.maybe(schema.string()), + }) + ), + query: schema.nullable( + schema.object({ + jobParams: schema.string({ + defaultValue: '', + }), + }) + ), }, - }; - }; - - // generate report - server.route({ - path: `${BASE_GENERATE}/{exportType}`, - method: 'POST', - options: getRouteConfig(), - handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { - const request = makeRequestFacade(legacyRequest); + }, + userHandler(async (user, context, req, res) => { let jobParamsRison: string | null; - if (request.payload) { - const { jobParams: jobParamsPayload } = request.payload as { jobParams: string }; + if (req.body) { + const { jobParams: jobParamsPayload } = req.body as { jobParams: string }; jobParamsRison = jobParamsPayload; } else { - const { jobParams: queryJobParams } = request.query as { jobParams: string }; + const { jobParams: queryJobParams } = req.query as { jobParams: string }; if (queryJobParams) { jobParamsRison = queryJobParams; } else { @@ -79,37 +59,46 @@ export function registerGenerateFromJobParams( } if (!jobParamsRison) { - throw boom.badRequest('A jobParams RISON string is required'); + return res.customError({ + statusCode: 400, + body: 'A jobParams RISON string is required in the querystring or POST body', + }); } - const { exportType } = request.params; + const { exportType } = req.params as { exportType: string }; let jobParams; - let response; + try { jobParams = rison.decode(jobParamsRison) as object | null; if (!jobParams) { - throw new Error('missing jobParams!'); + return res.customError({ + statusCode: 400, + body: 'Missing jobParams!', + }); } } catch (err) { - throw boom.badRequest(`invalid rison: ${jobParamsRison}`); + return res.customError({ + statusCode: 400, + body: `invalid rison: ${jobParamsRison}`, + }); } + try { - response = await handler(exportType, jobParams, legacyRequest, h); + return await handler(user, exportType, jobParams, context, req, res); } catch (err) { - throw handleError(exportType, err); + return handleError(res, err); } - return response; - }, - }); + }) + ); // Get route to generation endpoint: show error about GET method to user - server.route({ - path: `${BASE_GENERATE}/{p*}`, - method: 'GET', - handler: () => { - const err = boom.methodNotAllowed('GET is not allowed'); - err.output.headers.allow = 'POST'; - return err; + router.get( + { + path: `${BASE_GENERATE}/{p*}`, + validate: false, }, - }); + (context, req, res) => { + return res.customError({ statusCode: 405, body: 'GET is not allowed' }); + } + ); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts index 03a893d1abeb4..4bc143b911572 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts @@ -4,21 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { schema } from '@kbn/config-schema'; import { get } from 'lodash'; +import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; import { ReportingCore } from '../'; import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; import { getJobParamsFromRequest } from '../../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; -import { LevelLogger as Logger } from '../lib'; -import { ReportingSetupDeps, ServerFacade } from '../types'; -import { makeRequestFacade } from './lib/make_request_facade'; -import { getRouteOptionsCsv } from './lib/route_config_factories'; -import { - HandlerErrorFunction, - HandlerFunction, - QueuedJobPayload, - ReportingResponseToolkit, -} from './types'; +import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; /* * This function registers API Endpoints for queuing Reporting jobs. The API inputs are: @@ -31,22 +23,31 @@ import { */ export function registerGenerateCsvFromSavedObject( reporting: ReportingCore, - server: ServerFacade, - plugins: ReportingSetupDeps, handleRoute: HandlerFunction, - handleRouteError: HandlerErrorFunction, - logger: Logger + handleRouteError: HandlerErrorFunction ) { - const config = reporting.getConfig(); - const routeOptions = getRouteOptionsCsv(config, plugins, logger); - - server.route({ - path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, - method: 'POST', - options: routeOptions, - handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { - const requestFacade = makeRequestFacade(legacyRequest); - + const setupDeps = reporting.getPluginSetupDeps(); + const userHandler = authorizedUserPreRoutingFactory(reporting); + const { router } = setupDeps; + router.post( + { + path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, + validate: { + params: schema.object({ + savedObjectType: schema.string({ minLength: 2 }), + savedObjectId: schema.string({ minLength: 2 }), + }), + body: schema.object({ + state: schema.object({}), + timerange: schema.object({ + timezone: schema.string({ defaultValue: 'UTC' }), + min: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), + max: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), + }), + }), + }, + }, + userHandler(async (user, context, req, res) => { /* * 1. Build `jobParams` object: job data that execution will need to reference in various parts of the lifecycle * 2. Pass the jobParams and other common params to `handleRoute`, a shared function to enqueue the job with the params @@ -54,19 +55,31 @@ export function registerGenerateCsvFromSavedObject( */ let result: QueuedJobPayload; try { - const jobParams = getJobParamsFromRequest(requestFacade, { isImmediate: false }); - result = await handleRoute(CSV_FROM_SAVEDOBJECT_JOB_TYPE, jobParams, legacyRequest, h); // pass the original request because the handler will make the request facade on its own + const jobParams = getJobParamsFromRequest(req, { isImmediate: false }); + result = await handleRoute( + user, + CSV_FROM_SAVEDOBJECT_JOB_TYPE, + jobParams, + context, + req, + res + ); } catch (err) { - throw handleRouteError(CSV_FROM_SAVEDOBJECT_JOB_TYPE, err); + return handleRouteError(res, err); } if (get(result, 'source.job') == null) { - throw new Error( - `The Export handler is expected to return a result with job info! ${result}` - ); + return res.badRequest({ + body: `The Export handler is expected to return a result with job info! ${result}`, + }); } - return result; - }, - }); + return res.ok({ + body: result, + headers: { + 'content-type': 'application/json', + }, + }); + }) + ); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 22aebb05cdf49..8a6d4553dfa9c 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -4,22 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ResponseObject } from 'hapi'; -import { Legacy } from 'kibana'; +import { schema } from '@kbn/config-schema'; import { ReportingCore } from '../'; +import { HandlerErrorFunction } from './types'; import { API_BASE_GENERATE_V1 } from '../../common/constants'; import { createJobFactory, executeJobFactory } from '../../export_types/csv_from_savedobject'; import { getJobParamsFromRequest } from '../../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { JobDocPayloadPanelCsv } from '../../export_types/csv_from_savedobject/types'; import { LevelLogger as Logger } from '../lib'; -import { JobDocOutput, ReportingSetupDeps, ServerFacade } from '../types'; -import { makeRequestFacade } from './lib/make_request_facade'; -import { getRouteOptionsCsv } from './lib/route_config_factories'; -import { ReportingResponseToolkit } from './types'; - -type ResponseFacade = ResponseObject & { - isBoom: boolean; -}; +import { JobDocOutput } from '../types'; +import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; /* * This function registers API Endpoints for immediate Reporting jobs. The API inputs are: @@ -32,61 +26,77 @@ type ResponseFacade = ResponseObject & { */ export function registerGenerateCsvFromSavedObjectImmediate( reporting: ReportingCore, - server: ServerFacade, - plugins: ReportingSetupDeps, + handleError: HandlerErrorFunction, parentLogger: Logger ) { - const config = reporting.getConfig(); - const routeOptions = getRouteOptionsCsv(config, plugins, parentLogger); + const setupDeps = reporting.getPluginSetupDeps(); + const userHandler = authorizedUserPreRoutingFactory(reporting); + const { router } = setupDeps; /* * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: * - re-use the createJob function to build up es query config * - re-use the executeJob function to run the scan and scroll queries and capture the entire CSV in a result object. */ - server.route({ - path: `${API_BASE_GENERATE_V1}/immediate/csv/saved-object/{savedObjectType}:{savedObjectId}`, - method: 'POST', - options: routeOptions, - handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { - const request = makeRequestFacade(legacyRequest); + router.post( + { + path: `${API_BASE_GENERATE_V1}/immediate/csv/saved-object/{savedObjectType}:{savedObjectId}`, + validate: { + params: schema.object({ + savedObjectType: schema.string({ minLength: 5 }), + savedObjectId: schema.string({ minLength: 5 }), + }), + body: schema.object({ + state: schema.object({}, { unknowns: 'allow' }), + timerange: schema.object({ + timezone: schema.string({ defaultValue: 'UTC' }), + min: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), + max: schema.nullable(schema.oneOf([schema.number(), schema.string({ minLength: 5 })])), + }), + }), + }, + }, + userHandler(async (user, context, req, res) => { const logger = parentLogger.clone(['savedobject-csv']); - const jobParams = getJobParamsFromRequest(request, { isImmediate: true }); + const jobParams = getJobParamsFromRequest(req, { isImmediate: true }); const createJobFn = createJobFactory(reporting, logger); const executeJobFn = await executeJobFactory(reporting, logger); // FIXME: does not "need" to be async - const jobDocPayload: JobDocPayloadPanelCsv = await createJobFn( - jobParams, - request.headers, - request - ); - const { - content_type: jobOutputContentType, - content: jobOutputContent, - size: jobOutputSize, - }: JobDocOutput = await executeJobFn(null, jobDocPayload, request); - logger.info(`Job output size: ${jobOutputSize} bytes`); + try { + const jobDocPayload: JobDocPayloadPanelCsv = await createJobFn( + jobParams, + req.headers, + context, + req + ); + const { + content_type: jobOutputContentType, + content: jobOutputContent, + size: jobOutputSize, + }: JobDocOutput = await executeJobFn(null, jobDocPayload, context, req); - /* - * ESQueue worker function defaults `content` to null, even if the - * executeJob returned undefined. - * - * This converts null to undefined so the value can be sent to h.response() - */ - if (jobOutputContent === null) { - logger.warn('CSV Job Execution created empty content result'); - } - const response = h - .response(jobOutputContent ? jobOutputContent : undefined) - .type(jobOutputContentType); + logger.info(`Job output size: ${jobOutputSize} bytes`); - // Set header for buffer download, not streaming - const { isBoom } = response as ResponseFacade; - if (isBoom == null) { - response.header('accept-ranges', 'none'); - } + /* + * ESQueue worker function defaults `content` to null, even if the + * executeJob returned undefined. + * + * This converts null to undefined so the value can be sent to h.response() + */ + if (jobOutputContent === null) { + logger.warn('CSV Job Execution created empty content result'); + } - return response; - }, - }); + return res.ok({ + body: jobOutputContent || '', + headers: { + 'content-type': jobOutputContentType, + 'accept-ranges': 'none', + }, + }); + } catch (err) { + return handleError(res, err); + } + }) + ); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts index d767d37a477ab..fdde3253cf28e 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts @@ -4,140 +4,162 @@ * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; -import { ReportingConfig, ReportingCore } from '../'; -import { createMockReportingCore } from '../../test_helpers'; -import { LevelLogger as Logger } from '../lib'; -import { ReportingSetupDeps, ServerFacade } from '../types'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { setupServer } from 'src/core/server/saved_objects/routes/integration_tests/test_utils'; import { registerJobGenerationRoutes } from './generation'; +import { createMockReportingCore } from '../../test_helpers'; +import { ReportingCore } from '..'; +import { ExportTypesRegistry } from '../lib/export_types_registry'; +import { ExportTypeDefinition } from '../types'; +import { LevelLogger } from '../lib'; +import { of } from 'rxjs'; + +type setupServerReturn = UnwrapPromise>; + +describe('POST /api/reporting/generate', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let exportTypesRegistry: ExportTypesRegistry; + let core: ReportingCore; + + const config = { + get: jest.fn().mockImplementation((...args) => { + const key = args.join('.'); + switch (key) { + case 'queue.indexInterval': + return 10000; + case 'queue.timeout': + return 10000; + case 'index': + return '.reporting'; + case 'queue.pollEnabled': + return false; + default: + return; + } + }), + kbnConfig: { get: jest.fn() }, + }; + const mockLogger = ({ + error: jest.fn(), + debug: jest.fn(), + } as unknown) as jest.Mocked; + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer()); + const mockDeps = ({ + elasticsearch: { + adminClient: { callAsInternalUser: jest.fn() }, + }, + security: { + authc: { + getCurrentUser: () => ({ + id: '123', + roles: ['superuser'], + username: 'Tom Riddle', + }), + }, + }, + router: httpSetup.createRouter(''), + licensing: { + license$: of({ + isActive: true, + isAvailable: true, + type: 'gold', + }), + }, + } as unknown) as any; + core = await createMockReportingCore(config, mockDeps); + exportTypesRegistry = new ExportTypesRegistry(); + exportTypesRegistry.register({ + id: 'printablePdf', + jobType: 'printable_pdf', + jobContentEncoding: 'base64', + jobContentExtension: 'pdf', + validLicenses: ['basic', 'gold'], + } as ExportTypeDefinition); + core.getExportTypesRegistry = () => exportTypesRegistry; + }); -jest.mock('./lib/authorized_user_pre_routing', () => ({ - authorizedUserPreRoutingFactory: () => () => ({}), -})); -jest.mock('./lib/reporting_feature_pre_routing', () => ({ - reportingFeaturePreRoutingFactory: () => () => () => ({ - jobTypes: ['unencodedJobType', 'base64EncodedJobType'], - }), -})); - -let mockServer: Hapi.Server; -let mockReportingPlugin: ReportingCore; -let mockReportingConfig: ReportingConfig; - -const mockLogger = ({ - error: jest.fn(), - debug: jest.fn(), -} as unknown) as Logger; - -beforeEach(async () => { - mockServer = new Hapi.Server({ - debug: false, - port: 8080, - routes: { log: { collect: true } }, + afterEach(async () => { + mockLogger.debug.mockReset(); + mockLogger.error.mockReset(); + await server.stop(); }); - mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; - mockReportingPlugin = await createMockReportingCore(mockReportingConfig); - mockReportingPlugin.getEnqueueJob = async () => - jest.fn().mockImplementation(() => ({ toJSON: () => '{ "job": "data" }' })); -}); + it('returns 400 if there are no job params', async () => { + registerJobGenerationRoutes(core, mockLogger); -const mockPlugins = { - elasticsearch: { - adminClient: { callAsInternalUser: jest.fn() }, - }, - security: null, -}; - -const getErrorsFromRequest = (request: Hapi.Request) => { - // @ts-ignore error property doesn't exist on RequestLog - return request.logs.filter((log) => log.tags.includes('error')).map((log) => log.error); // NOTE: error stack is available -}; - -test(`returns 400 if there are no job params`, async () => { - registerJobGenerationRoutes( - mockReportingPlugin, - (mockServer as unknown) as ServerFacade, - (mockPlugins as unknown) as ReportingSetupDeps, - mockLogger - ); - - const options = { - method: 'POST', - url: '/api/reporting/generate/printablePdf', - }; + await server.start(); - const { payload, request } = await mockServer.inject(options); - expect(payload).toMatchInlineSnapshot( - `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"A jobParams RISON string is required\\"}"` - ); - - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: A jobParams RISON string is required], - ] - `); -}); + await supertest(httpSetup.server.listener) + .post('/api/reporting/generate/printablePdf') + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + '"A jobParams RISON string is required in the querystring or POST body"' + ) + ); + }); -test(`returns 400 if job params is invalid`, async () => { - registerJobGenerationRoutes( - mockReportingPlugin, - (mockServer as unknown) as ServerFacade, - (mockPlugins as unknown) as ReportingSetupDeps, - mockLogger - ); - - const options = { - method: 'POST', - url: '/api/reporting/generate/printablePdf', - payload: { jobParams: `foo:` }, - }; + it('returns 400 if job params query is invalid', async () => { + registerJobGenerationRoutes(core, mockLogger); - const { payload, request } = await mockServer.inject(options); - expect(payload).toMatchInlineSnapshot( - `"{\\"statusCode\\":400,\\"error\\":\\"Bad Request\\",\\"message\\":\\"invalid rison: foo:\\"}"` - ); - - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: invalid rison: foo:], - ] - `); -}); + await server.start(); -test(`returns 500 if job handler throws an error`, async () => { - mockReportingPlugin.getEnqueueJob = async () => - jest.fn().mockImplementation(() => ({ - toJSON: () => { - throw new Error('you found me'); - }, - })); - - registerJobGenerationRoutes( - mockReportingPlugin, - (mockServer as unknown) as ServerFacade, - (mockPlugins as unknown) as ReportingSetupDeps, - mockLogger - ); - - const options = { - method: 'POST', - url: '/api/reporting/generate/printablePdf', - payload: { jobParams: `abc` }, - }; + await supertest(httpSetup.server.listener) + .post('/api/reporting/generate/printablePdf?jobParams=foo:') + .expect(400) + .then(({ body }) => expect(body.message).toMatchInlineSnapshot('"invalid rison: foo:"')); + }); + + it('returns 400 if job params body is invalid', async () => { + registerJobGenerationRoutes(core, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/generate/printablePdf') + .send({ jobParams: `foo:` }) + .expect(400) + .then(({ body }) => expect(body.message).toMatchInlineSnapshot('"invalid rison: foo:"')); + }); + + it('returns 400 export type is invalid', async () => { + registerJobGenerationRoutes(core, mockLogger); + + await server.start(); - const { payload, request } = await mockServer.inject(options); - expect(payload).toMatchInlineSnapshot( - `"{\\"statusCode\\":500,\\"error\\":\\"Internal Server Error\\",\\"message\\":\\"An internal server error occurred\\"}"` - ); - - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: you found me], - [Error: you found me], - ] - `); + await supertest(httpSetup.server.listener) + .post('/api/reporting/generate/TonyHawksProSkater2') + .send({ jobParams: `abc` }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot('"Invalid export-type of TonyHawksProSkater2"') + ); + }); + + it('returns 400 if job handler throws an error', async () => { + const errorText = 'you found me'; + core.getEnqueueJob = async () => + jest.fn().mockImplementation(() => ({ + toJSON: () => { + throw new Error(errorText); + }, + })); + + registerJobGenerationRoutes(core, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post('/api/reporting/generate/printablePdf') + .send({ jobParams: `abc` }) + .expect(400) + .then(({ body }) => { + expect(body.message).toMatchInlineSnapshot(`"${errorText}"`); + }); + }); }); diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.ts index 56faa37d5fcbd..f2e616c0803a7 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.ts @@ -4,27 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import boom from 'boom'; +import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; -import { Legacy } from 'kibana'; +import { kibanaResponseFactory } from 'src/core/server'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { LevelLogger as Logger } from '../lib'; -import { ReportingSetupDeps, ServerFacade } from '../types'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; -import { makeRequestFacade } from './lib/make_request_facade'; -import { ReportingResponseToolkit } from './types'; +import { HandlerFunction } from './types'; const esErrors = elasticsearchErrors as Record; -export function registerJobGenerationRoutes( - reporting: ReportingCore, - server: ServerFacade, - plugins: ReportingSetupDeps, - logger: Logger -) { +export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Logger) { const config = reporting.getConfig(); const downloadBaseUrl = config.kbnConfig.get('server', 'basePath') + `${API_BASE_URL}/jobs/download`; @@ -32,48 +25,71 @@ export function registerJobGenerationRoutes( /* * Generates enqueued job details to use in responses */ - async function handler( - exportTypeId: string, - jobParams: object, - legacyRequest: Legacy.Request, - h: ReportingResponseToolkit - ) { - const request = makeRequestFacade(legacyRequest); - const user = request.pre.user; - const headers = request.headers; + const handler: HandlerFunction = async (user, exportTypeId, jobParams, context, req, res) => { + const licenseInfo = await reporting.getLicenseInfo(); + const licenseResults = licenseInfo[exportTypeId]; + + if (!licenseResults) { + return res.badRequest({ body: `Invalid export-type of ${exportTypeId}` }); + } + + if (!licenseResults.enableLinks) { + return res.forbidden({ body: licenseResults.message }); + } const enqueueJob = await reporting.getEnqueueJob(); - const job = await enqueueJob(exportTypeId, jobParams, user, headers, request); + const job = await enqueueJob(exportTypeId, jobParams, user, context, req); // return the queue's job information const jobJson = job.toJSON(); - return h - .response({ + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: { path: `${downloadBaseUrl}/${jobJson.id}`, job: jobJson, - }) - .type('application/json'); - } + }, + }); + }; + + function handleError(res: typeof kibanaResponseFactory, err: Error | Boom) { + if (err instanceof Boom) { + return res.customError({ + statusCode: err.output.statusCode, + body: err.output.payload.message, + }); + } - function handleError(exportTypeId: string, err: Error) { if (err instanceof esErrors['401']) { - return boom.unauthorized(`Sorry, you aren't authenticated`); + return res.unauthorized({ + body: `Sorry, you aren't authenticated`, + }); } + if (err instanceof esErrors['403']) { - return boom.forbidden(`Sorry, you are not authorized to create ${exportTypeId} reports`); + return res.forbidden({ + body: `Sorry, you are not authorized`, + }); } + if (err instanceof esErrors['404']) { - return boom.boomify(err, { statusCode: 404 }); + return res.notFound({ + body: err.message, + }); } - return err; + + return res.badRequest({ + body: err.message, + }); } - registerGenerateFromJobParams(reporting, server, plugins, handler, handleError, logger); + registerGenerateFromJobParams(reporting, handler, handleError); // Register beta panel-action download-related API's if (config.get('csv', 'enablePanelActionDownload')) { - registerGenerateCsvFromSavedObject(reporting, server, plugins, handler, handleError, logger); - registerGenerateCsvFromSavedObjectImmediate(reporting, server, plugins, logger); + registerGenerateCsvFromSavedObject(reporting, handler, handleError); + registerGenerateCsvFromSavedObjectImmediate(reporting, handleError, logger); } } diff --git a/x-pack/legacy/plugins/reporting/server/routes/index.ts b/x-pack/legacy/plugins/reporting/server/routes/index.ts index 556f4e12b077e..005d82086665c 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/index.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/index.ts @@ -4,18 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ReportingCore } from '../'; import { LevelLogger as Logger } from '../lib'; -import { ReportingSetupDeps, ServerFacade } from '../types'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; +import { ReportingCore } from '../core'; -export function registerRoutes( - reporting: ReportingCore, - server: ServerFacade, - plugins: ReportingSetupDeps, - logger: Logger -) { - registerJobGenerationRoutes(reporting, server, plugins, logger); - registerJobInfoRoutes(reporting, server, plugins, logger); +export function registerRoutes(reporting: ReportingCore, logger: Logger) { + registerJobGenerationRoutes(reporting, logger); + registerJobInfoRoutes(reporting); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.ts index 4f597bcee858e..73f3c660141c1 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.ts @@ -4,327 +4,347 @@ * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; -import { ReportingConfig, ReportingCore } from '../'; -import { LevelLogger } from '../lib'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@kbn/utility-types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { setupServer } from 'src/core/server/saved_objects/routes/integration_tests/test_utils'; +import { registerJobInfoRoutes } from './jobs'; import { createMockReportingCore } from '../../test_helpers'; +import { ReportingCore } from '..'; import { ExportTypesRegistry } from '../lib/export_types_registry'; -import { ExportTypeDefinition, ReportingSetupDeps } from '../types'; -import { registerJobInfoRoutes } from './jobs'; - -jest.mock('./lib/authorized_user_pre_routing', () => ({ - authorizedUserPreRoutingFactory: () => () => ({}), -})); -jest.mock('./lib/reporting_feature_pre_routing', () => ({ - reportingFeaturePreRoutingFactory: () => () => () => ({ - jobTypes: ['unencodedJobType', 'base64EncodedJobType'], - }), -})); - -let mockServer: any; -let exportTypesRegistry: ExportTypesRegistry; -let mockReportingPlugin: ReportingCore; -let mockReportingConfig: ReportingConfig; -const mockLogger = ({ - error: jest.fn(), - debug: jest.fn(), -} as unknown) as LevelLogger; - -beforeEach(async () => { - mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); - exportTypesRegistry = new ExportTypesRegistry(); - exportTypesRegistry.register({ - id: 'unencoded', - jobType: 'unencodedJobType', - jobContentExtension: 'csv', - } as ExportTypeDefinition); - exportTypesRegistry.register({ - id: 'base64Encoded', - jobType: 'base64EncodedJobType', - jobContentEncoding: 'base64', - jobContentExtension: 'pdf', - } as ExportTypeDefinition); - - mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; - mockReportingPlugin = await createMockReportingCore(mockReportingConfig); - mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; -}); - -const mockPlugins = ({ - elasticsearch: { - adminClient: { callAsInternalUser: jest.fn() }, - }, - security: null, -} as unknown) as ReportingSetupDeps; - -const getHits = (...sources: any) => { - return { - hits: { - hits: sources.map((source: object) => ({ _source: source })), - }, - }; -}; - -const getErrorsFromRequest = (request: any) => - request.logs.filter((log: any) => log.tags.includes('error')).map((log: any) => log.error); - -test(`returns 404 if job not found`, async () => { - // @ts-ignore - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - const response = await mockServer.inject(request); - const { statusCode } = response; - expect(statusCode).toBe(404); -}); - -test(`returns 401 if not valid job type`, async () => { - // @ts-ignore - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest - .fn() - .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', +import { ExportTypeDefinition } from '../types'; +import { LevelLogger } from '../lib'; +import { ReportingInternalSetup } from '../core'; +import { of } from 'rxjs'; + +type setupServerReturn = UnwrapPromise>; + +describe('GET /api/reporting/jobs/download', () => { + let server: setupServerReturn['server']; + let httpSetup: setupServerReturn['httpSetup']; + let exportTypesRegistry: ExportTypesRegistry; + let core: ReportingCore; + + const config = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; + const mockLogger = ({ + error: jest.fn(), + debug: jest.fn(), + } as unknown) as jest.Mocked; + + const getHits = (...sources: any) => { + return { + hits: { + hits: sources.map((source: object) => ({ _source: source })), + }, + }; }; - const { statusCode } = await mockServer.inject(request); - expect(statusCode).toBe(401); -}); - -describe(`when job is incomplete`, () => { - const getIncompleteResponse = async () => { + beforeEach(async () => { + ({ server, httpSetup } = await setupServer()); + core = await createMockReportingCore(config, ({ + elasticsearch: { + adminClient: { callAsInternalUser: jest.fn() }, + }, + security: { + authc: { + getCurrentUser: () => ({ + id: '123', + roles: ['superuser'], + username: 'Tom Riddle', + }), + }, + }, + router: httpSetup.createRouter(''), + licensing: { + license$: of({ + isActive: true, + isAvailable: true, + type: 'gold', + }), + }, + } as unknown) as ReportingInternalSetup); // @ts-ignore - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest - .fn() - .mockReturnValue( - Promise.resolve(getHits({ jobtype: 'unencodedJobType', status: 'pending' })) - ), - }; + exportTypesRegistry = new ExportTypesRegistry(); + exportTypesRegistry.register({ + id: 'unencoded', + jobType: 'unencodedJobType', + jobContentExtension: 'csv', + validLicenses: ['basic', 'gold'], + } as ExportTypeDefinition); + exportTypesRegistry.register({ + id: 'base64Encoded', + jobType: 'base64EncodedJobType', + jobContentEncoding: 'base64', + jobContentExtension: 'pdf', + validLicenses: ['basic', 'gold'], + } as ExportTypeDefinition); + core.getExportTypesRegistry = () => exportTypesRegistry; + }); - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + afterEach(async () => { + mockLogger.debug.mockReset(); + mockLogger.error.mockReset(); + await server.stop(); + }); - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', + it('fails on malformed download IDs', async () => { + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), }; + registerJobInfoRoutes(core); - return await mockServer.inject(request); - }; + await server.start(); - test(`sets statusCode to 503`, async () => { - const { statusCode } = await getIncompleteResponse(); - expect(statusCode).toBe(503); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/1') + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + '"[request params.docId]: value has length [1] but it must have a minimum length of [3]."' + ) + ); }); - test(`uses status as payload`, async () => { - const { payload } = await getIncompleteResponse(); - expect(payload).toBe('pending'); - }); + it('fails on unauthenticated users', async () => { + // @ts-ignore + core.pluginSetupDeps = ({ + // @ts-ignore + ...core.pluginSetupDeps, + security: { + authc: { + getCurrentUser: () => undefined, + }, + }, + } as unknown) as ReportingInternalSetup; + registerJobInfoRoutes(core); - test(`sets content-type header to application/json; charset=utf-8`, async () => { - const { headers } = await getIncompleteResponse(); - expect(headers['content-type']).toBe('application/json; charset=utf-8'); - }); + await server.start(); - test(`sets retry-after header to 30`, async () => { - const { headers } = await getIncompleteResponse(); - expect(headers['retry-after']).toBe(30); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dope') + .expect(401) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot(`"Sorry, you aren't authenticated"`) + ); }); -}); -describe(`when job is failed`, () => { - const getFailedResponse = async () => { - const hits = getHits({ - jobtype: 'unencodedJobType', - status: 'failed', - output: { content: 'job failure message' }, - }); + it('fails on users without the appropriate role', async () => { // @ts-ignore - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), - }; - - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); - - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - return await mockServer.inject(request); - }; - - test(`sets status code to 500`, async () => { - const { statusCode } = await getFailedResponse(); - expect(statusCode).toBe(500); - }); + core.pluginSetupDeps = ({ + // @ts-ignore + ...core.pluginSetupDeps, + security: { + authc: { + getCurrentUser: () => ({ + id: '123', + roles: ['peasant'], + username: 'Tom Riddle', + }), + }, + }, + } as unknown) as ReportingInternalSetup; + registerJobInfoRoutes(core); - test(`sets content-type header to application/json; charset=utf-8`, async () => { - const { headers } = await getFailedResponse(); - expect(headers['content-type']).toBe('application/json; charset=utf-8'); - }); + await server.start(); - test(`sets the payload.reason to the job content`, async () => { - const { payload } = await getFailedResponse(); - expect(JSON.parse(payload).reason).toBe('job failure message'); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dope') + .expect(403) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot(`"Sorry, you don't have access to Reporting"`) + ); }); -}); -describe(`when job is completed`, () => { - const getCompletedResponse = async ({ - jobType = 'unencodedJobType', - outputContent = 'job output content', - outputContentType = 'application/pdf', - title = '', - } = {}) => { - const hits = getHits({ - jobtype: jobType, - status: 'completed', - output: { content: outputContent, content_type: outputContentType }, - payload: { - title, - }, - }); + it('returns 404 if job not found', async () => { // @ts-ignore - mockPlugins.elasticsearch.adminClient = { - callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes(core); - const request = { - method: 'GET', - url: '/api/reporting/jobs/download/1', - }; - - return await mockServer.inject(request); - }; + await server.start(); - test(`sets statusCode to 200`, async () => { - const { statusCode, request } = await getCompletedResponse(); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(statusCode).toBe(200); + await supertest(httpSetup.server.listener).get('/api/reporting/jobs/download/poo').expect(404); }); - test(`doesn't encode output content for not-specified jobTypes`, async () => { - const { payload, request } = await getCompletedResponse({ - jobType: 'unencodedJobType', - outputContent: 'test', - }); + it('returns a 401 if not a valid job type', async () => { + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest + .fn() + .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), + }; + registerJobInfoRoutes(core); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); + await server.start(); - expect(payload).toBe('test'); + await supertest(httpSetup.server.listener).get('/api/reporting/jobs/download/poo').expect(401); }); - test(`base64 encodes output content for configured jobTypes`, async () => { - const { payload, request } = await getCompletedResponse({ - jobType: 'base64EncodedJobType', - outputContent: 'test', - }); - - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); + it('when a job is incomplete', async () => { + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest + .fn() + .mockReturnValue( + Promise.resolve(getHits({ jobtype: 'unencodedJobType', status: 'pending' })) + ), + }; + registerJobInfoRoutes(core); + + await server.start(); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dank') + .expect(503) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('Retry-After', '30') + .then(({ text }) => expect(text).toEqual('pending')); + }); - expect(payload).toBe(Buffer.from('test', 'base64').toString()); + it('when a job fails', async () => { + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue( + Promise.resolve( + getHits({ + jobtype: 'unencodedJobType', + status: 'failed', + output: { content: 'job failure message' }, + }) + ) + ), + }; + registerJobInfoRoutes(core); + + await server.start(); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dank') + .expect(500) + .expect('Content-Type', 'application/json; charset=utf-8') + .then(({ body }) => + expect(body.message).toEqual('Reporting generation failed: job failure message') + ); }); - test(`specifies text/csv; charset=utf-8 contentType header from the job output`, async () => { - const { headers, request } = await getCompletedResponse({ outputContentType: 'text/csv' }); + describe('successful downloads', () => { + const getCompleteHits = async ({ + jobType = 'unencodedJobType', + outputContent = 'job output content', + outputContentType = 'text/plain', + title = '', + } = {}) => { + return getHits({ + jobtype: jobType, + status: 'completed', + output: { content: outputContent, content_type: outputContentType }, + payload: { + title, + }, + }); + }; - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); + it('when a known job-type is complete', async () => { + const hits = getCompleteHits(); + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + }; + registerJobInfoRoutes(core); + + await server.start(); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dank') + .expect(200) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('content-disposition', 'inline; filename="report.csv"'); + }); - expect(headers['content-type']).toBe('text/csv; charset=utf-8'); - }); + it('succeeds when security is not there or disabled', async () => { + const hits = getCompleteHits(); + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + }; - test(`specifies default filename in content-disposition header if no title`, async () => { - const { headers, request } = await getCompletedResponse({}); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-disposition']).toBe('inline; filename="report.csv"'); - }); + // @ts-ignore + core.pluginSetupDeps.security = null; - test(`specifies payload title in content-disposition header`, async () => { - const { headers, request } = await getCompletedResponse({ title: 'something' }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-disposition']).toBe('inline; filename="something.csv"'); - }); + registerJobInfoRoutes(core); - test(`specifies jobContentExtension in content-disposition header`, async () => { - const { headers, request } = await getCompletedResponse({ jobType: 'base64EncodedJobType' }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-disposition']).toBe('inline; filename="report.pdf"'); - }); + await server.start(); - test(`specifies application/pdf contentType header from the job output`, async () => { - const { headers, request } = await getCompletedResponse({ - outputContentType: 'application/pdf', + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dope') + .expect(200) + .expect('Content-Type', 'text/plain; charset=utf-8') + .expect('content-disposition', 'inline; filename="report.csv"'); }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toEqual([]); - expect(headers['content-type']).toBe('application/pdf'); - }); - describe(`when non-whitelisted contentType specified in job output`, () => { - test(`sets statusCode to 500`, async () => { - const { statusCode, request } = await getCompletedResponse({ - outputContentType: 'application/html', + it(`doesn't encode output-content for non-specified job-types`, async () => { + const hits = getCompleteHits({ + jobType: 'unencodedJobType', + outputContent: 'test', }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: Unsupported content-type of application/html specified by job output], - [Error: Unsupported content-type of application/html specified by job output], - ] - `); - expect(statusCode).toBe(500); + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + }; + registerJobInfoRoutes(core); + + await server.start(); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dank') + .expect(200) + .expect('Content-Type', 'text/plain; charset=utf-8') + .then(({ text }) => expect(text).toEqual('test')); }); - test(`doesn't include job output content in payload`, async () => { - const { payload, request } = await getCompletedResponse({ - outputContentType: 'application/html', + it(`base64 encodes output content for configured jobTypes`, async () => { + const hits = getCompleteHits({ + jobType: 'base64EncodedJobType', + outputContent: 'test', + outputContentType: 'application/pdf', }); - expect(payload).toMatchInlineSnapshot( - `"{\\"statusCode\\":500,\\"error\\":\\"Internal Server Error\\",\\"message\\":\\"An internal server error occurred\\"}"` - ); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: Unsupported content-type of application/html specified by job output], - [Error: Unsupported content-type of application/html specified by job output], - ] - `); + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + }; + registerJobInfoRoutes(core); + + await server.start(); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dank') + .expect(200) + .expect('Content-Type', 'application/pdf') + .expect('content-disposition', 'inline; filename="report.pdf"') + .then(({ body }) => expect(Buffer.from(body).toString('base64')).toEqual('test')); }); - test(`logs error message about invalid content type`, async () => { - const { request } = await getCompletedResponse({ outputContentType: 'application/html' }); - const errorLogs = getErrorsFromRequest(request); - expect(errorLogs).toMatchInlineSnapshot(` - Array [ - [Error: Unsupported content-type of application/html specified by job output], - [Error: Unsupported content-type of application/html specified by job output], - ] - `); + it('refuses to return unknown content-types', async () => { + const hits = getCompleteHits({ + jobType: 'unencodedJobType', + outputContent: 'alert("all your base mine now");', + outputContentType: 'application/html', + }); + // @ts-ignore + core.pluginSetupDeps.elasticsearch.adminClient = { + callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), + }; + registerJobInfoRoutes(core); + + await server.start(); + await supertest(httpSetup.server.listener) + .get('/api/reporting/jobs/download/dank') + .expect(400) + .then(({ body }) => { + expect(body).toEqual({ + error: 'Bad Request', + message: 'Unsupported content-type of application/html specified by job output', + statusCode: 400, + }); + }); }); }); }); diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index 59090961998af..8c35f79ec0fb4 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -5,24 +5,15 @@ */ import Boom from 'boom'; -import { ResponseObject } from 'hapi'; -import { Legacy } from 'kibana'; +import { schema } from '@kbn/config-schema'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; -import { LevelLogger as Logger } from '../lib'; import { jobsQueryFactory } from '../lib/jobs_query'; -import { JobDocOutput, JobSource, ReportingSetupDeps, ServerFacade } from '../types'; import { deleteJobResponseHandlerFactory, downloadJobResponseHandlerFactory, } from './lib/job_response_handler'; -import { makeRequestFacade } from './lib/make_request_facade'; -import { - getRouteConfigFactoryDeletePre, - getRouteConfigFactoryDownloadPre, - getRouteConfigFactoryManagementPre, -} from './lib/route_config_factories'; -import { ReportingResponseToolkit } from './types'; +import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; interface ListQuery { page: string; @@ -31,193 +22,192 @@ interface ListQuery { } const MAIN_ENTRY = `${API_BASE_URL}/jobs`; -function isResponse(response: Boom | ResponseObject): response is ResponseObject { - return !(response as Boom).isBoom; -} - -export function registerJobInfoRoutes( - reporting: ReportingCore, - server: ServerFacade, - plugins: ReportingSetupDeps, - logger: Logger -) { +export async function registerJobInfoRoutes(reporting: ReportingCore) { const config = reporting.getConfig(); - const { elasticsearch } = plugins; + const setupDeps = reporting.getPluginSetupDeps(); + const userHandler = authorizedUserPreRoutingFactory(reporting); + const { elasticsearch, router } = setupDeps; const jobsQuery = jobsQueryFactory(config, elasticsearch); - const getRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); // list jobs in the queue, paginated - server.route({ - path: `${MAIN_ENTRY}/list`, - method: 'GET', - options: getRouteConfig(), - handler: (legacyRequest: Legacy.Request) => { - const request = makeRequestFacade(legacyRequest); - const { page: queryPage, size: querySize, ids: queryIds } = request.query as ListQuery; + router.get( + { + path: `${MAIN_ENTRY}/list`, + validate: false, + }, + userHandler(async (user, context, req, res) => { + const { + management: { jobTypes = [] }, + } = await reporting.getLicenseInfo(); + const { + page: queryPage = '0', + size: querySize = '10', + ids: queryIds = null, + } = req.query as ListQuery; const page = parseInt(queryPage, 10) || 0; const size = Math.min(100, parseInt(querySize, 10) || 10); const jobIds = queryIds ? queryIds.split(',') : null; + const results = await jobsQuery.list(jobTypes, user, page, size, jobIds); - const results = jobsQuery.list( - request.pre.management.jobTypes, - request.pre.user, - page, - size, - jobIds - ); - return results; - }, - }); + return res.ok({ + body: results, + headers: { + 'content-type': 'application/json', + }, + }); + }) + ); // return the count of all jobs in the queue - server.route({ - path: `${MAIN_ENTRY}/count`, - method: 'GET', - options: getRouteConfig(), - handler: (legacyRequest: Legacy.Request) => { - const request = makeRequestFacade(legacyRequest); - const results = jobsQuery.count(request.pre.management.jobTypes, request.pre.user); - return results; + router.get( + { + path: `${MAIN_ENTRY}/count`, + validate: false, }, - }); + userHandler(async (user, context, req, res) => { + const { + management: { jobTypes = [] }, + } = await reporting.getLicenseInfo(); + + const count = await jobsQuery.count(jobTypes, user); + + return res.ok({ + body: count.toString(), + headers: { + 'content-type': 'text/plain', + }, + }); + }) + ); // return the raw output from a job - server.route({ - path: `${MAIN_ENTRY}/output/{docId}`, - method: 'GET', - options: getRouteConfig(), - handler: (legacyRequest: Legacy.Request) => { - const request = makeRequestFacade(legacyRequest); - const { docId } = request.params; - - return jobsQuery.get(request.pre.user, docId, { includeContent: true }).then( - (result): JobDocOutput => { - if (!result) { - throw Boom.notFound(); - } - const { - _source: { jobtype: jobType, output: jobOutput }, - } = result; - - if (!request.pre.management.jobTypes.includes(jobType)) { - throw Boom.unauthorized(`Sorry, you are not authorized to download ${jobType} reports`); - } - - return jobOutput; - } - ); + router.get( + { + path: `${MAIN_ENTRY}/output/{docId}`, + validate: { + params: schema.object({ + docId: schema.string({ minLength: 2 }), + }), + }, }, - }); + userHandler(async (user, context, req, res) => { + const { docId } = req.params as { docId: string }; + const { + management: { jobTypes = [] }, + } = await reporting.getLicenseInfo(); + + const result = await jobsQuery.get(user, docId, { includeContent: true }); + + if (!result) { + throw Boom.notFound(); + } + + const { + _source: { jobtype: jobType, output: jobOutput }, + } = result; + + if (!jobTypes.includes(jobType)) { + throw Boom.unauthorized(`Sorry, you are not authorized to download ${jobType} reports`); + } + + return res.ok({ + body: jobOutput, + headers: { + 'content-type': 'application/json', + }, + }); + }) + ); // return some info about the job - server.route({ - path: `${MAIN_ENTRY}/info/{docId}`, - method: 'GET', - options: getRouteConfig(), - handler: (legacyRequest: Legacy.Request) => { - const request = makeRequestFacade(legacyRequest); - const { docId } = request.params; - - return jobsQuery.get(request.pre.user, docId).then((result): JobSource['_source'] => { - if (!result) { - throw Boom.notFound(); - } - - const { _source: job } = result; - const { jobtype: jobType, payload: jobPayload } = job; - if (!request.pre.management.jobTypes.includes(jobType)) { - throw Boom.unauthorized(`Sorry, you are not authorized to view ${jobType} info`); - } - - return { + router.get( + { + path: `${MAIN_ENTRY}/info/{docId}`, + validate: { + params: schema.object({ + docId: schema.string({ minLength: 2 }), + }), + }, + }, + userHandler(async (user, context, req, res) => { + const { docId } = req.params as { docId: string }; + const { + management: { jobTypes = [] }, + } = await reporting.getLicenseInfo(); + + const result = await jobsQuery.get(user, docId); + + if (!result) { + throw Boom.notFound(); + } + + const { _source: job } = result; + const { jobtype: jobType, payload: jobPayload } = job; + + if (!jobTypes.includes(jobType)) { + throw Boom.unauthorized(`Sorry, you are not authorized to view ${jobType} info`); + } + + return res.ok({ + body: { ...job, payload: { ...jobPayload, headers: undefined, }, - }; + }, + headers: { + 'content-type': 'application/json', + }, }); - }, - }); + }) + ); // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(config, plugins, logger); - const downloadResponseHandler = downloadJobResponseHandlerFactory(config, elasticsearch, exportTypesRegistry); // prettier-ignore - server.route({ - path: `${MAIN_ENTRY}/download/{docId}`, - method: 'GET', - options: getRouteConfigDownload(), - handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { - const request = makeRequestFacade(legacyRequest); - const { docId } = request.params; - - let response = await downloadResponseHandler( - request.pre.management.jobTypes, - request.pre.user, - h, - { docId } - ); - - if (isResponse(response)) { - const { statusCode } = response; - - if (statusCode !== 200) { - if (statusCode === 500) { - logger.error(`Report ${docId} has failed: ${JSON.stringify(response.source)}`); - } else { - logger.debug( - `Report ${docId} has non-OK status: [${statusCode}] Reason: [${JSON.stringify( - response.source - )}]` - ); - } - } - - response = response.header('accept-ranges', 'none'); - } - - return response; + const downloadResponseHandler = downloadJobResponseHandlerFactory( + config, + elasticsearch, + exportTypesRegistry + ); + + router.get( + { + path: `${MAIN_ENTRY}/download/{docId}`, + validate: { + params: schema.object({ + docId: schema.string({ minLength: 3 }), + }), + }, }, - }); + userHandler(async (user, context, req, res) => { + const { docId } = req.params as { docId: string }; + const { + management: { jobTypes = [] }, + } = await reporting.getLicenseInfo(); + + return downloadResponseHandler(res, jobTypes, user, { docId }); + }) + ); // allow a report to be deleted - const getRouteConfigDelete = getRouteConfigFactoryDeletePre(config, plugins, logger); const deleteResponseHandler = deleteJobResponseHandlerFactory(config, elasticsearch); - server.route({ - path: `${MAIN_ENTRY}/delete/{docId}`, - method: 'DELETE', - options: getRouteConfigDelete(), - handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => { - const request = makeRequestFacade(legacyRequest); - const { docId } = request.params; - - let response = await deleteResponseHandler( - request.pre.management.jobTypes, - request.pre.user, - h, - { docId } - ); - - if (isResponse(response)) { - const { statusCode } = response; - - if (statusCode !== 200) { - if (statusCode === 500) { - logger.error(`Report ${docId} has failed: ${JSON.stringify(response.source)}`); - } else { - logger.debug( - `Report ${docId} has non-OK status: [${statusCode}] Reason: [${JSON.stringify( - response.source - )}]` - ); - } - } - - response = response.header('accept-ranges', 'none'); - } - - return response; + router.delete( + { + path: `${MAIN_ENTRY}/delete/{docId}`, + validate: { + params: schema.object({ + docId: schema.string({ minLength: 3 }), + }), + }, }, - }); + userHandler(async (user, context, req, res) => { + const { docId } = req.params as { docId: string }; + const { + management: { jobTypes = [] }, + } = await reporting.getLicenseInfo(); + + return deleteResponseHandler(res, jobTypes, user, { docId }); + }) + ); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js deleted file mode 100644 index 2c80965432cd2..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; - -describe('authorized_user_pre_routing', function () { - const createMockConfig = (mockConfig = {}) => { - return { - get: (...keys) => mockConfig[keys.join('.')], - kbnConfig: { get: (...keys) => mockConfig[keys.join('.')] }, - }; - }; - const createMockPlugins = (function () { - const getUserStub = jest.fn(); - - return function ({ - securityEnabled = true, - xpackInfoUndefined = false, - xpackInfoAvailable = true, - getCurrentUser = undefined, - user = undefined, - }) { - getUserStub.mockReset(); - getUserStub.mockResolvedValue(user); - return { - security: securityEnabled - ? { - authc: { getCurrentUser }, - } - : null, - __LEGACY: { - plugins: { - xpack_main: { - info: !xpackInfoUndefined && { - isAvailable: () => xpackInfoAvailable, - feature(featureName) { - if (featureName === 'security') { - return { - isEnabled: () => securityEnabled, - isAvailable: () => xpackInfoAvailable, - }; - } - }, - }, - }, - }, - }, - }; - }; - })(); - - const mockRequestRaw = { - body: {}, - events: {}, - headers: {}, - isSystemRequest: false, - params: {}, - query: {}, - route: { settings: { payload: 'abc' }, options: { authRequired: true, body: {}, tags: [] } }, - withoutSecretHeaders: true, - }; - const getMockRequest = () => ({ - ...mockRequestRaw, - raw: { req: mockRequestRaw }, - }); - - const getMockLogger = () => ({ - warn: jest.fn(), - error: (msg) => { - throw new Error(msg); - }, - }); - - it('should return with boom notFound when xpackInfo is undefined', async function () { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ xpackInfoUndefined: true }), - getMockLogger() - ); - const response = await authorizedUserPreRouting(getMockRequest()); - expect(response.isBoom).toBe(true); - expect(response.output.statusCode).toBe(404); - }); - - it(`should return with boom notFound when xpackInfo isn't available`, async function () { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ xpackInfoAvailable: false }), - getMockLogger() - ); - const response = await authorizedUserPreRouting(getMockRequest()); - expect(response.isBoom).toBe(true); - expect(response.output.statusCode).toBe(404); - }); - - it('should return with null user when security is disabled in Elasticsearch', async function () { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - createMockPlugins({ securityEnabled: false }), - getMockLogger() - ); - const response = await authorizedUserPreRouting(getMockRequest()); - expect(response).toBe(null); - }); - - it('should return with boom unauthenticated when security is enabled but no authenticated user', async function () { - const mockPlugins = createMockPlugins({ - user: null, - config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, - }); - mockPlugins.security = { authc: { getCurrentUser: () => null } }; - - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - createMockConfig(), - mockPlugins, - getMockLogger() - ); - const response = await authorizedUserPreRouting(getMockRequest()); - expect(response.isBoom).toBe(true); - expect(response.output.statusCode).toBe(401); - }); - - it(`should return with boom forbidden when security is enabled but user doesn't have allowed role`, async function () { - const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); - const mockPlugins = createMockPlugins({ - user: { roles: [] }, - getCurrentUser: () => ({ roles: ['something_else'] }), - }); - - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, - mockPlugins, - getMockLogger() - ); - const response = await authorizedUserPreRouting(getMockRequest()); - expect(response.isBoom).toBe(true); - expect(response.output.statusCode).toBe(403); - }); - - it('should return with user when security is enabled and user has explicitly allowed role', async function () { - const user = { roles: ['.reporting_user', 'something_else'] }; - const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); - const mockPlugins = createMockPlugins({ - user, - getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }), - }); - - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, - mockPlugins, - getMockLogger() - ); - const response = await authorizedUserPreRouting(getMockRequest()); - expect(response).toEqual(user); - }); - - it('should return with user when security is enabled and user has superuser role', async function () { - const user = { roles: ['superuser', 'something_else'] }; - const mockConfig = createMockConfig({ 'roles.allow': [] }); - const mockPlugins = createMockPlugins({ - getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }), - }); - - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockConfig, - mockPlugins, - getMockLogger() - ); - const response = await authorizedUserPreRouting(getMockRequest()); - expect(response).toEqual(user); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts new file mode 100644 index 0000000000000..4cb7af3d0d409 --- /dev/null +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { KibanaRequest, RequestHandlerContext, KibanaResponseFactory } from 'kibana/server'; +import sinon from 'sinon'; +import { coreMock, httpServerMock } from 'src/core/server/mocks'; +import { ReportingConfig, ReportingCore } from '../../'; +import { createMockReportingCore } from '../../../test_helpers'; +import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; +import { ReportingInternalSetup } from '../../core'; + +let mockConfig: ReportingConfig; +let mockCore: ReportingCore; + +const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, +}); + +const getMockContext = () => + (({ + core: coreMock.createRequestHandlerContext(), + } as unknown) as RequestHandlerContext); + +const getMockRequest = () => + ({ + url: { port: '5601', query: '', path: '/foo' }, + route: { path: '/foo', options: {} }, + } as KibanaRequest); + +const getMockResponseFactory = () => + (({ + ...httpServerMock.createResponseFactory(), + forbidden: (obj: unknown) => obj, + unauthorized: (obj: unknown) => obj, + } as unknown) as KibanaResponseFactory); + +beforeEach(async () => { + const mockConfigGet = sinon.stub().withArgs('roles', 'allow').returns(['reporting_user']); + mockConfig = getMockConfig(mockConfigGet); + mockCore = await createMockReportingCore(mockConfig); +}); + +describe('authorized_user_pre_routing', function () { + it('should return from handler with null user when security is disabled', async function () { + mockCore.getPluginSetupDeps = () => + (({ + // @ts-ignore + ...mockCore.pluginSetupDeps, + security: undefined, // disable security + } as unknown) as ReportingInternalSetup); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); + const mockResponseFactory = httpServerMock.createResponseFactory() as KibanaResponseFactory; + + let handlerCalled = false; + authorizedUserPreRouting((user: unknown) => { + expect(user).toBe(null); // verify the user is a null value + handlerCalled = true; + return Promise.resolve({ status: 200, options: {} }); + })(getMockContext(), getMockRequest(), mockResponseFactory); + + expect(handlerCalled).toBe(true); + }); + + it('should return with 401 when security is enabled but no authenticated user', async function () { + mockCore.getPluginSetupDeps = () => + (({ + // @ts-ignore + ...mockCore.pluginSetupDeps, + security: { + authc: { getCurrentUser: () => null }, + }, + } as unknown) as ReportingInternalSetup); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); + const mockHandler = () => { + throw new Error('Handler callback should not be called'); + }; + const requestHandler = authorizedUserPreRouting(mockHandler); + const mockResponseFactory = getMockResponseFactory(); + + expect(requestHandler(getMockContext(), getMockRequest(), mockResponseFactory)).toMatchObject({ + body: `Sorry, you aren't authenticated`, + }); + }); + + it(`should return with 403 when security is enabled but user doesn't have allowed role`, async function () { + mockCore.getPluginSetupDeps = () => + (({ + // @ts-ignore + ...mockCore.pluginSetupDeps, + security: { + authc: { getCurrentUser: () => ({ username: 'friendlyuser', roles: ['cowboy'] }) }, + }, + } as unknown) as ReportingInternalSetup); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); + const mockResponseFactory = getMockResponseFactory(); + + const mockHandler = () => { + throw new Error('Handler callback should not be called'); + }; + expect( + authorizedUserPreRouting(mockHandler)(getMockContext(), getMockRequest(), mockResponseFactory) + ).toMatchObject({ body: `Sorry, you don't have access to Reporting` }); + }); + + it('should return from handler when security is enabled and user has explicitly allowed role', async function () { + mockCore.getPluginSetupDeps = () => + (({ + // @ts-ignore + ...mockCore.pluginSetupDeps, + security: { + authc: { + getCurrentUser: () => ({ username: 'friendlyuser', roles: ['reporting_user'] }), + }, + }, + } as unknown) as ReportingInternalSetup); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(mockCore); + const mockResponseFactory = getMockResponseFactory(); + + let handlerCalled = false; + authorizedUserPreRouting((user: unknown) => { + expect(user).toMatchObject({ roles: ['reporting_user'], username: 'friendlyuser' }); + handlerCalled = true; + return Promise.resolve({ status: 200, options: {} }); + })(getMockContext(), getMockRequest(), mockResponseFactory); + + expect(handlerCalled).toBe(true); + }); + + it('should return from handler when security is enabled and user has superuser role', async function () {}); +}); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index 0c4e75a53831e..87582ca3ca239 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -4,52 +4,48 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { Legacy } from 'kibana'; +import { RequestHandler, RouteMethod } from 'src/core/server'; import { AuthenticatedUser } from '../../../../../../plugins/security/server'; -import { ReportingConfig } from '../../../server'; -import { LevelLogger as Logger } from '../../../server/lib'; -import { ReportingSetupDeps } from '../../../server/types'; import { getUserFactory } from '../../lib/get_user'; +import { ReportingCore } from '../../core'; +type ReportingUser = AuthenticatedUser | null; const superuserRole = 'superuser'; -export type PreRoutingFunction = ( - request: Legacy.Request -) => Promise | AuthenticatedUser | null>; +export type RequestHandlerUser = RequestHandler extends (...a: infer U) => infer R + ? (user: ReportingUser, ...a: U) => R + : never; export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( - config: ReportingConfig, - plugins: ReportingSetupDeps, - logger: Logger + reporting: ReportingCore ) { - const getUser = getUserFactory(plugins.security, logger); - const { info: xpackInfo } = plugins.__LEGACY.plugins.xpack_main; - - return async function authorizedUserPreRouting(request: Legacy.Request) { - if (!xpackInfo || !xpackInfo.isAvailable()) { - logger.warn('Unable to authorize user before xpack info is available.', [ - 'authorizedUserPreRouting', - ]); - return Boom.notFound(); - } - - const security = xpackInfo.feature('security'); - if (!security.isEnabled() || !security.isAvailable()) { - return null; - } - - const user = await getUser(request); - - if (!user) { - return Boom.unauthorized(`Sorry, you aren't authenticated`); - } - - const authorizedRoles = [superuserRole, ...(config.get('roles', 'allow') as string[])]; - if (!user.roles.find((role) => authorizedRoles.includes(role))) { - return Boom.forbidden(`Sorry, you don't have access to Reporting`); - } - - return user; + const config = reporting.getConfig(); + const setupDeps = reporting.getPluginSetupDeps(); + const getUser = getUserFactory(setupDeps.security); + return (handler: RequestHandlerUser): RequestHandler => { + return (context, req, res) => { + let user: ReportingUser = null; + if (setupDeps.security) { + // find the authenticated user, or null if security is not enabled + user = getUser(req); + if (!user) { + // security is enabled but the user is null + return res.unauthorized({ body: `Sorry, you aren't authenticated` }); + } + } + + if (user) { + // check allowance with the configured set of roleas + "superuser" + const allowedRoles = config.get('roles', 'allow') || []; + const authorizedRoles = [superuserRole, ...allowedRoles]; + + if (!user.roles.find((role) => authorizedRoles.includes(role))) { + // user's roles do not allow + return res.forbidden({ body: `Sorry, you don't have access to Reporting` }); + } + } + + return handler(user, context, req, res); + }; }; }; diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts index 6a228c1915615..e16f5278c8cc7 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -12,15 +12,10 @@ import { statuses } from '../../lib/esqueue/constants/statuses'; import { ExportTypesRegistry } from '../../lib/export_types_registry'; import { ExportTypeDefinition, JobDocOutput, JobSource } from '../../types'; -interface ICustomHeaders { - [x: string]: any; -} - type ExportTypeType = ExportTypeDefinition; interface ErrorFromPayload { message: string; - reason: string | null; } // A camelCase version of JobDocOutput @@ -37,7 +32,7 @@ const getTitle = (exportType: ExportTypeType, title?: string): string => `${title || DEFAULT_TITLE}.${exportType.jobContentExtension}`; const getReportingHeaders = (output: JobDocOutput, exportType: ExportTypeType) => { - const metaDataHeaders: ICustomHeaders = {}; + const metaDataHeaders: Record = {}; if (exportType.jobType === CSV_JOB_TYPE) { const csvContainsFormulas = _.get(output, 'csv_contains_formulas', false); @@ -76,12 +71,13 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist }; } + // @TODO: These should be semantic HTTP codes as 500/503's indicate + // error then these are really operating properly. function getFailure(output: JobDocOutput): Payload { return { statusCode: 500, content: { - message: 'Reporting generation failed', - reason: output.content, + message: `Reporting generation failed: ${output.content}`, }, contentType: 'application/json', headers: {}, @@ -92,7 +88,7 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist return { statusCode: 503, content: status, - contentType: 'application/json', + contentType: 'text/plain', headers: { 'retry-after': 30 }, }; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index 174ec15c81d8a..990af2d0aca80 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { ResponseToolkit } from 'hapi'; -import { ElasticsearchServiceSetup } from 'kibana/server'; +import { ElasticsearchServiceSetup, kibanaResponseFactory } from 'kibana/server'; +import { AuthenticatedUser } from '../../../../../../plugins/security/server'; import { ReportingConfig } from '../../'; import { WHITELISTED_JOB_CONTENT_TYPES } from '../../../common/constants'; import { ExportTypesRegistry } from '../../lib/export_types_registry'; @@ -29,40 +28,43 @@ export function downloadJobResponseHandlerFactory( const jobsQuery = jobsQueryFactory(config, elasticsearch); const getDocumentPayload = getDocumentPayloadFactory(exportTypesRegistry); - return function jobResponseHandler( + return async function jobResponseHandler( + res: typeof kibanaResponseFactory, validJobTypes: string[], - user: any, - h: ResponseToolkit, + user: AuthenticatedUser | null, params: JobResponseHandlerParams, opts: JobResponseHandlerOpts = {} ) { const { docId } = params; - // TODO: async/await - return jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }).then((doc) => { - if (!doc) return Boom.notFound(); - const { jobtype: jobType } = doc._source; - if (!validJobTypes.includes(jobType)) { - return Boom.unauthorized(`Sorry, you are not authorized to download ${jobType} reports`); - } + const doc = await jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }); + if (!doc) { + return res.notFound(); + } - const output = getDocumentPayload(doc); + const { jobtype: jobType } = doc._source; - if (!WHITELISTED_JOB_CONTENT_TYPES.includes(output.contentType)) { - return Boom.badImplementation( - `Unsupported content-type of ${output.contentType} specified by job output` - ); - } + if (!validJobTypes.includes(jobType)) { + return res.unauthorized({ + body: `Sorry, you are not authorized to download ${jobType} reports`, + }); + } - const response = h.response(output.content).type(output.contentType).code(output.statusCode); + const response = getDocumentPayload(doc); - if (output.headers) { - Object.keys(output.headers).forEach((key) => { - response.header(key, output.headers[key]); - }); - } + if (!WHITELISTED_JOB_CONTENT_TYPES.includes(response.contentType)) { + return res.badRequest({ + body: `Unsupported content-type of ${response.contentType} specified by job output`, + }); + } - return response; // Hapi + return res.custom({ + body: typeof response.content === 'string' ? Buffer.from(response.content) : response.content, + statusCode: response.statusCode, + headers: { + ...response.headers, + 'content-type': response.contentType, + }, }); }; } @@ -74,26 +76,37 @@ export function deleteJobResponseHandlerFactory( const jobsQuery = jobsQueryFactory(config, elasticsearch); return async function deleteJobResponseHander( + res: typeof kibanaResponseFactory, validJobTypes: string[], - user: any, - h: ResponseToolkit, + user: AuthenticatedUser | null, params: JobResponseHandlerParams ) { const { docId } = params; const doc = await jobsQuery.get(user, docId, { includeContent: false }); - if (!doc) return Boom.notFound(); + + if (!doc) { + return res.notFound(); + } const { jobtype: jobType } = doc._source; + if (!validJobTypes.includes(jobType)) { - return Boom.unauthorized(`Sorry, you are not authorized to delete ${jobType} reports`); + return res.unauthorized({ + body: `Sorry, you are not authorized to delete ${jobType} reports`, + }); } try { const docIndex = doc._index; await jobsQuery.delete(docIndex, docId); - return h.response({ deleted: true }); + return res.ok({ + body: { deleted: true }, + }); } catch (error) { - return Boom.boomify(error, { statusCode: error.statusCode }); + return res.customError({ + statusCode: error.statusCode, + body: error.message, + }); } }; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/make_request_facade.test.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/make_request_facade.test.ts deleted file mode 100644 index 8cdb7b4c018d7..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/make_request_facade.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Legacy } from 'kibana'; -import { makeRequestFacade } from './make_request_facade'; - -describe('makeRequestFacade', () => { - test('creates a default object', () => { - const legacyRequest = ({ - getBasePath: () => 'basebase', - params: { - param1: 123, - }, - payload: { - payload1: 123, - }, - headers: { - user: 123, - }, - } as unknown) as Legacy.Request; - - expect(makeRequestFacade(legacyRequest)).toMatchInlineSnapshot(` - Object { - "getBasePath": [Function], - "getRawRequest": [Function], - "getSavedObjectsClient": undefined, - "headers": Object { - "user": 123, - }, - "params": Object { - "param1": 123, - }, - "payload": Object { - "payload1": 123, - }, - "pre": undefined, - "query": undefined, - "route": undefined, - } - `); - }); - - test('getRawRequest', () => { - const legacyRequest = ({ - getBasePath: () => 'basebase', - params: { - param1: 123, - }, - payload: { - payload1: 123, - }, - headers: { - user: 123, - }, - } as unknown) as Legacy.Request; - - expect(makeRequestFacade(legacyRequest).getRawRequest()).toBe(legacyRequest); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/make_request_facade.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/make_request_facade.ts deleted file mode 100644 index 5dd62711f2565..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/make_request_facade.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { RequestQuery } from 'hapi'; -import { Legacy } from 'kibana'; -import { - RequestFacade, - ReportingRequestPayload, - ReportingRequestPre, - ReportingRequestQuery, -} from '../../../server/types'; - -export function makeRequestFacade(request: Legacy.Request): RequestFacade { - // This condition is for unit tests - const getSavedObjectsClient = request.getSavedObjectsClient - ? request.getSavedObjectsClient.bind(request) - : request.getSavedObjectsClient; - return { - getSavedObjectsClient, - headers: request.headers, - params: request.params, - payload: (request.payload as object) as ReportingRequestPayload, - query: ((request.query as RequestQuery) as object) as ReportingRequestQuery, - pre: (request.pre as Record) as ReportingRequestPre, - getBasePath: request.getBasePath, - route: request.route, - getRawRequest: () => request, - }; -} diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts deleted file mode 100644 index f9c7571e25bac..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Boom from 'boom'; -import { Legacy } from 'kibana'; -import { ReportingConfig } from '../../'; -import { LevelLogger as Logger } from '../../lib'; -import { ReportingSetupDeps } from '../../types'; - -export type GetReportingFeatureIdFn = (request: Legacy.Request) => string; - -export const reportingFeaturePreRoutingFactory = function reportingFeaturePreRoutingFn( - config: ReportingConfig, - plugins: ReportingSetupDeps, - logger: Logger -) { - const xpackMainPlugin = plugins.__LEGACY.plugins.xpack_main; - const pluginId = 'reporting'; - - // License checking and enable/disable logic - return function reportingFeaturePreRouting(getReportingFeatureId: GetReportingFeatureIdFn) { - return function licensePreRouting(request: Legacy.Request) { - const licenseCheckResults = xpackMainPlugin.info.feature(pluginId).getLicenseCheckResults(); - const reportingFeatureId = getReportingFeatureId(request) as string; - const reportingFeature = licenseCheckResults[reportingFeatureId]; - if (!reportingFeature.showLinks || !reportingFeature.enableLinks) { - throw Boom.forbidden(reportingFeature.message); - } else { - return reportingFeature; - } - }; - }; -}; diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts deleted file mode 100644 index 0ee9db4678684..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import Joi from 'joi'; -import { ReportingConfig } from '../../'; -import { LevelLogger as Logger } from '../../lib'; -import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { ReportingSetupDeps } from '../../types'; -import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; -import { - GetReportingFeatureIdFn, - reportingFeaturePreRoutingFactory, -} from './reporting_feature_pre_routing'; - -const API_TAG = 'api'; - -export interface RouteConfigFactory { - tags?: string[]; - pre: any[]; - response?: { - ranges: boolean; - }; -} - -export type GetRouteConfigFactoryFn = ( - getFeatureId?: GetReportingFeatureIdFn -) => RouteConfigFactory; - -export function getRouteConfigFactoryReportingPre( - config: ReportingConfig, - plugins: ReportingSetupDeps, - logger: Logger -): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); - - return (getFeatureId?: GetReportingFeatureIdFn): RouteConfigFactory => { - const preRouting: any[] = [{ method: authorizedUserPreRouting, assign: 'user' }]; - if (getFeatureId) { - preRouting.push(reportingFeaturePreRouting(getFeatureId)); - } - - return { - tags: [API_TAG], - pre: preRouting, - }; - }; -} - -export function getRouteOptionsCsv( - config: ReportingConfig, - plugins: ReportingSetupDeps, - logger: Logger -) { - const getRouteConfig = getRouteConfigFactoryReportingPre(config, plugins, logger); - return { - ...getRouteConfig(() => CSV_FROM_SAVEDOBJECT_JOB_TYPE), - validate: { - params: Joi.object({ - savedObjectType: Joi.string().required(), - savedObjectId: Joi.string().required(), - }).required(), - payload: Joi.object({ - state: Joi.object().default({}), - timerange: Joi.object({ - timezone: Joi.string().default('UTC'), - min: Joi.date().required(), - max: Joi.date().required(), - }).optional(), - }), - }, - }; -} - -export function getRouteConfigFactoryManagementPre( - config: ReportingConfig, - plugins: ReportingSetupDeps, - logger: Logger -): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); - const managementPreRouting = reportingFeaturePreRouting(() => 'management'); - - return (): RouteConfigFactory => { - return { - pre: [ - { method: authorizedUserPreRouting, assign: 'user' }, - { method: managementPreRouting, assign: 'management' }, - ], - }; - }; -} - -// NOTE: We're disabling range request for downloading the PDF. There's a bug in Firefox's PDF.js viewer -// (https://github.com/mozilla/pdf.js/issues/8958) where they're using a range request to retrieve the -// TOC at the end of the PDF, but it's sending multiple cookies and causing our auth to fail with a 401. -// Additionally, the range-request doesn't alleviate any performance issues on the server as the entire -// download is loaded into memory. -export function getRouteConfigFactoryDownloadPre( - config: ReportingConfig, - plugins: ReportingSetupDeps, - logger: Logger -): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); - return (): RouteConfigFactory => ({ - ...getManagementRouteConfig(), - tags: [API_TAG, 'download'], - response: { - ranges: false, - }, - }); -} - -export function getRouteConfigFactoryDeletePre( - config: ReportingConfig, - plugins: ReportingSetupDeps, - logger: Logger -): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); - return (): RouteConfigFactory => ({ - ...getManagementRouteConfig(), - tags: [API_TAG, 'delete'], - response: { - ranges: false, - }, - }); -} diff --git a/x-pack/legacy/plugins/reporting/server/routes/types.d.ts b/x-pack/legacy/plugins/reporting/server/routes/types.d.ts index 2ebe1ada418dc..afa3fd3358fc1 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/types.d.ts @@ -4,17 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { KibanaResponseFactory, KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { AuthenticatedUser } from '../../../../../plugins/security/common/model/authenticated_user'; import { JobDocPayload } from '../types'; export type HandlerFunction = ( + user: AuthenticatedUser | null, exportType: string, jobParams: object, - request: Legacy.Request, - h: ReportingResponseToolkit + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory ) => any; -export type HandlerErrorFunction = (exportType: string, err: Error) => any; +export type HandlerErrorFunction = (res: KibanaResponseFactory, err: Error) => any; export interface QueuedJobPayload { error?: boolean; @@ -24,5 +27,3 @@ export interface QueuedJobPayload { }; }; } - -export type ReportingResponseToolkit = Legacy.ResponseToolkit; diff --git a/x-pack/legacy/plugins/reporting/server/types.ts b/x-pack/legacy/plugins/reporting/server/types.ts index bfab568fe9fb3..2ccc209c3ce50 100644 --- a/x-pack/legacy/plugins/reporting/server/types.ts +++ b/x-pack/legacy/plugins/reporting/server/types.ts @@ -5,6 +5,7 @@ */ import { Legacy } from 'kibana'; +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -53,8 +54,8 @@ export type ReportingRequestPayload = GenerateExportTypePayload | JobParamPostPa export interface TimeRangeParams { timezone: string; - min: Date | string | number; - max: Date | string | number; + min: Date | string | number | null; + max: Date | string | number | null; } export interface JobParamPostPayload { @@ -189,22 +190,10 @@ export interface LegacySetup { * Internal Types */ -export interface RequestFacade { - getBasePath: Legacy.Request['getBasePath']; - getSavedObjectsClient: Legacy.Request['getSavedObjectsClient']; - headers: Legacy.Request['headers']; - params: Legacy.Request['params']; - payload: JobParamPostPayload | GenerateExportTypePayload; - query: ReportingRequestQuery; - route: Legacy.Request['route']; - pre: ReportingRequestPre; - getRawRequest: () => Legacy.Request; -} - export type ESQueueCreateJobFn = ( jobParams: JobParamsType, - headers: Record, - request: RequestFacade + context: RequestHandlerContext, + request: KibanaRequest ) => Promise; export type ESQueueWorkerExecuteFn = ( diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts index 286e072f1f8f6..f6dbccdfe3980 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts @@ -12,17 +12,21 @@ jest.mock('../server/lib/create_queue'); jest.mock('../server/lib/enqueue_job'); jest.mock('../server/lib/validate'); +import { of } from 'rxjs'; import { EventEmitter } from 'events'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { coreMock } from 'src/core/server/mocks'; import { ReportingConfig, ReportingCore, ReportingPlugin } from '../server'; import { ReportingSetupDeps, ReportingStartDeps } from '../server/types'; +import { ReportingInternalSetup } from '../server/core'; const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => { return { elasticsearch: setupMock.elasticsearch, security: setupMock.security, - licensing: {} as any, + licensing: { + license$: of({ isAvailable: true, isActive: true, type: 'basic' }), + } as any, usageCollection: {} as any, __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, }; @@ -49,8 +53,18 @@ const createMockReportingPlugin = async (config: ReportingConfig): Promise => { +export const createMockReportingCore = async ( + config: ReportingConfig, + setupDepsMock?: ReportingInternalSetup +): Promise => { config = config || {}; const plugin = await createMockReportingPlugin(config); - return plugin.getReportingCore(); + const core = plugin.getReportingCore(); + + if (setupDepsMock) { + // @ts-ignore overwriting private properties + core.pluginSetupDeps = setupDepsMock; + } + + return core; }; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts index 819636b714631..01b9f6cbd9cd6 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts @@ -4,9 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ServerFacade } from '../server/types'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { createHttpServer, createCoreContext } from 'src/core/server/http/test_utils'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { coreMock } from 'src/core/server/mocks'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ContextService } from 'src/core/server/context/context_service'; -export const createMockServer = (): ServerFacade => { - const mockServer = {}; - return mockServer as any; +const coreId = Symbol('reporting'); + +export const createMockServer = async () => { + const coreContext = createCoreContext({ coreId }); + const contextService = new ContextService(coreContext); + + const server = createHttpServer(coreContext); + const httpSetup = await server.setup({ + context: contextService.setup({ pluginDependencies: new Map() }), + }); + const handlerContext = coreMock.createRequestHandlerContext(); + + httpSetup.registerRouteHandlerContext(coreId, 'core', async (ctx, req, res) => { + return handlerContext; + }); + + return { + server, + httpSetup, + handlerContext, + }; }; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index d068711b87c9d..e44bd92c42391 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -13,7 +13,8 @@ "uiActions", "embeddable", "share", - "kibanaLegacy" + "kibanaLegacy", + "licensing" ], "server": true, "ui": true diff --git a/x-pack/test/reporting_api_integration/reporting/csv_job_params.ts b/x-pack/test/reporting_api_integration/reporting/csv_job_params.ts index 7d11403add136..90f97d44da224 100644 --- a/x-pack/test/reporting_api_integration/reporting/csv_job_params.ts +++ b/x-pack/test/reporting_api_integration/reporting/csv_job_params.ts @@ -46,7 +46,7 @@ export default function ({ getService }: FtrProviderContext) { jobParams: 0, })) as supertest.Response; - expect(resText).to.match(/\\\"jobParams\\\" must be a string/); + expect(resText).to.match(/expected value of type \[string\] but got \[number\]/); expect(resStatus).to.eql(400); });