mirror of
https://github.com/elastic/kibana.git
synced 2025-06-28 03:01:21 -04:00
[Response Ops][Reporting] Scheduled Reports (#221028)
Resolves https://github.com/elastic/kibana/issues/216313
## Summary
This is a feature branch that contains the following commits. Each
individual linked PR contains a summary and verification instructions.
* Schedule API - https://github.com/elastic/kibana/pull/219771
* Scheduled report task runner -
https://github.com/elastic/kibana/pull/219770
* List and disable API - https://github.com/elastic/kibana/pull/220922
* Audit logging - https://github.com/elastic/kibana/pull/221846
* Send scheduled report emails -
https://github.com/elastic/kibana/pull/220539
* Commit to check license -
f5f9d9daed
* Update to list API response format -
https://github.com/elastic/kibana/pull/224262
---------
Co-authored-by: Ersin Erdal <ersin.erdal@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Alexi Doak <109488926+doakalexi@users.noreply.github.com>
This commit is contained in:
parent
900b1859ae
commit
a409627765
93 changed files with 8397 additions and 679 deletions
|
@ -958,6 +958,9 @@
|
||||||
"installCount",
|
"installCount",
|
||||||
"unInstallCount"
|
"unInstallCount"
|
||||||
],
|
],
|
||||||
|
"scheduled_report": [
|
||||||
|
"createdBy"
|
||||||
|
],
|
||||||
"search": [
|
"search": [
|
||||||
"description",
|
"description",
|
||||||
"title"
|
"title"
|
||||||
|
|
|
@ -3119,6 +3119,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scheduled_report": {
|
||||||
|
"dynamic": false,
|
||||||
|
"properties": {
|
||||||
|
"createdBy": {
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"dynamic": false,
|
"dynamic": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
@ -126,6 +126,7 @@ const previouslyRegisteredTypes = [
|
||||||
'query',
|
'query',
|
||||||
'rules-settings',
|
'rules-settings',
|
||||||
'sample-data-telemetry',
|
'sample-data-telemetry',
|
||||||
|
'scheduled_report',
|
||||||
'search',
|
'search',
|
||||||
'search-session',
|
'search-session',
|
||||||
'search-telemetry',
|
'search-telemetry',
|
||||||
|
|
|
@ -72,6 +72,13 @@ export class AuthenticationExpiredError extends ReportingError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MissingAuthenticationError extends ReportingError {
|
||||||
|
static code = 'missing_authentication_header_error' as const;
|
||||||
|
public get code(): string {
|
||||||
|
return MissingAuthenticationError.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class QueueTimeoutError extends ReportingError {
|
export class QueueTimeoutError extends ReportingError {
|
||||||
static code = 'queue_timeout_error' as const;
|
static code = 'queue_timeout_error' as const;
|
||||||
public get code(): string {
|
public get code(): string {
|
||||||
|
|
|
@ -24,8 +24,13 @@ export const INTERNAL_ROUTES = {
|
||||||
DELETE_PREFIX: prefixInternalPath + '/jobs/delete', // docId is added to the final path
|
DELETE_PREFIX: prefixInternalPath + '/jobs/delete', // docId is added to the final path
|
||||||
DOWNLOAD_PREFIX: prefixInternalPath + '/jobs/download', // docId is added to the final path
|
DOWNLOAD_PREFIX: prefixInternalPath + '/jobs/download', // docId is added to the final path
|
||||||
},
|
},
|
||||||
|
SCHEDULED: {
|
||||||
|
LIST: prefixInternalPath + '/scheduled/list',
|
||||||
|
BULK_DISABLE: prefixInternalPath + '/scheduled/bulk_disable',
|
||||||
|
},
|
||||||
HEALTH: prefixInternalPath + '/_health',
|
HEALTH: prefixInternalPath + '/_health',
|
||||||
GENERATE_PREFIX: prefixInternalPath + '/generate', // exportTypeId is added to the final path
|
GENERATE_PREFIX: prefixInternalPath + '/generate', // exportTypeId is added to the final path
|
||||||
|
SCHEDULE_PREFIX: prefixInternalPath + '/schedule', // exportTypeId is added to the final path
|
||||||
};
|
};
|
||||||
|
|
||||||
const prefixPublicPath = '/api/reporting';
|
const prefixPublicPath = '/api/reporting';
|
||||||
|
|
|
@ -66,6 +66,7 @@ export interface BaseParams {
|
||||||
objectType: string;
|
objectType: string;
|
||||||
title: string;
|
title: string;
|
||||||
version: string; // to handle any state migrations
|
version: string; // to handle any state migrations
|
||||||
|
forceNow?: string;
|
||||||
layout?: LayoutParams; // png & pdf only
|
layout?: LayoutParams; // png & pdf only
|
||||||
pagingStrategy?: CsvPagingStrategy; // csv only
|
pagingStrategy?: CsvPagingStrategy; // csv only
|
||||||
}
|
}
|
||||||
|
@ -152,6 +153,7 @@ export interface ReportSource {
|
||||||
created_at: string; // timestamp in UTC
|
created_at: string; // timestamp in UTC
|
||||||
'@timestamp'?: string; // creation timestamp, only used for data streams compatibility
|
'@timestamp'?: string; // creation timestamp, only used for data streams compatibility
|
||||||
status: JOB_STATUS;
|
status: JOB_STATUS;
|
||||||
|
scheduled_report_id?: string;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* `output` is only populated if the report job is completed or failed.
|
* `output` is only populated if the report job is completed or failed.
|
||||||
|
|
|
@ -49,6 +49,16 @@ describe('check_license', () => {
|
||||||
it('should set management.jobTypes to undefined', () => {
|
it('should set management.jobTypes to undefined', () => {
|
||||||
expect(checkLicense(exportTypesRegistry, undefined).management.jobTypes).toEqual(undefined);
|
expect(checkLicense(exportTypesRegistry, undefined).management.jobTypes).toEqual(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.showLinks to true', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, undefined).scheduledReports.showLinks).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.enableLinks to false', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, undefined).scheduledReports.enableLinks).toEqual(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('license information is not available', () => {
|
describe('license information is not available', () => {
|
||||||
|
@ -82,6 +92,16 @@ describe('check_license', () => {
|
||||||
it('should set management.jobTypes to undefined', () => {
|
it('should set management.jobTypes to undefined', () => {
|
||||||
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
|
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.showLinks to true', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.enableLinks to false', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('license information is available', () => {
|
describe('license information is available', () => {
|
||||||
|
@ -121,6 +141,18 @@ describe('check_license', () => {
|
||||||
'printable_pdf'
|
'printable_pdf'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.showLinks to true', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.enableLinks to true', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('& license is expired', () => {
|
describe('& license is expired', () => {
|
||||||
|
@ -147,6 +179,18 @@ describe('check_license', () => {
|
||||||
it('should set management.jobTypes to undefined', () => {
|
it('should set management.jobTypes to undefined', () => {
|
||||||
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
|
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.showLinks to true', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.enableLinks to false', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.enableLinks).toEqual(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -175,6 +219,12 @@ describe('check_license', () => {
|
||||||
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual([]);
|
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual([]);
|
||||||
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toHaveLength(0);
|
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.showLinks to false', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('& license is expired', () => {
|
describe('& license is expired', () => {
|
||||||
|
@ -193,6 +243,12 @@ describe('check_license', () => {
|
||||||
it('should set management.jobTypes to undefined', () => {
|
it('should set management.jobTypes to undefined', () => {
|
||||||
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
|
expect(checkLicense(exportTypesRegistry, license).management.jobTypes).toEqual(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set scheduledReports.showLinks to true', () => {
|
||||||
|
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,14 @@
|
||||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ILicense } from '@kbn/licensing-plugin/server';
|
import { ILicense, LicenseType } from '@kbn/licensing-plugin/server';
|
||||||
|
import {
|
||||||
|
LICENSE_TYPE_CLOUD_STANDARD,
|
||||||
|
LICENSE_TYPE_ENTERPRISE,
|
||||||
|
LICENSE_TYPE_GOLD,
|
||||||
|
LICENSE_TYPE_PLATINUM,
|
||||||
|
LICENSE_TYPE_TRIAL,
|
||||||
|
} from '@kbn/reporting-common';
|
||||||
import type { ExportType } from '.';
|
import type { ExportType } from '.';
|
||||||
import { ExportTypesRegistry } from './export_types_registry';
|
import { ExportTypesRegistry } from './export_types_registry';
|
||||||
|
|
||||||
|
@ -18,6 +25,14 @@ export interface LicenseCheckResult {
|
||||||
jobTypes?: string[];
|
jobTypes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scheduledReportValidLicenses: LicenseType[] = [
|
||||||
|
LICENSE_TYPE_TRIAL,
|
||||||
|
LICENSE_TYPE_CLOUD_STANDARD,
|
||||||
|
LICENSE_TYPE_GOLD,
|
||||||
|
LICENSE_TYPE_PLATINUM,
|
||||||
|
LICENSE_TYPE_ENTERPRISE,
|
||||||
|
];
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
getUnavailable: () => {
|
getUnavailable: () => {
|
||||||
return 'You cannot use Reporting because license information is not available at this time.';
|
return 'You cannot use Reporting because license information is not available at this time.';
|
||||||
|
@ -60,6 +75,42 @@ const makeManagementFeature = (exportTypes: ExportType[]) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const makeScheduledReportsFeature = () => {
|
||||||
|
return {
|
||||||
|
id: 'scheduledReports',
|
||||||
|
checkLicense: (license?: ILicense) => {
|
||||||
|
if (!license || !license.type) {
|
||||||
|
return {
|
||||||
|
showLinks: true,
|
||||||
|
enableLinks: false,
|
||||||
|
message: messages.getUnavailable(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!license.isActive) {
|
||||||
|
return {
|
||||||
|
showLinks: true,
|
||||||
|
enableLinks: false,
|
||||||
|
message: messages.getExpired(license),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scheduledReportValidLicenses.includes(license.type)) {
|
||||||
|
return {
|
||||||
|
showLinks: false,
|
||||||
|
enableLinks: false,
|
||||||
|
message: `Your ${license.type} license does not support Scheduled reports. Please upgrade your license.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
showLinks: true,
|
||||||
|
enableLinks: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const makeExportTypeFeature = (exportType: ExportType) => {
|
const makeExportTypeFeature = (exportType: ExportType) => {
|
||||||
return {
|
return {
|
||||||
id: exportType.id,
|
id: exportType.id,
|
||||||
|
@ -104,6 +155,7 @@ export function checkLicense(
|
||||||
const reportingFeatures = [
|
const reportingFeatures = [
|
||||||
...exportTypes.map(makeExportTypeFeature),
|
...exportTypes.map(makeExportTypeFeature),
|
||||||
makeManagementFeature(exportTypes),
|
makeManagementFeature(exportTypes),
|
||||||
|
makeScheduledReportsFeature(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return reportingFeatures.reduce((result, feature) => {
|
return reportingFeatures.reduce((result, feature) => {
|
||||||
|
|
|
@ -107,7 +107,9 @@ it('Provides a feature declaration ', () => {
|
||||||
"minimumLicense": "gold",
|
"minimumLicense": "gold",
|
||||||
"name": "Generate PDF reports",
|
"name": "Generate PDF reports",
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -216,7 +218,9 @@ it(`Calls on Reporting whether to include Generate PDF as a sub-feature`, () =>
|
||||||
"minimumLicense": "gold",
|
"minimumLicense": "gold",
|
||||||
"name": "Generate PDF reports",
|
"name": "Generate PDF reports",
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
|
|
@ -68,7 +68,7 @@ export function getCanvasFeature(plugins: { reporting?: ReportingStart }): Kiban
|
||||||
includeIn: 'all',
|
includeIn: 'all',
|
||||||
management: { insightsAndAlerting: ['reporting'] },
|
management: { insightsAndAlerting: ['reporting'] },
|
||||||
minimumLicense: 'gold',
|
minimumLicense: 'gold',
|
||||||
savedObject: { all: [], read: [] },
|
savedObject: { all: ['scheduled_report'], read: [] },
|
||||||
api: ['generateReport'],
|
api: ['generateReport'],
|
||||||
ui: ['generatePdf'],
|
ui: ['generatePdf'],
|
||||||
},
|
},
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"reporting"
|
"reporting"
|
||||||
],
|
],
|
||||||
"requiredPlugins": [
|
"requiredPlugins": [
|
||||||
|
"actions",
|
||||||
"data",
|
"data",
|
||||||
"discover",
|
"discover",
|
||||||
"encryptedSavedObjects",
|
"encryptedSavedObjects",
|
||||||
|
|
|
@ -23,6 +23,8 @@ import type {
|
||||||
StatusServiceSetup,
|
StatusServiceSetup,
|
||||||
UiSettingsServiceStart,
|
UiSettingsServiceStart,
|
||||||
} from '@kbn/core/server';
|
} from '@kbn/core/server';
|
||||||
|
import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server';
|
||||||
|
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
|
||||||
import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server';
|
import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server';
|
||||||
import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server';
|
import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server';
|
||||||
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
||||||
|
@ -35,7 +37,7 @@ import { PngExportType } from '@kbn/reporting-export-types-png';
|
||||||
import type { ReportingConfigType } from '@kbn/reporting-server';
|
import type { ReportingConfigType } from '@kbn/reporting-server';
|
||||||
import { ExportType } from '@kbn/reporting-server';
|
import { ExportType } from '@kbn/reporting-server';
|
||||||
import { ScreenshottingStart } from '@kbn/screenshotting-plugin/server';
|
import { ScreenshottingStart } from '@kbn/screenshotting-plugin/server';
|
||||||
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
|
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
|
||||||
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
||||||
import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
|
import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
|
||||||
import type {
|
import type {
|
||||||
|
@ -52,11 +54,20 @@ import type { ReportingSetup } from '.';
|
||||||
import { createConfig } from './config';
|
import { createConfig } from './config';
|
||||||
import { reportingEventLoggerFactory } from './lib/event_logger/logger';
|
import { reportingEventLoggerFactory } from './lib/event_logger/logger';
|
||||||
import type { IReport, ReportingStore } from './lib/store';
|
import type { IReport, ReportingStore } from './lib/store';
|
||||||
import { ExecuteReportTask, ReportTaskParams } from './lib/tasks';
|
import {
|
||||||
|
RunSingleReportTask,
|
||||||
|
ReportTaskParams,
|
||||||
|
RunScheduledReportTask,
|
||||||
|
ScheduledReportTaskParamsWithoutSpaceId,
|
||||||
|
} from './lib/tasks';
|
||||||
import type { ReportingPluginRouter } from './types';
|
import type { ReportingPluginRouter } from './types';
|
||||||
import { EventTracker } from './usage';
|
import { EventTracker } from './usage';
|
||||||
|
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects';
|
||||||
|
import { EmailNotificationService } from './services/notifications/email_notification_service';
|
||||||
|
import { API_PRIVILEGES } from './features';
|
||||||
|
|
||||||
export interface ReportingInternalSetup {
|
export interface ReportingInternalSetup {
|
||||||
|
actions: ActionsPluginSetupContract;
|
||||||
basePath: Pick<IBasePath, 'set'>;
|
basePath: Pick<IBasePath, 'set'>;
|
||||||
docLinks: DocLinksServiceSetup;
|
docLinks: DocLinksServiceSetup;
|
||||||
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
|
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
|
||||||
|
@ -83,6 +94,7 @@ export interface ReportingInternalStart {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
notifications: NotificationsPluginStart;
|
notifications: NotificationsPluginStart;
|
||||||
screenshotting?: ScreenshottingStart;
|
screenshotting?: ScreenshottingStart;
|
||||||
|
security?: SecurityPluginStart;
|
||||||
securityService: SecurityServiceStart;
|
securityService: SecurityServiceStart;
|
||||||
taskManager: TaskManagerStartContract;
|
taskManager: TaskManagerStartContract;
|
||||||
}
|
}
|
||||||
|
@ -96,7 +108,8 @@ export class ReportingCore {
|
||||||
private pluginStartDeps?: ReportingInternalStart;
|
private pluginStartDeps?: ReportingInternalStart;
|
||||||
private readonly pluginSetup$ = new Rx.ReplaySubject<boolean>(); // observe async background setupDeps each are done
|
private readonly pluginSetup$ = new Rx.ReplaySubject<boolean>(); // observe async background setupDeps each are done
|
||||||
private readonly pluginStart$ = new Rx.ReplaySubject<ReportingInternalStart>(); // observe async background startDeps
|
private readonly pluginStart$ = new Rx.ReplaySubject<ReportingInternalStart>(); // observe async background startDeps
|
||||||
private executeTask: ExecuteReportTask;
|
private runSingleReportTask: RunSingleReportTask;
|
||||||
|
private runScheduledReportTask: RunScheduledReportTask;
|
||||||
private config: ReportingConfigType;
|
private config: ReportingConfigType;
|
||||||
private executing: Set<string>;
|
private executing: Set<string>;
|
||||||
private exportTypesRegistry = new ExportTypesRegistry();
|
private exportTypesRegistry = new ExportTypesRegistry();
|
||||||
|
@ -117,7 +130,16 @@ export class ReportingCore {
|
||||||
this.getExportTypes().forEach((et) => {
|
this.getExportTypes().forEach((et) => {
|
||||||
this.exportTypesRegistry.register(et);
|
this.exportTypesRegistry.register(et);
|
||||||
});
|
});
|
||||||
this.executeTask = new ExecuteReportTask(this, config, this.logger);
|
this.runSingleReportTask = new RunSingleReportTask({
|
||||||
|
reporting: this,
|
||||||
|
config,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
this.runScheduledReportTask = new RunScheduledReportTask({
|
||||||
|
reporting: this,
|
||||||
|
config,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
|
||||||
this.getContract = () => ({
|
this.getContract = () => ({
|
||||||
registerExportTypes: (id) => id,
|
registerExportTypes: (id) => id,
|
||||||
|
@ -142,9 +164,10 @@ export class ReportingCore {
|
||||||
et.setup(setupDeps);
|
et.setup(setupDeps);
|
||||||
});
|
});
|
||||||
|
|
||||||
const { executeTask } = this;
|
const { runSingleReportTask, runScheduledReportTask } = this;
|
||||||
setupDeps.taskManager.registerTaskDefinitions({
|
setupDeps.taskManager.registerTaskDefinitions({
|
||||||
[executeTask.TYPE]: executeTask.getTaskDefinition(),
|
[runSingleReportTask.TYPE]: runSingleReportTask.getTaskDefinition(),
|
||||||
|
[runScheduledReportTask.TYPE]: runScheduledReportTask.getTaskDefinition(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,10 +182,17 @@ export class ReportingCore {
|
||||||
et.start({ ...startDeps });
|
et.start({ ...startDeps });
|
||||||
});
|
});
|
||||||
|
|
||||||
const { taskManager } = startDeps;
|
const { taskManager, notifications } = startDeps;
|
||||||
const { executeTask } = this;
|
const emailNotificationService = new EmailNotificationService({
|
||||||
|
notifications,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { runSingleReportTask, runScheduledReportTask } = this;
|
||||||
// enable this instance to generate reports
|
// enable this instance to generate reports
|
||||||
await Promise.all([executeTask.init(taskManager)]);
|
await Promise.all([
|
||||||
|
runSingleReportTask.init(taskManager),
|
||||||
|
runScheduledReportTask.init(taskManager, emailNotificationService),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public pluginStop() {
|
public pluginStop() {
|
||||||
|
@ -278,6 +308,18 @@ export class ReportingCore {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async canManageReportingForSpace(req: KibanaRequest): Promise<boolean> {
|
||||||
|
const { security } = await this.getPluginStartDeps();
|
||||||
|
const spaceId = this.getSpaceId(req);
|
||||||
|
const result = await security?.authz
|
||||||
|
.checkPrivilegesWithRequest(req)
|
||||||
|
.atSpace(spaceId ?? DEFAULT_SPACE_ID, {
|
||||||
|
kibana: [security?.authz.actions.api.get(API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING)],
|
||||||
|
});
|
||||||
|
|
||||||
|
return result?.hasAllRequested ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Gives synchronous access to the config
|
* Gives synchronous access to the config
|
||||||
*/
|
*/
|
||||||
|
@ -322,13 +364,25 @@ export class ReportingCore {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async scheduleTask(request: KibanaRequest, report: ReportTaskParams) {
|
public async scheduleTask(request: KibanaRequest, report: ReportTaskParams) {
|
||||||
return await this.executeTask.scheduleTask(request, report);
|
return await this.runSingleReportTask.scheduleTask(request, report);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async scheduleRecurringTask(
|
||||||
|
request: KibanaRequest,
|
||||||
|
report: ScheduledReportTaskParamsWithoutSpaceId
|
||||||
|
) {
|
||||||
|
return await this.runScheduledReportTask.scheduleTask(request, report);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getStore() {
|
public async getStore() {
|
||||||
return (await this.getPluginStartDeps()).store;
|
return (await this.getPluginStartDeps()).store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getAuditLogger(request: KibanaRequest) {
|
||||||
|
const startDeps = await this.getPluginStartDeps();
|
||||||
|
return startDeps.securityService.audit.asScoped(request);
|
||||||
|
}
|
||||||
|
|
||||||
public async getLicenseInfo() {
|
public async getLicenseInfo() {
|
||||||
const { license$ } = (await this.getPluginStartDeps()).licensing;
|
const { license$ } = (await this.getPluginStartDeps()).licensing;
|
||||||
const registry = this.getExportTypesRegistry();
|
const registry = this.getExportTypesRegistry();
|
||||||
|
@ -354,6 +408,13 @@ export class ReportingCore {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public validateNotificationEmails(emails: string[]): string | undefined {
|
||||||
|
const pluginSetupDeps = this.getPluginSetupDeps();
|
||||||
|
return pluginSetupDeps.actions
|
||||||
|
.getActionsConfigurationUtilities()
|
||||||
|
.validateEmailAddresses(emails);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Gives synchronous access to the setupDeps
|
* Gives synchronous access to the setupDeps
|
||||||
*/
|
*/
|
||||||
|
@ -374,6 +435,24 @@ export class ReportingCore {
|
||||||
return dataViews;
|
return dataViews;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScopedSoClient(request: KibanaRequest) {
|
||||||
|
const { savedObjects } = await this.getPluginStartDeps();
|
||||||
|
return savedObjects.getScopedClient(request, {
|
||||||
|
excludedExtensions: [SECURITY_EXTENSION_ID],
|
||||||
|
includedHiddenTypes: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getInternalSoClient() {
|
||||||
|
const { savedObjects } = await this.getPluginStartDeps();
|
||||||
|
return savedObjects.createInternalRepository([SCHEDULED_REPORT_SAVED_OBJECT_TYPE]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTaskManager() {
|
||||||
|
const { taskManager } = await this.getPluginStartDeps();
|
||||||
|
return taskManager;
|
||||||
|
}
|
||||||
|
|
||||||
public async getDataService() {
|
public async getDataService() {
|
||||||
const startDeps = await this.getPluginStartDeps();
|
const startDeps = await this.getPluginStartDeps();
|
||||||
return startDeps.data;
|
return startDeps.data;
|
||||||
|
|
|
@ -9,6 +9,11 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
|
||||||
import { i18n } from '@kbn/i18n';
|
import { i18n } from '@kbn/i18n';
|
||||||
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
||||||
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
|
import { KibanaFeatureScope } from '@kbn/features-plugin/common';
|
||||||
|
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects';
|
||||||
|
|
||||||
|
export const API_PRIVILEGES = {
|
||||||
|
MANAGE_SCHEDULED_REPORTING: 'manage_scheduled_reports',
|
||||||
|
};
|
||||||
|
|
||||||
interface FeatureRegistrationOpts {
|
interface FeatureRegistrationOpts {
|
||||||
features: FeaturesPluginSetup;
|
features: FeaturesPluginSetup;
|
||||||
|
@ -37,4 +42,29 @@ export function registerFeatures({ isServerless, features }: FeatureRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
features.enableReportingUiCapabilities();
|
features.enableReportingUiCapabilities();
|
||||||
|
|
||||||
|
features.registerKibanaFeature({
|
||||||
|
id: 'manageReporting',
|
||||||
|
name: i18n.translate('xpack.reporting.features.manageScheduledReportsFeatureName', {
|
||||||
|
defaultMessage: 'Manage Scheduled Reports',
|
||||||
|
}),
|
||||||
|
description: i18n.translate(
|
||||||
|
'xpack.reporting.features.manageScheduledReportsFeatureDescription',
|
||||||
|
{
|
||||||
|
defaultMessage: 'View and manage scheduled reports for all users in this space.',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
category: DEFAULT_APP_CATEGORIES.management,
|
||||||
|
scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security],
|
||||||
|
app: [],
|
||||||
|
privileges: {
|
||||||
|
all: {
|
||||||
|
api: [API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING],
|
||||||
|
savedObject: { all: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE], read: [] },
|
||||||
|
ui: ['show'],
|
||||||
|
},
|
||||||
|
// No read-only mode currently supported
|
||||||
|
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
export { Report } from './report';
|
export { Report } from './report';
|
||||||
export { SavedReport } from './saved_report';
|
export { SavedReport } from './saved_report';
|
||||||
|
export { ScheduledReport } from './scheduled_report';
|
||||||
export { ReportingStore } from './store';
|
export { ReportingStore } from './store';
|
||||||
export { IlmPolicyManager } from './ilm_policy_manager';
|
export { IlmPolicyManager } from './ilm_policy_manager';
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,61 @@ describe('Class Report', () => {
|
||||||
expect(report._id).toBeDefined();
|
expect(report._id).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('constructs Report instance when scheduled_task_id is defined', () => {
|
||||||
|
const report = new Report({
|
||||||
|
_index: '.reporting-test-index-12345',
|
||||||
|
jobtype: 'test-report',
|
||||||
|
created_by: 'created_by_test_string',
|
||||||
|
max_attempts: 50,
|
||||||
|
payload: {
|
||||||
|
headers: 'payload_test_field',
|
||||||
|
objectType: 'testOt',
|
||||||
|
title: 'cool report',
|
||||||
|
version: '7.14.0',
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
},
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
timeout: 30000,
|
||||||
|
scheduled_report_id: 'foobar',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.toReportSource()).toMatchObject({
|
||||||
|
attempts: 0,
|
||||||
|
completed_at: undefined,
|
||||||
|
created_by: 'created_by_test_string',
|
||||||
|
jobtype: 'test-report',
|
||||||
|
max_attempts: 50,
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
payload: { headers: 'payload_test_field', objectType: 'testOt' },
|
||||||
|
started_at: undefined,
|
||||||
|
status: 'pending',
|
||||||
|
timeout: 30000,
|
||||||
|
scheduled_report_id: 'foobar',
|
||||||
|
});
|
||||||
|
expect(report.toReportTaskJSON()).toMatchObject({
|
||||||
|
attempts: 0,
|
||||||
|
created_by: 'created_by_test_string',
|
||||||
|
index: '.reporting-test-index-12345',
|
||||||
|
jobtype: 'test-report',
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
payload: { headers: 'payload_test_field', objectType: 'testOt' },
|
||||||
|
});
|
||||||
|
expect(report.toApiJSON()).toMatchObject({
|
||||||
|
attempts: 0,
|
||||||
|
created_by: 'created_by_test_string',
|
||||||
|
index: '.reporting-test-index-12345',
|
||||||
|
jobtype: 'test-report',
|
||||||
|
max_attempts: 50,
|
||||||
|
payload: { objectType: 'testOt' },
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
status: 'pending',
|
||||||
|
timeout: 30000,
|
||||||
|
scheduled_report_id: 'foobar',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report._id).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('updateWithEsDoc method syncs fields to sync ES metadata', () => {
|
it('updateWithEsDoc method syncs fields to sync ES metadata', () => {
|
||||||
const report = new Report({
|
const report = new Report({
|
||||||
_index: '.reporting-test-index-12345',
|
_index: '.reporting-test-index-12345',
|
||||||
|
|
|
@ -41,6 +41,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
|
||||||
|
|
||||||
public readonly status: ReportSource['status'];
|
public readonly status: ReportSource['status'];
|
||||||
public readonly attempts: ReportSource['attempts'];
|
public readonly attempts: ReportSource['attempts'];
|
||||||
|
public readonly scheduled_report_id: ReportSource['scheduled_report_id'];
|
||||||
|
|
||||||
// fields with undefined values exist in report jobs that have not been claimed
|
// fields with undefined values exist in report jobs that have not been claimed
|
||||||
public readonly kibana_name: ReportSource['kibana_name'];
|
public readonly kibana_name: ReportSource['kibana_name'];
|
||||||
|
@ -99,6 +100,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
|
||||||
this.status = opts.status || JOB_STATUS.PENDING;
|
this.status = opts.status || JOB_STATUS.PENDING;
|
||||||
this.output = opts.output || null;
|
this.output = opts.output || null;
|
||||||
this.error = opts.error;
|
this.error = opts.error;
|
||||||
|
this.scheduled_report_id = opts.scheduled_report_id;
|
||||||
|
|
||||||
this.queue_time_ms = fields?.queue_time_ms;
|
this.queue_time_ms = fields?.queue_time_ms;
|
||||||
this.execution_time_ms = fields?.execution_time_ms;
|
this.execution_time_ms = fields?.execution_time_ms;
|
||||||
|
@ -142,6 +144,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
|
||||||
space_id: this.space_id,
|
space_id: this.space_id,
|
||||||
output: this.output || null,
|
output: this.output || null,
|
||||||
metrics: this.metrics,
|
metrics: this.metrics,
|
||||||
|
scheduled_report_id: this.scheduled_report_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,6 +194,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
|
||||||
payload: omit(this.payload, 'headers'),
|
payload: omit(this.payload, 'headers'),
|
||||||
output: omit(this.output, 'content'),
|
output: omit(this.output, 'content'),
|
||||||
metrics: this.metrics,
|
metrics: this.metrics,
|
||||||
|
scheduled_report_id: this.scheduled_report_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,178 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Frequency } from '@kbn/task-manager-plugin/server';
|
||||||
|
import { ScheduledReport } from '.';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
headers: '',
|
||||||
|
title: 'Test Report',
|
||||||
|
browserTimezone: '',
|
||||||
|
objectType: 'test',
|
||||||
|
version: '8.0.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('ScheduledReport should return correctly formatted outputs', () => {
|
||||||
|
const scheduledReport = new ScheduledReport({
|
||||||
|
runAt: new Date('2023-10-01T00:00:00Z'),
|
||||||
|
kibanaId: 'instance-uuid',
|
||||||
|
kibanaName: 'kibana',
|
||||||
|
queueTimeout: 120000,
|
||||||
|
scheduledReport: {
|
||||||
|
id: 'report-so-id-111',
|
||||||
|
attributes: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: 'test-user',
|
||||||
|
enabled: true,
|
||||||
|
jobType: 'test1',
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
migrationVersion: '8.0.0',
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
|
||||||
|
title: 'Test Report',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
type: 'scheduled-report',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(scheduledReport.toReportTaskJSON()).toEqual({
|
||||||
|
attempts: 1,
|
||||||
|
created_at: '2023-10-01T00:00:00.000Z',
|
||||||
|
created_by: 'test-user',
|
||||||
|
id: expect.any(String),
|
||||||
|
index: '.kibana-reporting',
|
||||||
|
jobtype: 'test1',
|
||||||
|
meta: {
|
||||||
|
objectType: 'test',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
browserTimezone: '',
|
||||||
|
forceNow: '2023-10-01T00:00:00.000Z',
|
||||||
|
headers: '',
|
||||||
|
objectType: 'test',
|
||||||
|
title: 'Test Report [2023-10-01T00:00:00.000Z]',
|
||||||
|
version: '8.0.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduledReport.toReportSource()).toEqual({
|
||||||
|
attempts: 1,
|
||||||
|
max_attempts: 1,
|
||||||
|
created_at: '2023-10-01T00:00:00.000Z',
|
||||||
|
created_by: 'test-user',
|
||||||
|
jobtype: 'test1',
|
||||||
|
meta: {
|
||||||
|
objectType: 'test',
|
||||||
|
},
|
||||||
|
migration_version: '7.14.0',
|
||||||
|
kibana_id: 'instance-uuid',
|
||||||
|
kibana_name: 'kibana',
|
||||||
|
output: null,
|
||||||
|
payload: {
|
||||||
|
browserTimezone: '',
|
||||||
|
forceNow: '2023-10-01T00:00:00.000Z',
|
||||||
|
headers: '',
|
||||||
|
objectType: 'test',
|
||||||
|
title: 'Test Report [2023-10-01T00:00:00.000Z]',
|
||||||
|
version: '8.0.0',
|
||||||
|
},
|
||||||
|
scheduled_report_id: 'report-so-id-111',
|
||||||
|
status: 'processing',
|
||||||
|
started_at: expect.any(String),
|
||||||
|
process_expiration: expect.any(String),
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scheduledReport.toApiJSON()).toEqual({
|
||||||
|
id: expect.any(String),
|
||||||
|
index: '.kibana-reporting',
|
||||||
|
kibana_id: 'instance-uuid',
|
||||||
|
kibana_name: 'kibana',
|
||||||
|
jobtype: 'test1',
|
||||||
|
created_at: '2023-10-01T00:00:00.000Z',
|
||||||
|
created_by: 'test-user',
|
||||||
|
meta: {
|
||||||
|
objectType: 'test',
|
||||||
|
},
|
||||||
|
timeout: 120000,
|
||||||
|
max_attempts: 1,
|
||||||
|
status: 'processing',
|
||||||
|
attempts: 1,
|
||||||
|
started_at: expect.any(String),
|
||||||
|
migration_version: '7.14.0',
|
||||||
|
output: {},
|
||||||
|
queue_time_ms: expect.any(Number),
|
||||||
|
payload: {
|
||||||
|
browserTimezone: '',
|
||||||
|
forceNow: '2023-10-01T00:00:00.000Z',
|
||||||
|
objectType: 'test',
|
||||||
|
title: 'Test Report [2023-10-01T00:00:00.000Z]',
|
||||||
|
version: '8.0.0',
|
||||||
|
},
|
||||||
|
scheduled_report_id: 'report-so-id-111',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ScheduledReport should throw an error if report payload is malformed', () => {
|
||||||
|
const createInstance = () => {
|
||||||
|
return new ScheduledReport({
|
||||||
|
runAt: new Date('2023-10-01T00:00:00Z'),
|
||||||
|
kibanaId: 'instance-uuid',
|
||||||
|
kibanaName: 'kibana',
|
||||||
|
queueTimeout: 120000,
|
||||||
|
scheduledReport: {
|
||||||
|
id: 'report-so-id-111',
|
||||||
|
attributes: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: 'test-user',
|
||||||
|
enabled: true,
|
||||||
|
jobType: 'test1',
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
migrationVersion: '8.0.0',
|
||||||
|
payload: 'abc',
|
||||||
|
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
|
||||||
|
title: 'Test Report',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
type: 'scheduled-report',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
expect(createInstance).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"Unable to parse payload from scheduled_report saved object: SyntaxError: Unexpected token 'a', \\"abc\\" is not valid JSON"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ScheduledReport should throw an error if scheduled_report saved object is missing ID', () => {
|
||||||
|
const createInstance = () => {
|
||||||
|
return new ScheduledReport({
|
||||||
|
runAt: new Date('2023-10-01T00:00:00Z'),
|
||||||
|
kibanaId: 'instance-uuid',
|
||||||
|
kibanaName: 'kibana',
|
||||||
|
queueTimeout: 120000,
|
||||||
|
// @ts-expect-error - missing id
|
||||||
|
scheduledReport: {
|
||||||
|
attributes: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: 'test-user',
|
||||||
|
enabled: true,
|
||||||
|
jobType: 'test1',
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
migrationVersion: '8.0.0',
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
|
||||||
|
title: 'Test Report',
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
type: 'scheduled-report',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
expect(createInstance).toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"Invalid scheduled_report saved object - no id"`
|
||||||
|
);
|
||||||
|
});
|
|
@ -0,0 +1,69 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import moment from 'moment';
|
||||||
|
import { JOB_STATUS } from '@kbn/reporting-common';
|
||||||
|
|
||||||
|
import { SavedObject } from '@kbn/core/server';
|
||||||
|
import { BasePayload } from '@kbn/reporting-common/types';
|
||||||
|
import { Report } from './report';
|
||||||
|
import { ScheduledReportType } from '../../types';
|
||||||
|
|
||||||
|
interface ConstructorOpts {
|
||||||
|
runAt: Date;
|
||||||
|
kibanaId: string;
|
||||||
|
kibanaName: string;
|
||||||
|
queueTimeout: number;
|
||||||
|
scheduledReport: SavedObject<ScheduledReportType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScheduledReport extends Report {
|
||||||
|
/*
|
||||||
|
* Create a report from a scheduled_report saved object
|
||||||
|
*/
|
||||||
|
constructor(opts: ConstructorOpts) {
|
||||||
|
const { kibanaId, kibanaName, runAt, scheduledReport, queueTimeout } = opts;
|
||||||
|
const now = moment.utc();
|
||||||
|
const startTime = now.toISOString();
|
||||||
|
const expirationTime = now.add(queueTimeout).toISOString();
|
||||||
|
|
||||||
|
let payload: BasePayload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(scheduledReport.attributes.payload);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Unable to parse payload from scheduled_report saved object: ${e}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.forceNow = runAt.toISOString();
|
||||||
|
payload.title = `${scheduledReport.attributes.title} [${runAt.toISOString()}]`;
|
||||||
|
|
||||||
|
if (!scheduledReport.id) {
|
||||||
|
throw new Error(`Invalid scheduled_report saved object - no id`);
|
||||||
|
}
|
||||||
|
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
migration_version: scheduledReport.attributes.migrationVersion,
|
||||||
|
jobtype: scheduledReport.attributes.jobType,
|
||||||
|
created_at: runAt.toISOString(),
|
||||||
|
created_by: scheduledReport.attributes.createdBy as string | false,
|
||||||
|
payload,
|
||||||
|
meta: scheduledReport.attributes.meta,
|
||||||
|
status: JOB_STATUS.PROCESSING,
|
||||||
|
attempts: 1,
|
||||||
|
process_expiration: expirationTime,
|
||||||
|
kibana_id: kibanaId,
|
||||||
|
kibana_name: kibanaName,
|
||||||
|
max_attempts: 1,
|
||||||
|
started_at: startTime,
|
||||||
|
timeout: queueTimeout,
|
||||||
|
scheduled_report_id: scheduledReport.id,
|
||||||
|
},
|
||||||
|
{ queue_time_ms: [now.diff(moment.utc(runAt), 'milliseconds')] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -60,6 +60,53 @@ describe('ReportingStore', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses report status if set', async () => {
|
||||||
|
const store = new ReportingStore(mockCore, mockLogger);
|
||||||
|
const mockReport = new Report({
|
||||||
|
_index: '.reporting-mock',
|
||||||
|
attempts: 0,
|
||||||
|
created_by: 'username1',
|
||||||
|
jobtype: 'unknowntype',
|
||||||
|
status: 'processing',
|
||||||
|
payload: {},
|
||||||
|
meta: {},
|
||||||
|
} as any);
|
||||||
|
await expect(store.addReport(mockReport)).resolves.toMatchObject({
|
||||||
|
_primary_term: undefined,
|
||||||
|
_seq_no: undefined,
|
||||||
|
attempts: 0,
|
||||||
|
completed_at: undefined,
|
||||||
|
created_by: 'username1',
|
||||||
|
jobtype: 'unknowntype',
|
||||||
|
payload: {},
|
||||||
|
meta: {},
|
||||||
|
status: 'processing',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to pending status if not set', async () => {
|
||||||
|
const store = new ReportingStore(mockCore, mockLogger);
|
||||||
|
const mockReport = new Report({
|
||||||
|
_index: '.reporting-mock',
|
||||||
|
attempts: 0,
|
||||||
|
created_by: 'username1',
|
||||||
|
jobtype: 'unknowntype',
|
||||||
|
payload: {},
|
||||||
|
meta: {},
|
||||||
|
} as any);
|
||||||
|
await expect(store.addReport(mockReport)).resolves.toMatchObject({
|
||||||
|
_primary_term: undefined,
|
||||||
|
_seq_no: undefined,
|
||||||
|
attempts: 0,
|
||||||
|
completed_at: undefined,
|
||||||
|
created_by: 'username1',
|
||||||
|
jobtype: 'unknowntype',
|
||||||
|
payload: {},
|
||||||
|
meta: {},
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('throws if options has invalid indexInterval', async () => {
|
it('throws if options has invalid indexInterval', async () => {
|
||||||
const reportingConfig = {
|
const reportingConfig = {
|
||||||
index: '.reporting-test',
|
index: '.reporting-test',
|
||||||
|
@ -181,6 +228,7 @@ describe('ReportingStore', () => {
|
||||||
},
|
},
|
||||||
"process_expiration": undefined,
|
"process_expiration": undefined,
|
||||||
"queue_time_ms": undefined,
|
"queue_time_ms": undefined,
|
||||||
|
"scheduled_report_id": undefined,
|
||||||
"space_id": undefined,
|
"space_id": undefined,
|
||||||
"started_at": undefined,
|
"started_at": undefined,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
|
@ -352,6 +400,48 @@ describe('ReportingStore', () => {
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('setReportWarning sets the status of a saved report to warning', async () => {
|
||||||
|
const store = new ReportingStore(mockCore, mockLogger);
|
||||||
|
const report = new SavedReport({
|
||||||
|
_id: 'id-of-processing',
|
||||||
|
_index: '.reporting-test-index-12345',
|
||||||
|
_seq_no: 42,
|
||||||
|
_primary_term: 10002,
|
||||||
|
jobtype: 'test-report',
|
||||||
|
created_by: 'created_by_test_string',
|
||||||
|
max_attempts: 50,
|
||||||
|
payload: {
|
||||||
|
title: 'test report',
|
||||||
|
headers: 'rp_test_headers',
|
||||||
|
objectType: 'testOt',
|
||||||
|
browserTimezone: 'ABC',
|
||||||
|
version: '7.14.0',
|
||||||
|
},
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.setReportWarning(report, {
|
||||||
|
output: { warnings: ['warning1'] },
|
||||||
|
warning: 'warning2',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const [[updateCall]] = mockEsClient.update.mock.calls;
|
||||||
|
|
||||||
|
const response = (updateCall as estypes.UpdateRequest)?.doc as Report;
|
||||||
|
expect(response.migration_version).toBe(`7.14.0`);
|
||||||
|
expect(response.status).toBe(`completed_with_warnings`);
|
||||||
|
expect(response.output).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"warnings": Array [
|
||||||
|
"warning1",
|
||||||
|
"warning2",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(updateCall.if_seq_no).toBe(42);
|
||||||
|
expect(updateCall.if_primary_term).toBe(10002);
|
||||||
|
});
|
||||||
|
|
||||||
describe('start', () => {
|
describe('start', () => {
|
||||||
class TestReportingStore extends ReportingStore {
|
class TestReportingStore extends ReportingStore {
|
||||||
constructor(...args: ConstructorParameters<typeof ReportingStore>) {
|
constructor(...args: ConstructorParameters<typeof ReportingStore>) {
|
||||||
|
|
|
@ -53,6 +53,11 @@ export type ReportCompletedFields = Required<{
|
||||||
output: Omit<ReportOutput, 'content'> | null;
|
output: Omit<ReportOutput, 'content'> | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export interface ReportWarningFields {
|
||||||
|
output: Omit<ReportOutput, 'content'>;
|
||||||
|
warning: string;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* When searching for long-pending reports, we get a subset of fields
|
* When searching for long-pending reports, we get a subset of fields
|
||||||
*/
|
*/
|
||||||
|
@ -145,8 +150,8 @@ export class ReportingStore {
|
||||||
...report.toReportSource(),
|
...report.toReportSource(),
|
||||||
...sourceDoc({
|
...sourceDoc({
|
||||||
process_expiration: new Date(0).toISOString(),
|
process_expiration: new Date(0).toISOString(),
|
||||||
attempts: 0,
|
attempts: report.attempts || 0,
|
||||||
status: JOB_STATUS.PENDING,
|
status: report.status || JOB_STATUS.PENDING,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -337,4 +342,31 @@ export class ReportingStore {
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setReportWarning(
|
||||||
|
report: SavedReport,
|
||||||
|
warningInfo: ReportWarningFields
|
||||||
|
): Promise<UpdateResponse<ReportDocument>> {
|
||||||
|
const { output, warning } = warningInfo;
|
||||||
|
const warnings: string[] = output.warnings ?? [];
|
||||||
|
warnings.push(warning);
|
||||||
|
const doc = sourceDoc({
|
||||||
|
output: {
|
||||||
|
...output,
|
||||||
|
warnings,
|
||||||
|
},
|
||||||
|
status: JOB_STATUS.WARNINGS,
|
||||||
|
} as ReportSource);
|
||||||
|
|
||||||
|
let body: UpdateResponse<ReportDocument>;
|
||||||
|
try {
|
||||||
|
const client = await this.getClient();
|
||||||
|
body = await client.update<unknown, unknown, ReportDocument>(esDocForUpdate(report, doc));
|
||||||
|
} catch (err) {
|
||||||
|
this.logError(`Error in updating status to warning! Report: ${jobDebugMessage(report)}`, err, report); // prettier-ignore
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,14 +5,16 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TaskRunCreatorFunction } from '@kbn/task-manager-plugin/server';
|
import { RruleSchedule, TaskRegisterDefinition } from '@kbn/task-manager-plugin/server';
|
||||||
import { BasePayload, ReportSource } from '@kbn/reporting-common/types';
|
import { BasePayload, ReportSource } from '@kbn/reporting-common/types';
|
||||||
|
|
||||||
export const REPORTING_EXECUTE_TYPE = 'report:execute';
|
export const REPORTING_EXECUTE_TYPE = 'report:execute';
|
||||||
|
export const SCHEDULED_REPORTING_EXECUTE_TYPE = 'report:execute-scheduled';
|
||||||
|
|
||||||
export const TIME_BETWEEN_ATTEMPTS = 10 * 1000; // 10 seconds
|
export const TIME_BETWEEN_ATTEMPTS = 10 * 1000; // 10 seconds
|
||||||
|
|
||||||
export { ExecuteReportTask } from './execute_report';
|
export { RunSingleReportTask } from './run_single_report';
|
||||||
|
export { RunScheduledReportTask } from './run_scheduled_report';
|
||||||
|
|
||||||
export interface ReportTaskParams<JobPayloadType = BasePayload> {
|
export interface ReportTaskParams<JobPayloadType = BasePayload> {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -25,18 +27,21 @@ export interface ReportTaskParams<JobPayloadType = BasePayload> {
|
||||||
meta: ReportSource['meta'];
|
meta: ReportSource['meta'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScheduledReportTaskParams {
|
||||||
|
id: string;
|
||||||
|
jobtype: ReportSource['jobtype'];
|
||||||
|
spaceId: string;
|
||||||
|
schedule: RruleSchedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduledReportTaskParamsWithoutSpaceId = Omit<ScheduledReportTaskParams, 'spaceId'>;
|
||||||
|
|
||||||
export enum ReportingTaskStatus {
|
export enum ReportingTaskStatus {
|
||||||
UNINITIALIZED = 'uninitialized',
|
UNINITIALIZED = 'uninitialized',
|
||||||
INITIALIZED = 'initialized',
|
INITIALIZED = 'initialized',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportingTask {
|
export interface ReportingTask {
|
||||||
getTaskDefinition: () => {
|
getTaskDefinition: () => TaskRegisterDefinition;
|
||||||
type: string;
|
|
||||||
title: string;
|
|
||||||
createTaskRunner: TaskRunCreatorFunction;
|
|
||||||
maxAttempts: number;
|
|
||||||
timeout: string;
|
|
||||||
};
|
|
||||||
getStatus: () => ReportingTaskStatus;
|
getStatus: () => ReportingTaskStatus;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,11 +11,11 @@ import { timeout } from 'rxjs';
|
||||||
import { Writable } from 'stream';
|
import { Writable } from 'stream';
|
||||||
import type { FakeRawRequest, Headers } from '@kbn/core-http-server';
|
import type { FakeRawRequest, Headers } from '@kbn/core-http-server';
|
||||||
import { UpdateResponse } from '@elastic/elasticsearch/lib/api/types';
|
import { UpdateResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||||
import type { KibanaRequest, Logger } from '@kbn/core/server';
|
import type { KibanaRequest, Logger, SavedObject } from '@kbn/core/server';
|
||||||
import {
|
import {
|
||||||
CancellationToken,
|
CancellationToken,
|
||||||
KibanaShuttingDownError,
|
KibanaShuttingDownError,
|
||||||
QueueTimeoutError,
|
MissingAuthenticationError,
|
||||||
ReportingError,
|
ReportingError,
|
||||||
durationToNumber,
|
durationToNumber,
|
||||||
numberToDuration,
|
numberToDuration,
|
||||||
|
@ -28,34 +28,28 @@ import type {
|
||||||
TaskRunResult,
|
TaskRunResult,
|
||||||
} from '@kbn/reporting-common/types';
|
} from '@kbn/reporting-common/types';
|
||||||
import { decryptJobHeaders, type ReportingConfigType } from '@kbn/reporting-server';
|
import { decryptJobHeaders, type ReportingConfigType } from '@kbn/reporting-server';
|
||||||
import type {
|
import {
|
||||||
RunContext,
|
throwRetryableError,
|
||||||
TaskManagerStartContract,
|
type ConcreteTaskInstance,
|
||||||
TaskRunCreatorFunction,
|
type RunContext,
|
||||||
|
type TaskManagerStartContract,
|
||||||
|
type TaskRegisterDefinition,
|
||||||
|
type TaskRunCreatorFunction,
|
||||||
} from '@kbn/task-manager-plugin/server';
|
} from '@kbn/task-manager-plugin/server';
|
||||||
import { throwRetryableError } from '@kbn/task-manager-plugin/server';
|
|
||||||
|
|
||||||
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
|
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
|
||||||
import { kibanaRequestFactory } from '@kbn/core-http-server-utils';
|
import { kibanaRequestFactory } from '@kbn/core-http-server-utils';
|
||||||
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
||||||
import {
|
import { mapToReportingError } from '../../../common/errors/map_to_reporting_error';
|
||||||
REPORTING_EXECUTE_TYPE,
|
import { ReportTaskParams, ReportingTask, ReportingTaskStatus, TIME_BETWEEN_ATTEMPTS } from '.';
|
||||||
ReportTaskParams,
|
|
||||||
ReportingTask,
|
|
||||||
ReportingTaskStatus,
|
|
||||||
TIME_BETWEEN_ATTEMPTS,
|
|
||||||
} from '.';
|
|
||||||
import { getContentStream, finishedWithNoPendingCallbacks } from '../content_stream';
|
|
||||||
import type { ReportingCore } from '../..';
|
import type { ReportingCore } from '../..';
|
||||||
import {
|
|
||||||
isExecutionError,
|
|
||||||
mapToReportingError,
|
|
||||||
} from '../../../common/errors/map_to_reporting_error';
|
|
||||||
import { EventTracker } from '../../usage';
|
import { EventTracker } from '../../usage';
|
||||||
import type { ReportingStore } from '../store';
|
|
||||||
import { Report, SavedReport } from '../store';
|
import { Report, SavedReport } from '../store';
|
||||||
import type { ReportFailedFields, ReportProcessingFields } from '../store/store';
|
import type { ReportFailedFields, ReportWarningFields } from '../store/store';
|
||||||
import { errorLogger } from './error_logger';
|
import { errorLogger } from './error_logger';
|
||||||
|
import { finishedWithNoPendingCallbacks, getContentStream } from '../content_stream';
|
||||||
|
import { EmailNotificationService } from '../../services/notifications/email_notification_service';
|
||||||
|
import { ScheduledReportType } from '../../types';
|
||||||
|
|
||||||
type CompletedReportOutput = Omit<ReportOutput, 'content'>;
|
type CompletedReportOutput = Omit<ReportOutput, 'content'>;
|
||||||
|
|
||||||
|
@ -72,12 +66,6 @@ interface GetHeadersOpts {
|
||||||
requestFromTask?: KibanaRequest;
|
requestFromTask?: KibanaRequest;
|
||||||
spaceId: string | undefined;
|
spaceId: string | undefined;
|
||||||
}
|
}
|
||||||
interface ReportingExecuteTaskInstance {
|
|
||||||
state: object;
|
|
||||||
taskType: string;
|
|
||||||
params: ReportTaskParams;
|
|
||||||
runAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOutput(output: CompletedReportOutput | Error): output is CompletedReportOutput {
|
function isOutput(output: CompletedReportOutput | Error): output is CompletedReportOutput {
|
||||||
return (output as CompletedReportOutput).size != null;
|
return (output as CompletedReportOutput).size != null;
|
||||||
|
@ -95,63 +83,100 @@ function parseError(error: unknown): ExecutionError | unknown {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ExecuteReportTask implements ReportingTask {
|
export interface ConstructorOpts {
|
||||||
public TYPE = REPORTING_EXECUTE_TYPE;
|
config: ReportingConfigType;
|
||||||
|
logger: Logger;
|
||||||
private logger: Logger;
|
reporting: ReportingCore;
|
||||||
private taskManagerStart?: TaskManagerStartContract;
|
|
||||||
private kibanaId?: string;
|
|
||||||
private kibanaName?: string;
|
|
||||||
private exportTypesRegistry: ExportTypesRegistry;
|
|
||||||
private store?: ReportingStore;
|
|
||||||
private eventTracker?: EventTracker;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private reporting: ReportingCore,
|
|
||||||
private config: ReportingConfigType,
|
|
||||||
logger: Logger
|
|
||||||
) {
|
|
||||||
this.logger = logger.get('runTask');
|
|
||||||
this.exportTypesRegistry = this.reporting.getExportTypesRegistry();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
export interface PrepareJobResults {
|
||||||
* To be called from plugin start
|
isLastAttempt: boolean;
|
||||||
*/
|
jobId: string;
|
||||||
public async init(taskManager: TaskManagerStartContract) {
|
report?: SavedReport;
|
||||||
|
task?: ReportTaskParams;
|
||||||
|
scheduledReport?: SavedObject<ScheduledReportType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportTaskParamsType = Record<string, any>;
|
||||||
|
|
||||||
|
export abstract class RunReportTask<TaskParams extends ReportTaskParamsType>
|
||||||
|
implements ReportingTask
|
||||||
|
{
|
||||||
|
protected readonly logger: Logger;
|
||||||
|
protected readonly queueTimeout: number;
|
||||||
|
|
||||||
|
protected taskManagerStart?: TaskManagerStartContract;
|
||||||
|
protected kibanaId?: string;
|
||||||
|
protected kibanaName?: string;
|
||||||
|
protected exportTypesRegistry: ExportTypesRegistry;
|
||||||
|
protected eventTracker?: EventTracker;
|
||||||
|
protected emailNotificationService?: EmailNotificationService;
|
||||||
|
|
||||||
|
constructor(protected readonly opts: ConstructorOpts) {
|
||||||
|
this.logger = opts.logger.get('runTask');
|
||||||
|
this.exportTypesRegistry = opts.reporting.getExportTypesRegistry();
|
||||||
|
this.queueTimeout = durationToNumber(opts.config.queue.timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abstract methods
|
||||||
|
public abstract get TYPE(): string;
|
||||||
|
|
||||||
|
public abstract getTaskDefinition(): TaskRegisterDefinition;
|
||||||
|
|
||||||
|
public abstract scheduleTask(
|
||||||
|
request: KibanaRequest,
|
||||||
|
params: TaskParams
|
||||||
|
): Promise<ConcreteTaskInstance>;
|
||||||
|
|
||||||
|
protected abstract prepareJob(taskInstance: ConcreteTaskInstance): Promise<PrepareJobResults>;
|
||||||
|
|
||||||
|
protected abstract getMaxAttempts(): number | undefined;
|
||||||
|
|
||||||
|
protected abstract notify(
|
||||||
|
report: SavedReport,
|
||||||
|
taskInstance: ConcreteTaskInstance,
|
||||||
|
output: TaskRunResult,
|
||||||
|
byteSize: number,
|
||||||
|
scheduledReport?: SavedObject<ScheduledReportType>,
|
||||||
|
spaceId?: string
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
// Public methods
|
||||||
|
public async init(
|
||||||
|
taskManager: TaskManagerStartContract,
|
||||||
|
emailNotificationService?: EmailNotificationService
|
||||||
|
) {
|
||||||
this.taskManagerStart = taskManager;
|
this.taskManagerStart = taskManager;
|
||||||
|
|
||||||
const { reporting } = this;
|
const { uuid, name } = this.opts.reporting.getServerInfo();
|
||||||
const { uuid, name } = reporting.getServerInfo();
|
|
||||||
this.kibanaId = uuid;
|
this.kibanaId = uuid;
|
||||||
this.kibanaName = name;
|
this.kibanaName = name;
|
||||||
|
|
||||||
|
this.emailNotificationService = emailNotificationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
public getStatus() {
|
||||||
* Async get the ReportingStore: it is only available after PluginStart
|
if (this.taskManagerStart) {
|
||||||
*/
|
return ReportingTaskStatus.INITIALIZED;
|
||||||
private async getStore(): Promise<ReportingStore> {
|
|
||||||
if (this.store) {
|
|
||||||
return this.store;
|
|
||||||
}
|
|
||||||
const { store } = await this.reporting.getPluginStartDeps();
|
|
||||||
this.store = store;
|
|
||||||
return store;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTaskManagerStart() {
|
return ReportingTaskStatus.UNINITIALIZED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected methods
|
||||||
|
protected getTaskManagerStart() {
|
||||||
if (!this.taskManagerStart) {
|
if (!this.taskManagerStart) {
|
||||||
throw new Error('Reporting task runner has not been initialized!');
|
throw new Error('Reporting task runner has not been initialized!');
|
||||||
}
|
}
|
||||||
return this.taskManagerStart;
|
return this.taskManagerStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEventTracker(report: Report) {
|
protected getEventTracker(report: Report) {
|
||||||
if (this.eventTracker) {
|
if (this.eventTracker) {
|
||||||
return this.eventTracker;
|
return this.eventTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventTracker = this.reporting.getEventTracker(
|
const eventTracker = this.opts.reporting.getEventTracker(
|
||||||
report._id,
|
report._id,
|
||||||
report.jobtype,
|
report.jobtype,
|
||||||
report.payload.objectType
|
report.payload.objectType
|
||||||
|
@ -160,91 +185,26 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
return this.eventTracker;
|
return this.eventTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getJobContentEncoding(jobType: string) {
|
protected getJobContentEncoding(jobType: string) {
|
||||||
const exportType = this.exportTypesRegistry.getByJobType(jobType);
|
const exportType = this.exportTypesRegistry.getByJobType(jobType);
|
||||||
return exportType.jobContentEncoding;
|
return exportType.jobContentEncoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _claimJob(task: ReportTaskParams): Promise<SavedReport> {
|
protected getJobContentExtension(jobType: string) {
|
||||||
if (this.kibanaId == null) {
|
const exportType = this.exportTypesRegistry.getByJobType(jobType);
|
||||||
throw new Error(`Kibana instance ID is undefined!`);
|
return exportType.jobContentExtension;
|
||||||
}
|
|
||||||
if (this.kibanaName == null) {
|
|
||||||
throw new Error(`Kibana instance name is undefined!`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = await this.getStore();
|
protected getMaxConcurrency() {
|
||||||
const report = await store.findReportFromTask(task); // receives seq_no and primary_term
|
return this.opts.config.queue.pollEnabled ? 1 : 0;
|
||||||
const logger = this.logger.get(report._id);
|
|
||||||
|
|
||||||
if (report.status === 'completed') {
|
|
||||||
throw new Error(`Can not claim the report job: it is already completed!`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const m = moment();
|
protected getQueueTimeout() {
|
||||||
|
// round up from ms to the nearest second
|
||||||
// check if job has exceeded the configured maxAttempts
|
return Math.ceil(numberToDuration(this.opts.config.queue.timeout).asSeconds()) + 's';
|
||||||
const maxAttempts = this.getMaxAttempts();
|
|
||||||
if (report.attempts >= maxAttempts) {
|
|
||||||
let err: ReportingError;
|
|
||||||
if (report.error && isExecutionError(report.error)) {
|
|
||||||
// We have an error stored from a previous attempts, so we'll use that
|
|
||||||
// error to fail the job and return it to the user.
|
|
||||||
const { error } = report;
|
|
||||||
err = mapToReportingError(error);
|
|
||||||
err.stack = error.stack;
|
|
||||||
} else {
|
|
||||||
if (report.error && report.error instanceof Error) {
|
|
||||||
errorLogger(logger, 'Error executing report', report.error);
|
|
||||||
}
|
|
||||||
err = new QueueTimeoutError(
|
|
||||||
`Max attempts reached (${maxAttempts}). Queue timeout reached.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this._failJob(report, err);
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueTimeout = durationToNumber(this.config.queue.timeout);
|
protected async failJob(
|
||||||
const startTime = m.toISOString();
|
|
||||||
const expirationTime = m.add(queueTimeout).toISOString();
|
|
||||||
|
|
||||||
const doc: ReportProcessingFields = {
|
|
||||||
kibana_id: this.kibanaId,
|
|
||||||
kibana_name: this.kibanaName,
|
|
||||||
attempts: report.attempts + 1,
|
|
||||||
max_attempts: maxAttempts,
|
|
||||||
started_at: startTime,
|
|
||||||
timeout: queueTimeout,
|
|
||||||
process_expiration: expirationTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
const claimedReport = new SavedReport({
|
|
||||||
...report,
|
|
||||||
...doc,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Claiming ${claimedReport.jobtype} ${report._id} ` +
|
|
||||||
`[_index: ${report._index}] ` +
|
|
||||||
`[_seq_no: ${report._seq_no}] ` +
|
|
||||||
`[_primary_term: ${report._primary_term}] ` +
|
|
||||||
`[attempts: ${report.attempts}] ` +
|
|
||||||
`[process_expiration: ${expirationTime}]`
|
|
||||||
);
|
|
||||||
|
|
||||||
// event tracking of claimed job
|
|
||||||
const eventTracker = this.getEventTracker(report);
|
|
||||||
const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf();
|
|
||||||
eventTracker?.claimJob({ timeSinceCreation });
|
|
||||||
|
|
||||||
const resp = await store.setReportClaimed(claimedReport, doc);
|
|
||||||
claimedReport._seq_no = resp._seq_no!;
|
|
||||||
claimedReport._primary_term = resp._primary_term!;
|
|
||||||
return claimedReport;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _failJob(
|
|
||||||
report: SavedReport,
|
report: SavedReport,
|
||||||
error?: ReportingError
|
error?: ReportingError
|
||||||
): Promise<UpdateResponse<ReportDocument>> {
|
): Promise<UpdateResponse<ReportDocument>> {
|
||||||
|
@ -255,13 +215,13 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
let docOutput;
|
let docOutput;
|
||||||
if (error) {
|
if (error) {
|
||||||
errorLogger(logger, message, error);
|
errorLogger(logger, message, error);
|
||||||
docOutput = this._formatOutput(error);
|
docOutput = this.formatOutput(error);
|
||||||
} else {
|
} else {
|
||||||
errorLogger(logger, message);
|
errorLogger(logger, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// update the report in the store
|
// update the report in the store
|
||||||
const store = await this.getStore();
|
const store = await this.opts.reporting.getStore();
|
||||||
const completedTime = moment();
|
const completedTime = moment();
|
||||||
const doc: ReportFailedFields = {
|
const doc: ReportFailedFields = {
|
||||||
completed_at: completedTime.toISOString(),
|
completed_at: completedTime.toISOString(),
|
||||||
|
@ -280,7 +240,7 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
return await store.setReportFailed(report, doc);
|
return await store.setReportFailed(report, doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _saveExecutionError(
|
protected async saveExecutionError(
|
||||||
report: SavedReport,
|
report: SavedReport,
|
||||||
failedToExecuteErr: any
|
failedToExecuteErr: any
|
||||||
): Promise<UpdateResponse<ReportDocument>> {
|
): Promise<UpdateResponse<ReportDocument>> {
|
||||||
|
@ -291,7 +251,7 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
errorLogger(logger, message, failedToExecuteErr);
|
errorLogger(logger, message, failedToExecuteErr);
|
||||||
|
|
||||||
// update the report in the store
|
// update the report in the store
|
||||||
const store = await this.getStore();
|
const store = await this.opts.reporting.getStore();
|
||||||
const doc: ReportFailedFields = {
|
const doc: ReportFailedFields = {
|
||||||
output: null,
|
output: null,
|
||||||
error: errorParsed,
|
error: errorParsed,
|
||||||
|
@ -300,7 +260,25 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
return await store.setReportError(report, doc);
|
return await store.setReportError(report, doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput {
|
protected async saveExecutionWarning(
|
||||||
|
report: SavedReport,
|
||||||
|
output: CompletedReportOutput,
|
||||||
|
message: string
|
||||||
|
): Promise<UpdateResponse<ReportDocument>> {
|
||||||
|
const logger = this.logger.get(report._id);
|
||||||
|
logger.warn(message);
|
||||||
|
|
||||||
|
// update the report in the store
|
||||||
|
const store = await this.opts.reporting.getStore();
|
||||||
|
const doc: ReportWarningFields = {
|
||||||
|
output,
|
||||||
|
warning: message,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await store.setReportWarning(report, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput {
|
||||||
const docOutput = {} as ReportOutput;
|
const docOutput = {} as ReportOutput;
|
||||||
const unknownMime = null;
|
const unknownMime = null;
|
||||||
|
|
||||||
|
@ -324,7 +302,7 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
return docOutput;
|
return docOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getRequestToUse({
|
protected async getRequestToUse({
|
||||||
requestFromTask,
|
requestFromTask,
|
||||||
spaceId,
|
spaceId,
|
||||||
encryptedHeaders,
|
encryptedHeaders,
|
||||||
|
@ -339,17 +317,17 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
let decryptedHeaders;
|
let decryptedHeaders;
|
||||||
if (this.config.encryptionKey && encryptedHeaders) {
|
if (this.opts.config.encryptionKey && encryptedHeaders) {
|
||||||
// get decrypted headers
|
// get decrypted headers
|
||||||
decryptedHeaders = await decryptJobHeaders(
|
decryptedHeaders = await decryptJobHeaders(
|
||||||
this.config.encryptionKey,
|
this.opts.config.encryptionKey,
|
||||||
encryptedHeaders,
|
encryptedHeaders,
|
||||||
this.logger
|
this.logger
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decryptedHeaders && !apiKeyAuthHeaders) {
|
if (!decryptedHeaders && !apiKeyAuthHeaders) {
|
||||||
throw new Error('No headers found to execute report');
|
throw new MissingAuthenticationError();
|
||||||
}
|
}
|
||||||
|
|
||||||
let headersToUse: Headers = {};
|
let headersToUse: Headers = {};
|
||||||
|
@ -367,10 +345,10 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
headersToUse = decryptedHeaders || {};
|
headersToUse = decryptedHeaders || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return this._getFakeRequest(headersToUse, spaceId, this.logger);
|
return this.getFakeRequest(headersToUse, spaceId, this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getFakeRequest(
|
protected getFakeRequest(
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
spaceId: string | undefined,
|
spaceId: string | undefined,
|
||||||
logger = this.logger
|
logger = this.logger
|
||||||
|
@ -381,7 +359,7 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
};
|
};
|
||||||
const fakeRequest = kibanaRequestFactory(rawRequest);
|
const fakeRequest = kibanaRequestFactory(rawRequest);
|
||||||
|
|
||||||
const setupDeps = this.reporting.getPluginSetupDeps();
|
const setupDeps = this.opts.reporting.getPluginSetupDeps();
|
||||||
const spacesService = setupDeps.spaces?.spacesService;
|
const spacesService = setupDeps.spaces?.spacesService;
|
||||||
if (spacesService) {
|
if (spacesService) {
|
||||||
if (spaceId && spaceId !== DEFAULT_SPACE_ID) {
|
if (spaceId && spaceId !== DEFAULT_SPACE_ID) {
|
||||||
|
@ -392,7 +370,7 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
return fakeRequest;
|
return fakeRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _performJob({
|
protected async performJob({
|
||||||
task,
|
task,
|
||||||
fakeRequest,
|
fakeRequest,
|
||||||
taskInstanceFields,
|
taskInstanceFields,
|
||||||
|
@ -405,8 +383,7 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
}
|
}
|
||||||
// run the report
|
// run the report
|
||||||
// if workerFn doesn't finish before timeout, call the cancellationToken and throw an error
|
// if workerFn doesn't finish before timeout, call the cancellationToken and throw an error
|
||||||
const queueTimeout = durationToNumber(this.config.queue.timeout);
|
const request = await this.getRequestToUse({
|
||||||
const request = await this._getRequestToUse({
|
|
||||||
requestFromTask: fakeRequest,
|
requestFromTask: fakeRequest,
|
||||||
spaceId: task.payload.spaceId,
|
spaceId: task.payload.spaceId,
|
||||||
encryptedHeaders: task.payload.headers,
|
encryptedHeaders: task.payload.headers,
|
||||||
|
@ -422,11 +399,11 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
cancellationToken,
|
cancellationToken,
|
||||||
stream,
|
stream,
|
||||||
})
|
})
|
||||||
).pipe(timeout(queueTimeout)) // throw an error if a value is not emitted before timeout
|
).pipe(timeout(this.queueTimeout)) // throw an error if a value is not emitted before timeout
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _completeJob(
|
protected async completeJob(
|
||||||
report: SavedReport,
|
report: SavedReport,
|
||||||
output: CompletedReportOutput
|
output: CompletedReportOutput
|
||||||
): Promise<SavedReport> {
|
): Promise<SavedReport> {
|
||||||
|
@ -436,8 +413,8 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
logger.debug(`Saving ${report.jobtype} to ${docId}.`);
|
logger.debug(`Saving ${report.jobtype} to ${docId}.`);
|
||||||
|
|
||||||
const completedTime = moment();
|
const completedTime = moment();
|
||||||
const docOutput = this._formatOutput(output);
|
const docOutput = this.formatOutput(output);
|
||||||
const store = await this.getStore();
|
const store = await this.opts.reporting.getStore();
|
||||||
const doc = {
|
const doc = {
|
||||||
completed_at: completedTime.toISOString(),
|
completed_at: completedTime.toISOString(),
|
||||||
metrics: output.metrics,
|
metrics: output.metrics,
|
||||||
|
@ -477,25 +454,20 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic is used to let TS infer the return type at call site.
|
// Generic is used to let TS infer the return type at call site.
|
||||||
private async throwIfKibanaShutsDown<T>(): Promise<T> {
|
protected async throwIfKibanaShutsDown<T>(): Promise<T> {
|
||||||
await Rx.firstValueFrom(this.reporting.getKibanaShutdown$());
|
await Rx.firstValueFrom(this.opts.reporting.getKibanaShutdown$());
|
||||||
throw new KibanaShuttingDownError();
|
throw new KibanaShuttingDownError();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Provides a TaskRunner for Task Manager
|
* Provides a TaskRunner for Task Manager
|
||||||
*/
|
*/
|
||||||
private getTaskRunner(): TaskRunCreatorFunction {
|
protected getTaskRunner(): TaskRunCreatorFunction {
|
||||||
// Keep a separate local stack for each task run
|
// Keep a separate local stack for each task run
|
||||||
return ({ taskInstance, fakeRequest }: RunContext) => {
|
return ({ taskInstance, fakeRequest }: RunContext) => {
|
||||||
let jobId: string;
|
let jobId: string;
|
||||||
const cancellationToken = new CancellationToken();
|
const cancellationToken = new CancellationToken();
|
||||||
const {
|
const { retryAt: taskRetryAt, startedAt: taskStartedAt } = taskInstance;
|
||||||
attempts: taskAttempts,
|
|
||||||
params: reportTaskParams,
|
|
||||||
retryAt: taskRetryAt,
|
|
||||||
startedAt: taskStartedAt,
|
|
||||||
} = taskInstance;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/*
|
/*
|
||||||
|
@ -506,31 +478,30 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
* If any error happens, additional retry attempts may be picked up by a separate instance
|
* If any error happens, additional retry attempts may be picked up by a separate instance
|
||||||
*/
|
*/
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
if (this.kibanaId == null) {
|
||||||
|
throw new Error(`Kibana instance ID is undefined!`);
|
||||||
|
}
|
||||||
|
if (this.kibanaName == null) {
|
||||||
|
throw new Error(`Kibana instance name is undefined!`);
|
||||||
|
}
|
||||||
|
|
||||||
let report: SavedReport | undefined;
|
let report: SavedReport | undefined;
|
||||||
const isLastAttempt = taskAttempts >= this.getMaxAttempts();
|
const {
|
||||||
|
isLastAttempt,
|
||||||
|
jobId: jId,
|
||||||
|
report: preparedReport,
|
||||||
|
task,
|
||||||
|
scheduledReport,
|
||||||
|
} = await this.prepareJob(taskInstance);
|
||||||
|
jobId = jId;
|
||||||
|
report = preparedReport;
|
||||||
|
|
||||||
// find the job in the store and set status to processing
|
|
||||||
const task = reportTaskParams as ReportTaskParams;
|
|
||||||
jobId = task?.id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!jobId) {
|
|
||||||
throw new Error('Invalid report data provided in scheduled task!');
|
|
||||||
}
|
|
||||||
if (!isLastAttempt) {
|
if (!isLastAttempt) {
|
||||||
this.reporting.trackReport(jobId);
|
this.opts.reporting.trackReport(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update job status to claimed
|
if (!report || !task) {
|
||||||
report = await this._claimJob(task);
|
this.opts.reporting.untrackReport(jobId);
|
||||||
} catch (failedToClaim) {
|
|
||||||
// error claiming report - log the error
|
|
||||||
// could be version conflict, or too many attempts or no longer connected to ES
|
|
||||||
errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
this.reporting.untrackReport(jobId);
|
|
||||||
|
|
||||||
if (isLastAttempt) {
|
if (isLastAttempt) {
|
||||||
errorLogger(this.logger, `Job ${jobId} failed too many times. Exiting...`);
|
errorLogger(this.logger, `Job ${jobId} failed too many times. Exiting...`);
|
||||||
|
@ -545,22 +516,27 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { jobtype: jobType, attempts } = report;
|
const { jobtype: jobType, attempts } = report;
|
||||||
const maxAttempts = this.getMaxAttempts();
|
|
||||||
const logger = this.logger.get(jobId);
|
const logger = this.logger.get(jobId);
|
||||||
|
|
||||||
|
const maxAttempts = this.getMaxAttempts();
|
||||||
|
if (maxAttempts) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.`
|
`Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.`
|
||||||
);
|
);
|
||||||
logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`);
|
} else {
|
||||||
|
logger.debug(`Starting ${jobType} report ${jobId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
const eventLog = this.reporting.getEventLogger(
|
logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`);
|
||||||
|
|
||||||
|
const eventLog = this.opts.reporting.getEventLogger(
|
||||||
new Report({ ...task, _id: task.id, _index: task.index })
|
new Report({ ...task, _id: task.id, _index: task.index })
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobContentEncoding = this.getJobContentEncoding(jobType);
|
const jobContentEncoding = this.getJobContentEncoding(jobType);
|
||||||
const stream = await getContentStream(
|
const stream = await getContentStream(
|
||||||
this.reporting,
|
this.opts.reporting,
|
||||||
{
|
{
|
||||||
id: report._id,
|
id: report._id,
|
||||||
index: report._index,
|
index: report._index,
|
||||||
|
@ -574,7 +550,7 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
eventLog.logExecutionStart();
|
eventLog.logExecutionStart();
|
||||||
|
|
||||||
const output = await Promise.race<TaskRunResult>([
|
const output = await Promise.race<TaskRunResult>([
|
||||||
this._performJob({
|
this.performJob({
|
||||||
task,
|
task,
|
||||||
fakeRequest,
|
fakeRequest,
|
||||||
taskInstanceFields: { retryAt: taskRetryAt, startedAt: taskStartedAt },
|
taskInstanceFields: { retryAt: taskRetryAt, startedAt: taskStartedAt },
|
||||||
|
@ -593,18 +569,28 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
report._seq_no = stream.getSeqNo()!;
|
report._seq_no = stream.getSeqNo()!;
|
||||||
report._primary_term = stream.getPrimaryTerm()!;
|
report._primary_term = stream.getPrimaryTerm()!;
|
||||||
|
|
||||||
|
const byteSize = stream.bytesWritten;
|
||||||
eventLog.logExecutionComplete({
|
eventLog.logExecutionComplete({
|
||||||
...(output.metrics ?? {}),
|
...(output.metrics ?? {}),
|
||||||
byteSize: stream.bytesWritten,
|
byteSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (output) {
|
if (output) {
|
||||||
logger.debug(`Job output size: ${stream.bytesWritten} bytes.`);
|
logger.debug(`Job output size: ${byteSize} bytes.`);
|
||||||
// Update the job status to "completed"
|
// Update the job status to "completed"
|
||||||
report = await this._completeJob(report, {
|
report = await this.completeJob(report, {
|
||||||
...output,
|
...output,
|
||||||
size: stream.bytesWritten,
|
size: byteSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.notify(
|
||||||
|
report,
|
||||||
|
taskInstance,
|
||||||
|
output,
|
||||||
|
byteSize,
|
||||||
|
scheduledReport,
|
||||||
|
task.payload.spaceId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// untrack the report for concurrency awareness
|
// untrack the report for concurrency awareness
|
||||||
|
@ -612,11 +598,9 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
} catch (failedToExecuteErr) {
|
} catch (failedToExecuteErr) {
|
||||||
eventLog.logError(failedToExecuteErr);
|
eventLog.logError(failedToExecuteErr);
|
||||||
|
|
||||||
await this._saveExecutionError(report, failedToExecuteErr).catch(
|
await this.saveExecutionError(report, failedToExecuteErr).catch((failedToSaveError) => {
|
||||||
(failedToSaveError) => {
|
|
||||||
errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError);
|
errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError);
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
cancellationToken.cancel();
|
cancellationToken.cancel();
|
||||||
|
|
||||||
|
@ -624,8 +608,8 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
|
|
||||||
throwRetryableError(error, new Date(Date.now() + TIME_BETWEEN_ATTEMPTS));
|
throwRetryableError(error, new Date(Date.now() + TIME_BETWEEN_ATTEMPTS));
|
||||||
} finally {
|
} finally {
|
||||||
this.reporting.untrackReport(jobId);
|
this.opts.reporting.untrackReport(jobId);
|
||||||
logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`);
|
logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -642,47 +626,4 @@ export class ExecuteReportTask implements ReportingTask {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMaxAttempts() {
|
|
||||||
return this.config.capture.maxAttempts ?? 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTaskDefinition() {
|
|
||||||
// round up from ms to the nearest second
|
|
||||||
const queueTimeout = Math.ceil(numberToDuration(this.config.queue.timeout).asSeconds()) + 's';
|
|
||||||
const maxConcurrency = this.config.queue.pollEnabled ? 1 : 0;
|
|
||||||
const maxAttempts = this.getMaxAttempts();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: REPORTING_EXECUTE_TYPE,
|
|
||||||
title: 'Reporting: execute job',
|
|
||||||
createTaskRunner: this.getTaskRunner(),
|
|
||||||
maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart
|
|
||||||
timeout: queueTimeout,
|
|
||||||
maxConcurrency,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) {
|
|
||||||
const reportingHealth = await this.reporting.getHealthInfo();
|
|
||||||
const shouldScheduleWithApiKey =
|
|
||||||
reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure;
|
|
||||||
const taskInstance: ReportingExecuteTaskInstance = {
|
|
||||||
taskType: REPORTING_EXECUTE_TYPE,
|
|
||||||
state: {},
|
|
||||||
params,
|
|
||||||
};
|
|
||||||
|
|
||||||
return shouldScheduleWithApiKey
|
|
||||||
? await this.getTaskManagerStart().schedule(taskInstance, { request })
|
|
||||||
: await this.getTaskManagerStart().schedule(taskInstance);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getStatus() {
|
|
||||||
if (this.taskManagerStart) {
|
|
||||||
return ReportingTaskStatus.INITIALIZED;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReportingTaskStatus.UNINITIALIZED;
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -0,0 +1,709 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Transform } from 'stream';
|
||||||
|
import type { estypes } from '@elastic/elasticsearch';
|
||||||
|
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||||
|
import { MockedLogger } from '@kbn/logging-mocks';
|
||||||
|
import { JOB_STATUS, KibanaShuttingDownError } from '@kbn/reporting-common';
|
||||||
|
import { ReportDocument } from '@kbn/reporting-common/types';
|
||||||
|
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
|
||||||
|
import { type ExportType, type ReportingConfigType } from '@kbn/reporting-server';
|
||||||
|
import type { RunContext } from '@kbn/task-manager-plugin/server';
|
||||||
|
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
|
||||||
|
import { notificationsMock } from '@kbn/notifications-plugin/server/mocks';
|
||||||
|
|
||||||
|
import { RunScheduledReportTask, SCHEDULED_REPORTING_EXECUTE_TYPE } from '.';
|
||||||
|
import { ReportingCore } from '../..';
|
||||||
|
import { createMockReportingCore } from '../../test_helpers';
|
||||||
|
import {
|
||||||
|
FakeRawRequest,
|
||||||
|
KibanaRequest,
|
||||||
|
SavedObject,
|
||||||
|
SavedObjectsClientContract,
|
||||||
|
} from '@kbn/core/server';
|
||||||
|
import { Frequency } from '@kbn/rrule';
|
||||||
|
import { ReportingStore, SavedReport } from '../store';
|
||||||
|
import { ScheduledReportType } from '../../types';
|
||||||
|
import { EmailNotificationService } from '../../services/notifications/email_notification_service';
|
||||||
|
|
||||||
|
interface StreamMock {
|
||||||
|
getSeqNo: () => number;
|
||||||
|
getPrimaryTerm: () => number;
|
||||||
|
write: (data: string) => void;
|
||||||
|
fail: () => void;
|
||||||
|
end: () => void;
|
||||||
|
transform: Transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStreamMock(): StreamMock {
|
||||||
|
const transform: Transform = new Transform({});
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSeqNo: () => 10,
|
||||||
|
getPrimaryTerm: () => 20,
|
||||||
|
write: (data: string) => {
|
||||||
|
transform.push(`${data}\n`);
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
transform.emit('error', new Error('Stream failed'));
|
||||||
|
transform.end();
|
||||||
|
},
|
||||||
|
transform,
|
||||||
|
end: () => {
|
||||||
|
transform.end();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockStream = createStreamMock();
|
||||||
|
jest.mock('../content_stream', () => ({
|
||||||
|
getContentStream: () => mockStream,
|
||||||
|
finishedWithNoPendingCallbacks: () => Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../../services/notifications/email_notification_service');
|
||||||
|
|
||||||
|
const fakeRawRequest: FakeRawRequest = {
|
||||||
|
headers: {
|
||||||
|
authorization: `ApiKey skdjtq4u543yt3rhewrh`,
|
||||||
|
},
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
headers: '',
|
||||||
|
title: 'Test Report',
|
||||||
|
browserTimezone: '',
|
||||||
|
objectType: 'test',
|
||||||
|
version: '8.0.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduledReport: SavedObject<ScheduledReportType> = {
|
||||||
|
id: 'report-so-id',
|
||||||
|
attributes: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
createdBy: 'test-user',
|
||||||
|
enabled: true,
|
||||||
|
jobType: 'test1',
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
migrationVersion: '8.0.0',
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } },
|
||||||
|
title: 'Test Report',
|
||||||
|
notification: {
|
||||||
|
email: {
|
||||||
|
to: ['test1@test.com'],
|
||||||
|
bcc: ['test2@test.com'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
references: [],
|
||||||
|
type: 'scheduled-report',
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedReport = new SavedReport({
|
||||||
|
_id: '290357209345723095',
|
||||||
|
_index: '.reporting-fantastic',
|
||||||
|
_seq_no: 23,
|
||||||
|
_primary_term: 354000,
|
||||||
|
jobtype: 'test1',
|
||||||
|
migration_version: '8.0.0',
|
||||||
|
payload,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
created_by: 'test-user',
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
scheduled_report_id: 'report-so-id',
|
||||||
|
status: JOB_STATUS.PROCESSING,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Run Scheduled Report Task', () => {
|
||||||
|
let mockReporting: ReportingCore;
|
||||||
|
let configType: ReportingConfigType;
|
||||||
|
let soClient: SavedObjectsClientContract;
|
||||||
|
let reportStore: ReportingStore;
|
||||||
|
const notifications = notificationsMock.createStart();
|
||||||
|
let emailNotificationService: EmailNotificationService;
|
||||||
|
let logger: MockedLogger;
|
||||||
|
|
||||||
|
const runTaskFn = jest.fn().mockResolvedValue({ content_type: 'application/pdf' });
|
||||||
|
beforeAll(async () => {
|
||||||
|
configType = createMockConfigSchema();
|
||||||
|
mockReporting = await createMockReportingCore(configType);
|
||||||
|
|
||||||
|
soClient = await mockReporting.getInternalSoClient();
|
||||||
|
|
||||||
|
mockReporting.getExportTypesRegistry().register({
|
||||||
|
id: 'test1',
|
||||||
|
name: 'Test1',
|
||||||
|
setup: jest.fn(),
|
||||||
|
start: jest.fn(),
|
||||||
|
createJob: () => new Promise(() => {}),
|
||||||
|
runTask: runTaskFn,
|
||||||
|
jobContentEncoding: 'base64',
|
||||||
|
jobContentExtension: 'pdf',
|
||||||
|
jobType: 'test1',
|
||||||
|
validLicenses: [],
|
||||||
|
} as unknown as ExportType);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
logger = loggingSystemMock.createLogger();
|
||||||
|
soClient.get = jest.fn().mockImplementation(async () => {
|
||||||
|
return scheduledReport;
|
||||||
|
});
|
||||||
|
reportStore = await mockReporting.getStore();
|
||||||
|
reportStore.addReport = jest.fn().mockImplementation(async () => {
|
||||||
|
return savedReport;
|
||||||
|
});
|
||||||
|
reportStore.setReportError = jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
_id: 'test',
|
||||||
|
jobtype: 'noop',
|
||||||
|
status: 'processing',
|
||||||
|
} as unknown as estypes.UpdateUpdateWriteResponseBase<ReportDocument>)
|
||||||
|
);
|
||||||
|
reportStore.setReportWarning = jest.fn();
|
||||||
|
emailNotificationService = new EmailNotificationService({
|
||||||
|
notifications,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Instance setup', () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
expect(task.getStatus()).toBe('uninitialized');
|
||||||
|
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"createTaskRunner": [Function],
|
||||||
|
"maxConcurrency": 1,
|
||||||
|
"timeout": "120s",
|
||||||
|
"title": "Reporting: execute scheduled job",
|
||||||
|
"type": "report:execute-scheduled",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Instance start', () => {
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
expect(task.init(mockTaskManager, emailNotificationService));
|
||||||
|
expect(task.getStatus()).toBe('initialized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create task runner', async () => {
|
||||||
|
logger.info = jest.fn();
|
||||||
|
logger.error = jest.fn();
|
||||||
|
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const taskDef = task.getTaskDefinition();
|
||||||
|
const taskRunner = taskDef.createTaskRunner({
|
||||||
|
taskInstance: {
|
||||||
|
id: 'random-task-id',
|
||||||
|
params: { id: 'cool-reporting-id', jobtype: 'test1' },
|
||||||
|
},
|
||||||
|
} as unknown as RunContext);
|
||||||
|
expect(taskRunner).toHaveProperty('run');
|
||||||
|
expect(taskRunner).toHaveProperty('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Max Concurrency is 0 if pollEnabled is false', () => {
|
||||||
|
const queueConfig = {
|
||||||
|
queue: { pollEnabled: false, timeout: 55000 },
|
||||||
|
} as unknown as ReportingConfigType['queue'];
|
||||||
|
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: { ...configType, ...queueConfig },
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
expect(task.getStatus()).toBe('uninitialized');
|
||||||
|
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"createTaskRunner": [Function],
|
||||||
|
"maxConcurrency": 0,
|
||||||
|
"timeout": "55s",
|
||||||
|
"title": "Reporting: execute scheduled job",
|
||||||
|
"type": "report:execute-scheduled",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules task with request', async () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
|
||||||
|
await task.scheduleTask(fakeRawRequest as unknown as KibanaRequest, {
|
||||||
|
id: 'report-so-id',
|
||||||
|
jobtype: 'test1',
|
||||||
|
schedule: {
|
||||||
|
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTaskManager.schedule).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
id: 'report-so-id',
|
||||||
|
taskType: SCHEDULED_REPORTING_EXECUTE_TYPE,
|
||||||
|
state: {},
|
||||||
|
params: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
spaceId: 'default',
|
||||||
|
jobtype: 'test1',
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ request: fakeRawRequest }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses authorization headers from task manager fake request', async () => {
|
||||||
|
const runAt = new Date('2023-10-01T00:00:00Z');
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
jest
|
||||||
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance
|
||||||
|
.spyOn(task, 'completeJob')
|
||||||
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
|
||||||
|
const taskDef = task.getTaskDefinition();
|
||||||
|
const taskRunner = taskDef.createTaskRunner({
|
||||||
|
taskInstance: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
runAt,
|
||||||
|
params: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
jobtype: 'test1',
|
||||||
|
schedule: {
|
||||||
|
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fakeRequest: fakeRawRequest,
|
||||||
|
} as unknown as RunContext);
|
||||||
|
|
||||||
|
await taskRunner.run();
|
||||||
|
|
||||||
|
expect(soClient.get).toHaveBeenCalledWith('scheduled_report', 'report-so-id', {
|
||||||
|
namespace: 'default',
|
||||||
|
});
|
||||||
|
expect(reportStore.addReport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
_id: expect.any(String),
|
||||||
|
_index: '.kibana-reporting',
|
||||||
|
jobtype: 'test1',
|
||||||
|
created_at: expect.any(String),
|
||||||
|
created_by: 'test-user',
|
||||||
|
payload: {
|
||||||
|
headers: '',
|
||||||
|
title: expect.any(String),
|
||||||
|
browserTimezone: '',
|
||||||
|
objectType: 'test',
|
||||||
|
version: '8.0.0',
|
||||||
|
forceNow: expect.any(String),
|
||||||
|
},
|
||||||
|
meta: { objectType: 'test' },
|
||||||
|
status: 'processing',
|
||||||
|
attempts: 1,
|
||||||
|
scheduled_report_id: 'report-so-id',
|
||||||
|
kibana_name: 'kibana',
|
||||||
|
kibana_id: 'instance-uuid',
|
||||||
|
started_at: expect.any(String),
|
||||||
|
timeout: 120000,
|
||||||
|
max_attempts: 1,
|
||||||
|
process_expiration: expect.any(String),
|
||||||
|
migration_version: '7.14.0',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(runTaskFn.mock.calls[0][0].request.headers).toEqual({
|
||||||
|
authorization: 'ApiKey skdjtq4u543yt3rhewrh',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if no fake request from task', async () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
|
||||||
|
const taskDef = task.getTaskDefinition();
|
||||||
|
const taskRunner = taskDef.createTaskRunner({
|
||||||
|
taskInstance: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
runAt: new Date('2023-10-01T00:00:00Z'),
|
||||||
|
params: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
jobtype: 'test1',
|
||||||
|
schedule: {
|
||||||
|
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fakeRequest: undefined,
|
||||||
|
} as unknown as RunContext);
|
||||||
|
|
||||||
|
await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||||
|
`"ReportingError(code: missing_authentication_header_error)"`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(soClient.get).toHaveBeenCalled();
|
||||||
|
expect(reportStore.addReport).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(reportStore.setReportError).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
_id: '290357209345723095',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: `ReportingError(code: missing_authentication_header_error)`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws during reporting if Kibana starts shutting down', async () => {
|
||||||
|
mockReporting.getExportTypesRegistry().register({
|
||||||
|
id: 'noop',
|
||||||
|
name: 'Noop',
|
||||||
|
setup: jest.fn(),
|
||||||
|
start: jest.fn(),
|
||||||
|
createJob: () => new Promise(() => {}),
|
||||||
|
runTask: () => new Promise(() => {}),
|
||||||
|
jobContentExtension: 'pdf',
|
||||||
|
jobType: 'noop',
|
||||||
|
validLicenses: [],
|
||||||
|
} as unknown as ExportType);
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
jest
|
||||||
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance
|
||||||
|
.spyOn(task, 'prepareJob')
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
isLastAttempt: false,
|
||||||
|
jobId: '290357209345723095',
|
||||||
|
report: { _id: '290357209345723095', jobtype: 'noop' },
|
||||||
|
task: {
|
||||||
|
id: '290357209345723095',
|
||||||
|
index: '.reporting-fantastic',
|
||||||
|
jobtype: 'noop',
|
||||||
|
payload,
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
|
||||||
|
const taskDef = task.getTaskDefinition();
|
||||||
|
const taskRunner = taskDef.createTaskRunner({
|
||||||
|
taskInstance: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
params: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
jobtype: 'test1',
|
||||||
|
schedule: {
|
||||||
|
rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fakeRequest: fakeRawRequest,
|
||||||
|
} as unknown as RunContext);
|
||||||
|
|
||||||
|
const taskPromise = taskRunner.run();
|
||||||
|
setImmediate(() => {
|
||||||
|
mockReporting.pluginStop();
|
||||||
|
});
|
||||||
|
await taskPromise.catch(() => {});
|
||||||
|
|
||||||
|
expect(reportStore.setReportError).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
_id: '290357209345723095',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: `ReportingError(code: ${new KibanaShuttingDownError().code})`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('notify', () => {
|
||||||
|
it('sends an email notification', async () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
const taskInstance = {
|
||||||
|
id: 'task-id',
|
||||||
|
runAt: new Date('2025-06-04T00:00:00Z'),
|
||||||
|
params: { id: 'report-so-id', jobtype: 'test1' },
|
||||||
|
};
|
||||||
|
const byteSize = 2097152; // 2MB
|
||||||
|
const output = { content_type: 'application/pdf' };
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
|
||||||
|
expect(soClient.get).not.toHaveBeenCalled();
|
||||||
|
expect(emailNotificationService.notify).toHaveBeenCalledWith({
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
emailParams: {
|
||||||
|
bcc: ['test2@test.com'],
|
||||||
|
cc: undefined,
|
||||||
|
spaceId: 'default',
|
||||||
|
subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report',
|
||||||
|
to: ['test1@test.com'],
|
||||||
|
},
|
||||||
|
filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf',
|
||||||
|
id: '290357209345723095',
|
||||||
|
index: '.reporting-fantastic',
|
||||||
|
relatedObject: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
namespace: 'default',
|
||||||
|
type: 'scheduled-report',
|
||||||
|
},
|
||||||
|
reporting: mockReporting,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gets the scheduled_report saved object if it's not defined", async () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
const taskInstance = {
|
||||||
|
id: 'task-id',
|
||||||
|
runAt: new Date('2025-06-04T00:00:00Z'),
|
||||||
|
params: { id: 'report-so-id', jobtype: 'test1' },
|
||||||
|
};
|
||||||
|
const byteSize = 2097152; // 2MB
|
||||||
|
const output = { content_type: 'application/pdf' };
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
await task.notify(savedReport, taskInstance, output, byteSize, undefined, 'default');
|
||||||
|
expect(soClient.get).toHaveBeenCalled();
|
||||||
|
expect(emailNotificationService.notify).toHaveBeenCalledWith({
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
emailParams: {
|
||||||
|
bcc: ['test2@test.com'],
|
||||||
|
cc: undefined,
|
||||||
|
spaceId: 'default',
|
||||||
|
subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report',
|
||||||
|
to: ['test1@test.com'],
|
||||||
|
},
|
||||||
|
filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf',
|
||||||
|
id: '290357209345723095',
|
||||||
|
index: '.reporting-fantastic',
|
||||||
|
relatedObject: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
namespace: 'default',
|
||||||
|
type: 'scheduled-report',
|
||||||
|
},
|
||||||
|
reporting: mockReporting,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send an email notification if the notification is not defined', async () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
const taskInstance = {
|
||||||
|
id: 'task-id',
|
||||||
|
runAt: new Date('2025-06-04T00:00:00Z'),
|
||||||
|
params: { id: 'report-so-id', jobtype: 'test1' },
|
||||||
|
};
|
||||||
|
const byteSize = 2097152; // 2MB
|
||||||
|
const output = { content_type: 'application/pdf' };
|
||||||
|
const noNotification = { ...scheduledReport, notification: undefined };
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
await task.notify(savedReport, taskInstance, output, byteSize, noNotification, 'default');
|
||||||
|
expect(soClient.get).not.toHaveBeenCalled();
|
||||||
|
expect(emailNotificationService.notify).not.toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a warning and sets the execution to warning when the report is larger than 10MB', async () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
const taskInstance = {
|
||||||
|
id: 'task-id',
|
||||||
|
runAt: new Date('2025-06-04T00:00:00Z'),
|
||||||
|
params: { id: 'report-so-id', jobtype: 'test1' },
|
||||||
|
};
|
||||||
|
const byteSize = 11534336; // 11MB
|
||||||
|
const output = { content_type: 'application/pdf' };
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(emailNotificationService.notify).not.toHaveBeenCalled();
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Error sending notification for scheduled report: The report is larger than the 10MB limit.'
|
||||||
|
);
|
||||||
|
expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, {
|
||||||
|
output: { content_type: 'application/pdf', size: 11534336 },
|
||||||
|
warning:
|
||||||
|
'Error sending notification for scheduled report: The report is larger than the 10MB limit.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a warning and sets the execution to warning when the notification service is not initialized', async () => {
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager);
|
||||||
|
const taskInstance = {
|
||||||
|
id: 'task-id',
|
||||||
|
runAt: new Date('2025-06-04T00:00:00Z'),
|
||||||
|
params: { id: 'report-so-id', jobtype: 'test1' },
|
||||||
|
};
|
||||||
|
const byteSize = 2097152; // 2MB
|
||||||
|
const output = { content_type: 'application/pdf' };
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(emailNotificationService.notify).not.toHaveBeenCalled();
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Error sending notification for scheduled report: Reporting notification service has not been initialized.'
|
||||||
|
);
|
||||||
|
expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, {
|
||||||
|
output: { content_type: 'application/pdf', size: 2097152 },
|
||||||
|
warning:
|
||||||
|
'Error sending notification for scheduled report: Reporting notification service has not been initialized.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a warning and sets the execution to warning if the notification service throws an error', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(emailNotificationService, 'notify')
|
||||||
|
.mockRejectedValueOnce(new Error('This is a test error!'));
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
const taskInstance = {
|
||||||
|
id: 'task-id',
|
||||||
|
runAt: new Date('2025-06-04T00:00:00Z'),
|
||||||
|
params: { id: 'report-so-id', jobtype: 'test1' },
|
||||||
|
};
|
||||||
|
const byteSize = 2097152; // 2MB
|
||||||
|
const output = { content_type: 'application/pdf' };
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(emailNotificationService.notify).toHaveBeenCalledWith({
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
emailParams: {
|
||||||
|
bcc: ['test2@test.com'],
|
||||||
|
cc: undefined,
|
||||||
|
spaceId: 'default',
|
||||||
|
subject: 'Test Report-2025-06-04T00:00:00.000Z scheduled report',
|
||||||
|
to: ['test1@test.com'],
|
||||||
|
},
|
||||||
|
filename: 'Test Report-2025-06-04T00:00:00.000Z.pdf',
|
||||||
|
id: '290357209345723095',
|
||||||
|
index: '.reporting-fantastic',
|
||||||
|
relatedObject: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
namespace: 'default',
|
||||||
|
type: 'scheduled-report',
|
||||||
|
},
|
||||||
|
reporting: mockReporting,
|
||||||
|
});
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
'Error sending notification for scheduled report: This is a test error!'
|
||||||
|
);
|
||||||
|
expect(reportStore.setReportWarning).toHaveBeenCalledWith(savedReport, {
|
||||||
|
output: { content_type: 'application/pdf', size: 2097152 },
|
||||||
|
warning: 'Error sending notification for scheduled report: This is a test error!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs an error if there is an error thrown setting execution to warning', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(reportStore, 'setReportWarning')
|
||||||
|
.mockRejectedValueOnce('Error setting status to warning');
|
||||||
|
const task = new RunScheduledReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: configType,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
|
await task.init(mockTaskManager, emailNotificationService);
|
||||||
|
const taskInstance = {
|
||||||
|
id: 'task-id',
|
||||||
|
runAt: new Date('2025-06-04T00:00:00Z'),
|
||||||
|
params: { id: 'report-so-id', jobtype: 'test1' },
|
||||||
|
};
|
||||||
|
const byteSize = 11534336; // 11MB
|
||||||
|
const output = { content_type: 'application/pdf' };
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
await task.notify(savedReport, taskInstance, output, byteSize, scheduledReport, 'default');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(emailNotificationService.notify).not.toHaveBeenCalled();
|
||||||
|
expect(logger.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,201 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { KibanaRequest, SavedObject } from '@kbn/core/server';
|
||||||
|
import type { TaskRunResult } from '@kbn/reporting-common/types';
|
||||||
|
import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server';
|
||||||
|
|
||||||
|
import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils';
|
||||||
|
import {
|
||||||
|
SCHEDULED_REPORTING_EXECUTE_TYPE,
|
||||||
|
ScheduledReportTaskParams,
|
||||||
|
ScheduledReportTaskParamsWithoutSpaceId,
|
||||||
|
} from '.';
|
||||||
|
import type { SavedReport } from '../store';
|
||||||
|
import { errorLogger } from './error_logger';
|
||||||
|
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../saved_objects';
|
||||||
|
import { PrepareJobResults, RunReportTask } from './run_report';
|
||||||
|
import { ScheduledReport } from '../store/scheduled_report';
|
||||||
|
import { ScheduledReportType } from '../../types';
|
||||||
|
|
||||||
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10mb
|
||||||
|
|
||||||
|
type ScheduledReportTaskInstance = Omit<TaskInstance, 'params'> & {
|
||||||
|
params: Omit<ScheduledReportTaskParams, 'schedule'>;
|
||||||
|
};
|
||||||
|
export class RunScheduledReportTask extends RunReportTask<ScheduledReportTaskParams> {
|
||||||
|
public get TYPE() {
|
||||||
|
return SCHEDULED_REPORTING_EXECUTE_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise<PrepareJobResults> {
|
||||||
|
const { runAt, params: scheduledReportTaskParams } = taskInstance;
|
||||||
|
|
||||||
|
let report: SavedReport | undefined;
|
||||||
|
let jobId: string;
|
||||||
|
let scheduledReport: SavedObject<ScheduledReportType> | undefined;
|
||||||
|
const task = scheduledReportTaskParams as ScheduledReportTaskParams;
|
||||||
|
const scheduledReportId = task.id;
|
||||||
|
const reportSpaceId = task.spaceId || DEFAULT_SPACE_ID;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!scheduledReportId) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid scheduled_report saved object data provided in scheduled task! - No saved object with id "${scheduledReportId}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const internalSoClient = await this.opts.reporting.getInternalSoClient();
|
||||||
|
scheduledReport = await internalSoClient.get<ScheduledReportType>(
|
||||||
|
SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
scheduledReportId,
|
||||||
|
{ namespace: reportSpaceId }
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = await this.opts.reporting.getStore();
|
||||||
|
|
||||||
|
// Add the report to ReportingStore to show as processing
|
||||||
|
report = await store.addReport(
|
||||||
|
new ScheduledReport({
|
||||||
|
runAt,
|
||||||
|
kibanaId: this.kibanaId!,
|
||||||
|
kibanaName: this.kibanaName!,
|
||||||
|
queueTimeout: this.queueTimeout,
|
||||||
|
scheduledReport,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
jobId = report._id;
|
||||||
|
if (!jobId) {
|
||||||
|
throw new Error(`Unable to store report document in ReportingStore`);
|
||||||
|
}
|
||||||
|
} catch (failedToClaim) {
|
||||||
|
// error claiming report - log the error
|
||||||
|
errorLogger(
|
||||||
|
this.logger,
|
||||||
|
`Error in running scheduled report ${scheduledReportId}`,
|
||||||
|
failedToClaim
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLastAttempt: false,
|
||||||
|
jobId: jobId!,
|
||||||
|
report,
|
||||||
|
task: report?.toReportTaskJSON(),
|
||||||
|
scheduledReport,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getMaxAttempts() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async notify(
|
||||||
|
report: SavedReport,
|
||||||
|
taskInstance: ConcreteTaskInstance,
|
||||||
|
output: TaskRunResult,
|
||||||
|
byteSize: number,
|
||||||
|
scheduledReport?: SavedObject<ScheduledReportType>,
|
||||||
|
spaceId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { runAt, params } = taskInstance;
|
||||||
|
const task = params as ScheduledReportTaskParams;
|
||||||
|
if (!scheduledReport) {
|
||||||
|
const internalSoClient = await this.opts.reporting.getInternalSoClient();
|
||||||
|
scheduledReport = await internalSoClient.get<ScheduledReportType>(
|
||||||
|
SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
task.id,
|
||||||
|
{ namespace: spaceId }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notification } = scheduledReport.attributes;
|
||||||
|
if (notification && notification.email) {
|
||||||
|
if (byteSize > MAX_ATTACHMENT_SIZE) {
|
||||||
|
throw new Error('The report is larger than the 10MB limit.');
|
||||||
|
}
|
||||||
|
if (!this.emailNotificationService) {
|
||||||
|
throw new Error('Reporting notification service has not been initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = notification.email;
|
||||||
|
const title = scheduledReport.attributes.title;
|
||||||
|
const extension = this.getJobContentExtension(report.jobtype);
|
||||||
|
|
||||||
|
await this.emailNotificationService.notify({
|
||||||
|
reporting: this.opts.reporting,
|
||||||
|
index: report._index,
|
||||||
|
id: report._id,
|
||||||
|
filename: `${title}-${runAt.toISOString()}.${extension}`,
|
||||||
|
contentType: output.content_type,
|
||||||
|
relatedObject: {
|
||||||
|
id: scheduledReport.id,
|
||||||
|
type: scheduledReport.type,
|
||||||
|
namespace: spaceId,
|
||||||
|
},
|
||||||
|
emailParams: {
|
||||||
|
to: email.to,
|
||||||
|
cc: email.cc,
|
||||||
|
bcc: email.bcc,
|
||||||
|
subject: `${title}-${runAt.toISOString()} scheduled report`,
|
||||||
|
spaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = `Error sending notification for scheduled report: ${error.message}`;
|
||||||
|
this.saveExecutionWarning(
|
||||||
|
report,
|
||||||
|
{
|
||||||
|
...output,
|
||||||
|
size: byteSize,
|
||||||
|
},
|
||||||
|
message
|
||||||
|
).catch((failedToSaveWarning) => {
|
||||||
|
errorLogger(
|
||||||
|
this.logger,
|
||||||
|
`Error in saving execution warning ${report._id}`,
|
||||||
|
failedToSaveWarning
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getTaskDefinition() {
|
||||||
|
const queueTimeout = this.getQueueTimeout();
|
||||||
|
const maxConcurrency = this.getMaxConcurrency();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: SCHEDULED_REPORTING_EXECUTE_TYPE,
|
||||||
|
title: 'Reporting: execute scheduled job',
|
||||||
|
createTaskRunner: this.getTaskRunner(),
|
||||||
|
timeout: queueTimeout,
|
||||||
|
maxConcurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async scheduleTask(
|
||||||
|
request: KibanaRequest,
|
||||||
|
params: ScheduledReportTaskParamsWithoutSpaceId
|
||||||
|
) {
|
||||||
|
const spaceId = this.opts.reporting.getSpaceId(request, this.logger);
|
||||||
|
const taskInstance: ScheduledReportTaskInstance = {
|
||||||
|
id: params.id,
|
||||||
|
taskType: SCHEDULED_REPORTING_EXECUTE_TYPE,
|
||||||
|
state: {},
|
||||||
|
params: {
|
||||||
|
id: params.id,
|
||||||
|
spaceId: spaceId || DEFAULT_SPACE_ID,
|
||||||
|
jobtype: params.jobtype,
|
||||||
|
},
|
||||||
|
schedule: params.schedule,
|
||||||
|
};
|
||||||
|
return await this.getTaskManagerStart().schedule(taskInstance, { request });
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ import { cryptoFactory, type ExportType, type ReportingConfigType } from '@kbn/r
|
||||||
import type { RunContext } from '@kbn/task-manager-plugin/server';
|
import type { RunContext } from '@kbn/task-manager-plugin/server';
|
||||||
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
|
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
|
||||||
|
|
||||||
import { ExecuteReportTask, REPORTING_EXECUTE_TYPE } from '.';
|
import { RunSingleReportTask, REPORTING_EXECUTE_TYPE } from '.';
|
||||||
import { ReportingCore } from '../..';
|
import { ReportingCore } from '../..';
|
||||||
import { createMockReportingCore } from '../../test_helpers';
|
import { createMockReportingCore } from '../../test_helpers';
|
||||||
import { FakeRawRequest, KibanaRequest } from '@kbn/core/server';
|
import { FakeRawRequest, KibanaRequest } from '@kbn/core/server';
|
||||||
|
@ -104,7 +104,7 @@ const fakeRawRequest: FakeRawRequest = {
|
||||||
path: '/',
|
path: '/',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Execute Report Task', () => {
|
describe('Run Single Report Task', () => {
|
||||||
let mockReporting: ReportingCore;
|
let mockReporting: ReportingCore;
|
||||||
let configType: ReportingConfigType;
|
let configType: ReportingConfigType;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
@ -113,7 +113,7 @@ describe('Execute Report Task', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Instance setup', () => {
|
it('Instance setup', () => {
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
expect(task.getStatus()).toBe('uninitialized');
|
expect(task.getStatus()).toBe('uninitialized');
|
||||||
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
|
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
|
@ -129,7 +129,7 @@ describe('Execute Report Task', () => {
|
||||||
|
|
||||||
it('Instance start', () => {
|
it('Instance start', () => {
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
expect(task.init(mockTaskManager));
|
expect(task.init(mockTaskManager));
|
||||||
expect(task.getStatus()).toBe('initialized');
|
expect(task.getStatus()).toBe('initialized');
|
||||||
});
|
});
|
||||||
|
@ -138,7 +138,7 @@ describe('Execute Report Task', () => {
|
||||||
logger.info = jest.fn();
|
logger.info = jest.fn();
|
||||||
logger.error = jest.fn();
|
logger.error = jest.fn();
|
||||||
|
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
const taskDef = task.getTaskDefinition();
|
const taskDef = task.getTaskDefinition();
|
||||||
const taskRunner = taskDef.createTaskRunner({
|
const taskRunner = taskDef.createTaskRunner({
|
||||||
taskInstance: {
|
taskInstance: {
|
||||||
|
@ -155,7 +155,11 @@ describe('Execute Report Task', () => {
|
||||||
queue: { pollEnabled: false, timeout: 55000 },
|
queue: { pollEnabled: false, timeout: 55000 },
|
||||||
} as unknown as ReportingConfigType['queue'];
|
} as unknown as ReportingConfigType['queue'];
|
||||||
|
|
||||||
const task = new ExecuteReportTask(mockReporting, { ...configType, ...queueConfig }, logger);
|
const task = new RunSingleReportTask({
|
||||||
|
reporting: mockReporting,
|
||||||
|
config: { ...configType, ...queueConfig },
|
||||||
|
logger,
|
||||||
|
});
|
||||||
expect(task.getStatus()).toBe('uninitialized');
|
expect(task.getStatus()).toBe('uninitialized');
|
||||||
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
|
expect(task.getTaskDefinition()).toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
|
@ -175,7 +179,7 @@ describe('Execute Report Task', () => {
|
||||||
hasPermanentEncryptionKey: true,
|
hasPermanentEncryptionKey: true,
|
||||||
areNotificationsEnabled: true,
|
areNotificationsEnabled: true,
|
||||||
});
|
});
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
await task.init(mockTaskManager);
|
await task.init(mockTaskManager);
|
||||||
|
|
||||||
|
@ -208,7 +212,7 @@ describe('Execute Report Task', () => {
|
||||||
hasPermanentEncryptionKey: true,
|
hasPermanentEncryptionKey: true,
|
||||||
areNotificationsEnabled: false,
|
areNotificationsEnabled: false,
|
||||||
});
|
});
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
await task.init(mockTaskManager);
|
await task.init(mockTaskManager);
|
||||||
|
|
||||||
|
@ -238,7 +242,7 @@ describe('Execute Report Task', () => {
|
||||||
hasPermanentEncryptionKey: false,
|
hasPermanentEncryptionKey: false,
|
||||||
areNotificationsEnabled: true,
|
areNotificationsEnabled: true,
|
||||||
});
|
});
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
await task.init(mockTaskManager);
|
await task.init(mockTaskManager);
|
||||||
|
|
||||||
|
@ -275,14 +279,14 @@ describe('Execute Report Task', () => {
|
||||||
jobType: 'test1',
|
jobType: 'test1',
|
||||||
validLicenses: [],
|
validLicenses: [],
|
||||||
} as unknown as ExportType);
|
} as unknown as ExportType);
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
jest
|
jest
|
||||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||||
.spyOn(task, '_claimJob')
|
.spyOn(task, 'claimJob')
|
||||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
|
||||||
jest
|
jest
|
||||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||||
.spyOn(task, '_completeJob')
|
.spyOn(task, 'completeJob')
|
||||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
await task.init(mockTaskManager);
|
await task.init(mockTaskManager);
|
||||||
|
@ -320,14 +324,14 @@ describe('Execute Report Task', () => {
|
||||||
jobType: 'test2',
|
jobType: 'test2',
|
||||||
validLicenses: [],
|
validLicenses: [],
|
||||||
} as unknown as ExportType);
|
} as unknown as ExportType);
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
jest
|
jest
|
||||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||||
.spyOn(task, '_claimJob')
|
.spyOn(task, 'claimJob')
|
||||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
|
||||||
jest
|
jest
|
||||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||||
.spyOn(task, '_completeJob')
|
.spyOn(task, 'completeJob')
|
||||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
await task.init(mockTaskManager);
|
await task.init(mockTaskManager);
|
||||||
|
@ -367,14 +371,14 @@ describe('Execute Report Task', () => {
|
||||||
jobType: 'test3',
|
jobType: 'test3',
|
||||||
validLicenses: [],
|
validLicenses: [],
|
||||||
} as unknown as ExportType);
|
} as unknown as ExportType);
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
jest
|
jest
|
||||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||||
.spyOn(task, '_claimJob')
|
.spyOn(task, 'claimJob')
|
||||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
|
||||||
jest
|
jest
|
||||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||||
.spyOn(task, '_completeJob')
|
.spyOn(task, 'completeJob')
|
||||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
await task.init(mockTaskManager);
|
await task.init(mockTaskManager);
|
||||||
|
@ -421,10 +425,10 @@ describe('Execute Report Task', () => {
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
} as unknown as estypes.UpdateUpdateWriteResponseBase<ReportDocument>)
|
} as unknown as estypes.UpdateUpdateWriteResponseBase<ReportDocument>)
|
||||||
);
|
);
|
||||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||||
jest
|
jest
|
||||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||||
.spyOn(task, '_claimJob')
|
.spyOn(task, 'claimJob')
|
||||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'noop', status: 'pending' } as never);
|
.mockResolvedValueOnce({ _id: 'test', jobtype: 'noop', status: 'pending' } as never);
|
||||||
const mockTaskManager = taskManagerMock.createStart();
|
const mockTaskManager = taskManagerMock.createStart();
|
||||||
await task.init(mockTaskManager);
|
await task.init(mockTaskManager);
|
|
@ -0,0 +1,160 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import moment from 'moment';
|
||||||
|
import type { KibanaRequest } from '@kbn/core/server';
|
||||||
|
import { QueueTimeoutError, ReportingError } from '@kbn/reporting-common';
|
||||||
|
import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server';
|
||||||
|
|
||||||
|
import { REPORTING_EXECUTE_TYPE, ReportTaskParams } from '.';
|
||||||
|
import {
|
||||||
|
isExecutionError,
|
||||||
|
mapToReportingError,
|
||||||
|
} from '../../../common/errors/map_to_reporting_error';
|
||||||
|
import { SavedReport } from '../store';
|
||||||
|
import type { ReportProcessingFields } from '../store/store';
|
||||||
|
import { errorLogger } from './error_logger';
|
||||||
|
import { PrepareJobResults, RunReportTask } from './run_report';
|
||||||
|
|
||||||
|
type SingleReportTaskInstance = Omit<TaskInstance, 'params'> & {
|
||||||
|
params: ReportTaskParams;
|
||||||
|
};
|
||||||
|
export class RunSingleReportTask extends RunReportTask<ReportTaskParams> {
|
||||||
|
public get TYPE() {
|
||||||
|
return REPORTING_EXECUTE_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claimJob(task: ReportTaskParams): Promise<SavedReport> {
|
||||||
|
const store = await this.opts.reporting.getStore();
|
||||||
|
const report = await store.findReportFromTask(task); // receives seq_no and primary_term
|
||||||
|
const logger = this.logger.get(report._id);
|
||||||
|
|
||||||
|
if (report.status === 'completed') {
|
||||||
|
throw new Error(`Can not claim the report job: it is already completed!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = moment();
|
||||||
|
|
||||||
|
// check if job has exceeded the configured maxAttempts
|
||||||
|
const maxAttempts = this.getMaxAttempts();
|
||||||
|
if (report.attempts >= maxAttempts) {
|
||||||
|
let err: ReportingError;
|
||||||
|
if (report.error && isExecutionError(report.error)) {
|
||||||
|
// We have an error stored from a previous attempts, so we'll use that
|
||||||
|
// error to fail the job and return it to the user.
|
||||||
|
const { error } = report;
|
||||||
|
err = mapToReportingError(error);
|
||||||
|
err.stack = error.stack;
|
||||||
|
} else {
|
||||||
|
if (report.error && report.error instanceof Error) {
|
||||||
|
errorLogger(logger, 'Error executing report', report.error);
|
||||||
|
}
|
||||||
|
err = new QueueTimeoutError(
|
||||||
|
`Max attempts reached (${maxAttempts}). Queue timeout reached.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.failJob(report, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = m.toISOString();
|
||||||
|
const expirationTime = m.add(this.queueTimeout).toISOString();
|
||||||
|
|
||||||
|
const doc: ReportProcessingFields = {
|
||||||
|
kibana_id: this.kibanaId,
|
||||||
|
kibana_name: this.kibanaName,
|
||||||
|
attempts: report.attempts + 1,
|
||||||
|
max_attempts: maxAttempts,
|
||||||
|
started_at: startTime,
|
||||||
|
timeout: this.queueTimeout,
|
||||||
|
process_expiration: expirationTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimedReport = new SavedReport({ ...report, ...doc });
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Claiming ${claimedReport.jobtype} ${report._id} ` +
|
||||||
|
`[_index: ${report._index}] ` +
|
||||||
|
`[_seq_no: ${report._seq_no}] ` +
|
||||||
|
`[_primary_term: ${report._primary_term}] ` +
|
||||||
|
`[attempts: ${report.attempts}] ` +
|
||||||
|
`[process_expiration: ${expirationTime}]`
|
||||||
|
);
|
||||||
|
|
||||||
|
// event tracking of claimed job
|
||||||
|
const eventTracker = this.getEventTracker(report);
|
||||||
|
const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf();
|
||||||
|
eventTracker?.claimJob({ timeSinceCreation });
|
||||||
|
|
||||||
|
const resp = await store.setReportClaimed(claimedReport, doc);
|
||||||
|
claimedReport._seq_no = resp._seq_no!;
|
||||||
|
claimedReport._primary_term = resp._primary_term!;
|
||||||
|
return claimedReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise<PrepareJobResults> {
|
||||||
|
const { attempts: taskAttempts, params: reportTaskParams } = taskInstance;
|
||||||
|
|
||||||
|
let report: SavedReport | undefined;
|
||||||
|
const isLastAttempt = taskAttempts >= this.getMaxAttempts();
|
||||||
|
|
||||||
|
// find the job in the store and set status to processing
|
||||||
|
const task = reportTaskParams as ReportTaskParams;
|
||||||
|
const jobId = task?.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!jobId) {
|
||||||
|
throw new Error('Invalid report data provided in scheduled task!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update job status to claimed
|
||||||
|
report = await this.claimJob(task);
|
||||||
|
} catch (failedToClaim) {
|
||||||
|
// error claiming report - log the error
|
||||||
|
// could be version conflict, or too many attempts or no longer connected to ES
|
||||||
|
errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isLastAttempt, jobId, report, task };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getMaxAttempts() {
|
||||||
|
return this.opts.config.capture.maxAttempts ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async notify(): Promise<void> {}
|
||||||
|
|
||||||
|
public getTaskDefinition() {
|
||||||
|
const queueTimeout = this.getQueueTimeout();
|
||||||
|
const maxConcurrency = this.getMaxConcurrency();
|
||||||
|
const maxAttempts = this.getMaxAttempts();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: REPORTING_EXECUTE_TYPE,
|
||||||
|
title: 'Reporting: execute job',
|
||||||
|
createTaskRunner: this.getTaskRunner(),
|
||||||
|
maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart
|
||||||
|
timeout: queueTimeout,
|
||||||
|
maxConcurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) {
|
||||||
|
const reportingHealth = await this.opts.reporting.getHealthInfo();
|
||||||
|
const shouldScheduleWithApiKey =
|
||||||
|
reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure;
|
||||||
|
const taskInstance: SingleReportTaskInstance = {
|
||||||
|
taskType: REPORTING_EXECUTE_TYPE,
|
||||||
|
state: {},
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
|
||||||
|
return shouldScheduleWithApiKey
|
||||||
|
? await this.getTaskManagerStart().schedule(taskInstance, { request })
|
||||||
|
: await this.getTaskManagerStart().schedule(taskInstance);
|
||||||
|
}
|
||||||
|
}
|
|
@ -76,6 +76,21 @@ describe('Reporting Plugin', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers a saved object for scheduled reports', async () => {
|
||||||
|
plugin.setup(coreSetup, pluginSetup);
|
||||||
|
expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'scheduled_report',
|
||||||
|
namespaceType: 'multiple',
|
||||||
|
hidden: true,
|
||||||
|
indexPattern: '.kibana_alerting_cases',
|
||||||
|
management: {
|
||||||
|
importableAndExportable: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('logs start issues', async () => {
|
it('logs start issues', async () => {
|
||||||
// wait for the setup phase background work
|
// wait for the setup phase background work
|
||||||
plugin.setup(coreSetup, pluginSetup);
|
plugin.setup(coreSetup, pluginSetup);
|
||||||
|
@ -168,21 +183,37 @@ describe('Reporting Plugin', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('features registration', () => {
|
describe('features registration', () => {
|
||||||
it('does not register Kibana reporting feature in traditional build flavour', async () => {
|
it('registers Kibana manage scheduled reporting feature in traditional build flavour', async () => {
|
||||||
plugin.setup(coreSetup, pluginSetup);
|
plugin.setup(coreSetup, pluginSetup);
|
||||||
expect(featuresSetup.registerKibanaFeature).not.toHaveBeenCalled();
|
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1);
|
||||||
|
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
|
||||||
|
id: 'manageReporting',
|
||||||
|
name: 'Manage Scheduled Reports',
|
||||||
|
description: 'View and manage scheduled reports for all users in this space.',
|
||||||
|
category: DEFAULT_APP_CATEGORIES.management,
|
||||||
|
scope: ['spaces', 'security'],
|
||||||
|
app: [],
|
||||||
|
privileges: {
|
||||||
|
all: {
|
||||||
|
api: ['manage_scheduled_reports'],
|
||||||
|
savedObject: { all: ['scheduled_report'], read: [] },
|
||||||
|
ui: ['show'],
|
||||||
|
},
|
||||||
|
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
|
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers Kibana reporting feature in serverless build flavour', async () => {
|
it('registers additional Kibana reporting feature in serverless build flavour', async () => {
|
||||||
const serverlessInitContext = coreMock.createPluginInitializerContext(configSchema);
|
const serverlessInitContext = coreMock.createPluginInitializerContext(configSchema);
|
||||||
// Force type-cast to convert `ReadOnly<PackageInfo>` to mutable `PackageInfo`.
|
// Force type-cast to convert `ReadOnly<PackageInfo>` to mutable `PackageInfo`.
|
||||||
(serverlessInitContext.env.packageInfo as PackageInfo).buildFlavor = 'serverless';
|
(serverlessInitContext.env.packageInfo as PackageInfo).buildFlavor = 'serverless';
|
||||||
plugin = new ReportingPlugin(serverlessInitContext);
|
plugin = new ReportingPlugin(serverlessInitContext);
|
||||||
|
|
||||||
plugin.setup(coreSetup, pluginSetup);
|
plugin.setup(coreSetup, pluginSetup);
|
||||||
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1);
|
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(2);
|
||||||
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
|
expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(1, {
|
||||||
id: 'reporting',
|
id: 'reporting',
|
||||||
name: 'Reporting',
|
name: 'Reporting',
|
||||||
category: DEFAULT_APP_CATEGORIES.management,
|
category: DEFAULT_APP_CATEGORIES.management,
|
||||||
|
@ -193,6 +224,22 @@ describe('Reporting Plugin', () => {
|
||||||
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
|
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(2, {
|
||||||
|
id: 'manageReporting',
|
||||||
|
name: 'Manage Scheduled Reports',
|
||||||
|
description: 'View and manage scheduled reports for all users in this space.',
|
||||||
|
category: DEFAULT_APP_CATEGORIES.management,
|
||||||
|
scope: ['spaces', 'security'],
|
||||||
|
app: [],
|
||||||
|
privileges: {
|
||||||
|
all: {
|
||||||
|
api: ['manage_scheduled_reports'],
|
||||||
|
savedObject: { all: ['scheduled_report'], read: [] },
|
||||||
|
ui: ['show'],
|
||||||
|
},
|
||||||
|
read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
|
expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -27,6 +27,7 @@ import type {
|
||||||
import { ReportingRequestHandlerContext } from './types';
|
import { ReportingRequestHandlerContext } from './types';
|
||||||
import { registerReportingEventTypes, registerReportingUsageCollector } from './usage';
|
import { registerReportingEventTypes, registerReportingUsageCollector } from './usage';
|
||||||
import { registerFeatures } from './features';
|
import { registerFeatures } from './features';
|
||||||
|
import { setupSavedObjects } from './saved_objects';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @internal
|
* @internal
|
||||||
|
@ -75,6 +76,9 @@ export class ReportingPlugin
|
||||||
registerReportingUsageCollector(reportingCore, plugins.usageCollection);
|
registerReportingUsageCollector(reportingCore, plugins.usageCollection);
|
||||||
registerReportingEventTypes(core);
|
registerReportingEventTypes(core);
|
||||||
|
|
||||||
|
// Saved objects
|
||||||
|
setupSavedObjects(core.savedObjects);
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
registerRoutes(reportingCore, this.logger);
|
registerRoutes(reportingCore, this.logger);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EcsEvent } from '@kbn/core/server';
|
||||||
|
import type { AuditEvent } from '@kbn/security-plugin/server';
|
||||||
|
import type { ArrayElement } from '@kbn/utility-types';
|
||||||
|
|
||||||
|
export enum ScheduledReportAuditAction {
|
||||||
|
SCHEDULE = 'scheduled_report_schedule',
|
||||||
|
LIST = 'scheduled_report_list',
|
||||||
|
DISABLE = 'scheduled_report_disable',
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerbsTuple = [string, string, string];
|
||||||
|
|
||||||
|
const scheduledReportEventVerbs: Record<ScheduledReportAuditAction, VerbsTuple> = {
|
||||||
|
scheduled_report_schedule: ['create', 'creating', 'created'],
|
||||||
|
scheduled_report_list: ['access', 'accessing', 'accessed'],
|
||||||
|
scheduled_report_disable: ['disable', 'disabling', 'disabled'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduledReportEventTypes: Record<
|
||||||
|
ScheduledReportAuditAction,
|
||||||
|
ArrayElement<EcsEvent['type']>
|
||||||
|
> = {
|
||||||
|
scheduled_report_schedule: 'creation',
|
||||||
|
scheduled_report_list: 'access',
|
||||||
|
scheduled_report_disable: 'change',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ScheduledReportAuditEventParams {
|
||||||
|
action: ScheduledReportAuditAction;
|
||||||
|
outcome?: EcsEvent['outcome'];
|
||||||
|
savedObject?: NonNullable<AuditEvent['kibana']>['saved_object'];
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduledReportAuditEvent({
|
||||||
|
action,
|
||||||
|
savedObject,
|
||||||
|
outcome,
|
||||||
|
error,
|
||||||
|
}: ScheduledReportAuditEventParams): AuditEvent {
|
||||||
|
const doc = savedObject
|
||||||
|
? [`scheduled report [id=${savedObject.id}]`, savedObject.name && `[name=${savedObject.name}]`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
: 'a scheduled report';
|
||||||
|
|
||||||
|
const [present, progressive, past] = scheduledReportEventVerbs[action];
|
||||||
|
const message = error
|
||||||
|
? `Failed attempt to ${present} ${doc}`
|
||||||
|
: outcome === 'unknown'
|
||||||
|
? `User is ${progressive} ${doc}`
|
||||||
|
: `User has ${past} ${doc}`;
|
||||||
|
const type = scheduledReportEventTypes[action];
|
||||||
|
|
||||||
|
return {
|
||||||
|
message,
|
||||||
|
event: {
|
||||||
|
action,
|
||||||
|
category: ['database'],
|
||||||
|
type: type ? [type] : undefined,
|
||||||
|
outcome: outcome ?? (error ? 'failure' : 'success'),
|
||||||
|
},
|
||||||
|
kibana: {
|
||||||
|
saved_object: savedObject,
|
||||||
|
},
|
||||||
|
error: error && {
|
||||||
|
code: error.name,
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,264 +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
|
|
||||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
|
||||||
* 2.0.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Boom from '@hapi/boom';
|
|
||||||
import moment from 'moment';
|
|
||||||
|
|
||||||
import { schema, TypeOf } from '@kbn/config-schema';
|
|
||||||
import type { KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server';
|
|
||||||
import { i18n } from '@kbn/i18n';
|
|
||||||
import { PUBLIC_ROUTES } from '@kbn/reporting-common';
|
|
||||||
import type { BaseParams } from '@kbn/reporting-common/types';
|
|
||||||
import { cryptoFactory } from '@kbn/reporting-server';
|
|
||||||
import rison from '@kbn/rison';
|
|
||||||
|
|
||||||
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
|
||||||
import { type Counters, getCounters } from '..';
|
|
||||||
import type { ReportingCore } from '../../..';
|
|
||||||
import { checkParamsVersion } from '../../../lib';
|
|
||||||
import { Report } from '../../../lib/store';
|
|
||||||
import type {
|
|
||||||
ReportingJobResponse,
|
|
||||||
ReportingRequestHandlerContext,
|
|
||||||
ReportingUser,
|
|
||||||
} from '../../../types';
|
|
||||||
|
|
||||||
export const handleUnavailable = (res: KibanaResponseFactory) => {
|
|
||||||
return res.custom({ statusCode: 503, body: 'Not Available' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const validation = {
|
|
||||||
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: '' }) })),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the common parts of requests to generate a report
|
|
||||||
* Serves report job handling in the context of the request to generate the report
|
|
||||||
*/
|
|
||||||
export class RequestHandler {
|
|
||||||
constructor(
|
|
||||||
private reporting: ReportingCore,
|
|
||||||
private user: ReportingUser,
|
|
||||||
private context: ReportingRequestHandlerContext,
|
|
||||||
private path: string,
|
|
||||||
private req: KibanaRequest<
|
|
||||||
TypeOf<(typeof validation)['params']>,
|
|
||||||
TypeOf<(typeof validation)['query']>,
|
|
||||||
TypeOf<(typeof validation)['body']>
|
|
||||||
>,
|
|
||||||
private res: KibanaResponseFactory,
|
|
||||||
private logger: Logger
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private async encryptHeaders() {
|
|
||||||
const { encryptionKey } = this.reporting.getConfig();
|
|
||||||
const crypto = cryptoFactory(encryptionKey);
|
|
||||||
return await crypto.encrypt(this.req.headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async enqueueJob(exportTypeId: string, jobParams: BaseParams) {
|
|
||||||
const { reporting, logger, context, req, user } = this;
|
|
||||||
|
|
||||||
const exportType = reporting.getExportTypesRegistry().getById(exportTypeId);
|
|
||||||
|
|
||||||
if (exportType == null) {
|
|
||||||
throw new Error(`Export type ${exportTypeId} does not exist in the registry!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = await reporting.getStore();
|
|
||||||
|
|
||||||
if (!exportType.createJob) {
|
|
||||||
throw new Error(`Export type ${exportTypeId} is not a valid instance!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Ensure the incoming params have a version field (should be set by the UI)
|
|
||||||
jobParams.version = checkParamsVersion(jobParams, logger);
|
|
||||||
|
|
||||||
// 2. Encrypt request headers to store for the running report job to authenticate itself with Kibana
|
|
||||||
const headers = await this.encryptHeaders();
|
|
||||||
|
|
||||||
// 3. Create a payload object by calling exportType.createJob(), and adding some automatic parameters
|
|
||||||
const job = await exportType.createJob(jobParams, context, req);
|
|
||||||
|
|
||||||
const spaceId = reporting.getSpaceId(req, logger);
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
...job,
|
|
||||||
headers,
|
|
||||||
title: job.title,
|
|
||||||
objectType: jobParams.objectType,
|
|
||||||
browserTimezone: jobParams.browserTimezone,
|
|
||||||
version: jobParams.version,
|
|
||||||
spaceId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Add the report to ReportingStore to show as pending
|
|
||||||
const report = await store.addReport(
|
|
||||||
new Report({
|
|
||||||
jobtype: exportType.jobType,
|
|
||||||
created_by: user ? user.username : false,
|
|
||||||
payload,
|
|
||||||
migration_version: jobParams.version,
|
|
||||||
space_id: spaceId || DEFAULT_SPACE_ID,
|
|
||||||
meta: {
|
|
||||||
// telemetry fields
|
|
||||||
objectType: jobParams.objectType,
|
|
||||||
layout: jobParams.layout?.id,
|
|
||||||
isDeprecated: job.isDeprecated,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`);
|
|
||||||
|
|
||||||
// 5. Schedule the report with Task Manager
|
|
||||||
const task = await reporting.scheduleTask(req, report.toReportTaskJSON());
|
|
||||||
logger.info(
|
|
||||||
`Scheduled ${exportType.name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 6. Log the action with event log
|
|
||||||
reporting.getEventLogger(report, task).logScheduleTask();
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getJobParams(): BaseParams {
|
|
||||||
let jobParamsRison: null | string = null;
|
|
||||||
const req = this.req;
|
|
||||||
const res = this.res;
|
|
||||||
|
|
||||||
if (req.body) {
|
|
||||||
const { jobParams: jobParamsPayload } = req.body;
|
|
||||||
jobParamsRison = jobParamsPayload ? jobParamsPayload : null;
|
|
||||||
} else if (req.query?.jobParams) {
|
|
||||||
const { jobParams: queryJobParams } = req.query;
|
|
||||||
if (queryJobParams) {
|
|
||||||
jobParamsRison = queryJobParams;
|
|
||||||
} else {
|
|
||||||
jobParamsRison = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!jobParamsRison) {
|
|
||||||
throw res.customError({
|
|
||||||
statusCode: 400,
|
|
||||||
body: 'A jobParams RISON string is required in the querystring or POST body',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let jobParams;
|
|
||||||
|
|
||||||
try {
|
|
||||||
jobParams = rison.decode(jobParamsRison) as BaseParams | null;
|
|
||||||
if (!jobParams) {
|
|
||||||
throw res.customError({
|
|
||||||
statusCode: 400,
|
|
||||||
body: 'Missing jobParams!',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
throw res.customError({
|
|
||||||
statusCode: 400,
|
|
||||||
body: `invalid rison: ${jobParamsRison}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return jobParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static getValidation() {
|
|
||||||
return validation;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleGenerateRequest(exportTypeId: string, jobParams: BaseParams) {
|
|
||||||
const req = this.req;
|
|
||||||
const reporting = this.reporting;
|
|
||||||
|
|
||||||
const counters = getCounters(
|
|
||||||
req.route.method,
|
|
||||||
this.path.replace(/{exportType}/, exportTypeId),
|
|
||||||
reporting.getUsageCounter()
|
|
||||||
);
|
|
||||||
|
|
||||||
// ensure the async dependencies are loaded
|
|
||||||
if (!this.context.reporting) {
|
|
||||||
return handleUnavailable(this.res);
|
|
||||||
}
|
|
||||||
|
|
||||||
const licenseInfo = await this.reporting.getLicenseInfo();
|
|
||||||
const licenseResults = licenseInfo[exportTypeId];
|
|
||||||
|
|
||||||
if (!licenseResults) {
|
|
||||||
return this.res.badRequest({ body: `Invalid export-type of ${exportTypeId}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!licenseResults.enableLinks) {
|
|
||||||
return this.res.forbidden({ body: licenseResults.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jobParams.browserTimezone && !moment.tz.zone(jobParams.browserTimezone)) {
|
|
||||||
return this.res.badRequest({
|
|
||||||
body: `Invalid timezone "${jobParams.browserTimezone ?? ''}".`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let report: Report | undefined;
|
|
||||||
try {
|
|
||||||
report = await this.enqueueJob(exportTypeId, jobParams);
|
|
||||||
const { basePath } = this.reporting.getServerInfo();
|
|
||||||
const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX;
|
|
||||||
|
|
||||||
// return task manager's task information and the download URL
|
|
||||||
counters.usageCounter();
|
|
||||||
const eventTracker = reporting.getEventTracker(
|
|
||||||
report._id,
|
|
||||||
exportTypeId,
|
|
||||||
jobParams.objectType
|
|
||||||
);
|
|
||||||
eventTracker?.createReport({
|
|
||||||
isDeprecated: Boolean(report.payload.isDeprecated),
|
|
||||||
isPublicApi: this.path.match(/internal/) === null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.res.ok<ReportingJobResponse>({
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: {
|
|
||||||
path: `${publicDownloadPath}/${report._id}`,
|
|
||||||
job: report.toApiJSON(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return this.handleError(err, counters, report?.jobtype);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleError(err: Error | Boom.Boom, counters: Counters, jobtype?: string) {
|
|
||||||
this.logger.error(err);
|
|
||||||
|
|
||||||
if (err instanceof Boom.Boom) {
|
|
||||||
const statusCode = err.output.statusCode;
|
|
||||||
counters?.errorCounter(jobtype, statusCode);
|
|
||||||
|
|
||||||
return this.res.customError({
|
|
||||||
statusCode,
|
|
||||||
body: err.output.payload.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
counters?.errorCounter(jobtype, 500);
|
|
||||||
|
|
||||||
return this.res.customError({
|
|
||||||
statusCode: 500,
|
|
||||||
body:
|
|
||||||
err?.message ||
|
|
||||||
i18n.translate('xpack.reporting.errorHandler.unknownError', {
|
|
||||||
defaultMessage: 'Unknown error',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,7 +12,7 @@ import { getCounters } from '..';
|
||||||
import { ReportingCore } from '../../..';
|
import { ReportingCore } from '../../..';
|
||||||
import { getContentStream } from '../../../lib';
|
import { getContentStream } from '../../../lib';
|
||||||
import { ReportingRequestHandlerContext, ReportingUser } from '../../../types';
|
import { ReportingRequestHandlerContext, ReportingUser } from '../../../types';
|
||||||
import { handleUnavailable } from '../generate';
|
import { handleUnavailable } from '../request_handler';
|
||||||
import { jobManagementPreRouting } from './job_management_pre_routing';
|
import { jobManagementPreRouting } from './job_management_pre_routing';
|
||||||
import { jobsQueryFactory } from './jobs_query';
|
import { jobsQueryFactory } from './jobs_query';
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ import {
|
||||||
ReportingRequestHandlerContext,
|
ReportingRequestHandlerContext,
|
||||||
ReportingSetup,
|
ReportingSetup,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import { RequestHandler } from './request_handler';
|
import { GenerateRequestHandler } from './generate_request_handler';
|
||||||
|
|
||||||
jest.mock('@kbn/reporting-server/crypto', () => ({
|
jest.mock('@kbn/reporting-server/crypto', () => ({
|
||||||
cryptoFactory: () => ({
|
cryptoFactory: () => ({
|
||||||
|
@ -68,7 +68,7 @@ describe('Handle request to generate', () => {
|
||||||
let mockContext: ReturnType<typeof getMockContext>;
|
let mockContext: ReturnType<typeof getMockContext>;
|
||||||
let mockRequest: ReturnType<typeof getMockRequest>;
|
let mockRequest: ReturnType<typeof getMockRequest>;
|
||||||
let mockResponseFactory: ReturnType<typeof getMockResponseFactory>;
|
let mockResponseFactory: ReturnType<typeof getMockResponseFactory>;
|
||||||
let requestHandler: RequestHandler;
|
let requestHandler: GenerateRequestHandler;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
reportingCore = await createMockReportingCore(createMockConfigSchema({}));
|
reportingCore = await createMockReportingCore(createMockConfigSchema({}));
|
||||||
|
@ -91,20 +91,23 @@ describe('Handle request to generate', () => {
|
||||||
mockContext = getMockContext();
|
mockContext = getMockContext();
|
||||||
mockContext.reporting = Promise.resolve({} as ReportingSetup);
|
mockContext.reporting = Promise.resolve({} as ReportingSetup);
|
||||||
|
|
||||||
requestHandler = new RequestHandler(
|
requestHandler = new GenerateRequestHandler({
|
||||||
reportingCore,
|
reporting: reportingCore,
|
||||||
{ username: 'testymcgee' },
|
user: { username: 'testymcgee' },
|
||||||
mockContext,
|
context: mockContext,
|
||||||
'/api/reporting/test/generate/pdf',
|
path: '/api/reporting/test/generate/pdf',
|
||||||
mockRequest,
|
req: mockRequest,
|
||||||
mockResponseFactory,
|
res: mockResponseFactory,
|
||||||
mockLogger
|
logger: mockLogger,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Enqueue Job', () => {
|
describe('Enqueue Job', () => {
|
||||||
test('creates a report object to queue', async () => {
|
test('creates a report object to queue', async () => {
|
||||||
const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams);
|
const report = await requestHandler.enqueueJob({
|
||||||
|
exportTypeId: 'printablePdfV2',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
});
|
||||||
|
|
||||||
const { _id, created_at: _created_at, payload, ...snapObj } = report;
|
const { _id, created_at: _created_at, payload, ...snapObj } = report;
|
||||||
expect(snapObj).toMatchInlineSnapshot(`
|
expect(snapObj).toMatchInlineSnapshot(`
|
||||||
|
@ -131,6 +134,7 @@ describe('Handle request to generate', () => {
|
||||||
"output": null,
|
"output": null,
|
||||||
"process_expiration": undefined,
|
"process_expiration": undefined,
|
||||||
"queue_time_ms": undefined,
|
"queue_time_ms": undefined,
|
||||||
|
"scheduled_report_id": undefined,
|
||||||
"space_id": "default",
|
"space_id": "default",
|
||||||
"started_at": undefined,
|
"started_at": undefined,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
|
@ -158,7 +162,10 @@ describe('Handle request to generate', () => {
|
||||||
test('provides a default kibana version field for older POST URLs', async () => {
|
test('provides a default kibana version field for older POST URLs', async () => {
|
||||||
// how do we handle the printable_pdf endpoint that isn't migrating to the class instance of export types?
|
// how do we handle the printable_pdf endpoint that isn't migrating to the class instance of export types?
|
||||||
(mockJobParams as unknown as { version?: string }).version = undefined;
|
(mockJobParams as unknown as { version?: string }).version = undefined;
|
||||||
const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams);
|
const report = await requestHandler.enqueueJob({
|
||||||
|
exportTypeId: 'printablePdfV2',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
});
|
||||||
|
|
||||||
const { _id, created_at: _created_at, ...snapObj } = report;
|
const { _id, created_at: _created_at, ...snapObj } = report;
|
||||||
expect(snapObj.payload.version).toBe('7.14.0');
|
expect(snapObj.payload.version).toBe('7.14.0');
|
||||||
|
@ -207,10 +214,14 @@ describe('Handle request to generate', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleGenerateRequest', () => {
|
describe('handleRequest', () => {
|
||||||
test('disallows invalid export type', async () => {
|
test('disallows invalid export type', async () => {
|
||||||
expect(await requestHandler.handleGenerateRequest('neanderthals', mockJobParams))
|
expect(
|
||||||
.toMatchInlineSnapshot(`
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'neanderthals',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
"body": "Invalid export-type of neanderthals",
|
"body": "Invalid export-type of neanderthals",
|
||||||
}
|
}
|
||||||
|
@ -225,26 +236,10 @@ describe('Handle request to generate', () => {
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(await requestHandler.handleGenerateRequest('csv_searchsource', mockJobParams))
|
|
||||||
.toMatchInlineSnapshot(`
|
|
||||||
Object {
|
|
||||||
"body": "seeing this means the license isn't supported",
|
|
||||||
}
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('disallows invalid browser timezone', async () => {
|
|
||||||
(reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({
|
|
||||||
csv_searchsource: {
|
|
||||||
enableLinks: false,
|
|
||||||
message: `seeing this means the license isn't supported`,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await requestHandler.handleGenerateRequest('csv_searchsource', {
|
await requestHandler.handleRequest({
|
||||||
...mockJobParams,
|
exportTypeId: 'csv_searchsource',
|
||||||
browserTimezone: 'America/Amsterdam',
|
jobParams: mockJobParams,
|
||||||
})
|
})
|
||||||
).toMatchInlineSnapshot(`
|
).toMatchInlineSnapshot(`
|
||||||
Object {
|
Object {
|
||||||
|
@ -253,11 +248,27 @@ describe('Handle request to generate', () => {
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('disallows invalid browser timezone', async () => {
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'csv_searchsource',
|
||||||
|
jobParams: {
|
||||||
|
...mockJobParams,
|
||||||
|
browserTimezone: 'America/Amsterdam',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "Invalid timezone \\"America/Amsterdam\\".",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
test('generates the download path', async () => {
|
test('generates the download path', async () => {
|
||||||
const { body } = (await requestHandler.handleGenerateRequest(
|
const { body } = (await requestHandler.handleRequest({
|
||||||
'csv_searchsource',
|
exportTypeId: 'csv_searchsource',
|
||||||
mockJobParams
|
jobParams: mockJobParams,
|
||||||
)) as unknown as { body: ReportingJobResponse };
|
})) as unknown as { body: ReportingJobResponse };
|
||||||
|
|
||||||
expect(body.path).toMatch('/mock-server-basepath/api/reporting/jobs/download/mock-report-id');
|
expect(body.path).toMatch('/mock-server-basepath/api/reporting/jobs/download/mock-report-id');
|
||||||
});
|
});
|
|
@ -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
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { schema } from '@kbn/config-schema';
|
||||||
|
import { PUBLIC_ROUTES } from '@kbn/reporting-common';
|
||||||
|
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
||||||
|
import { getCounters } from '..';
|
||||||
|
import { Report, SavedReport } from '../../../lib/store';
|
||||||
|
import type { ReportingJobResponse } from '../../../types';
|
||||||
|
import { RequestHandler, RequestParams } from './request_handler';
|
||||||
|
|
||||||
|
const validation = {
|
||||||
|
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: '' }) })),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the common parts of requests to generate a report
|
||||||
|
* Serves report job handling in the context of the request to generate the report
|
||||||
|
*/
|
||||||
|
export class GenerateRequestHandler extends RequestHandler<
|
||||||
|
(typeof validation)['params'],
|
||||||
|
(typeof validation)['query'],
|
||||||
|
(typeof validation)['body'],
|
||||||
|
SavedReport
|
||||||
|
> {
|
||||||
|
public static getValidation() {
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enqueueJob(params: RequestParams) {
|
||||||
|
const { exportTypeId, jobParams } = params;
|
||||||
|
const { reporting, logger, req, user } = this.opts;
|
||||||
|
|
||||||
|
const store = await reporting.getStore();
|
||||||
|
const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams);
|
||||||
|
|
||||||
|
const spaceId = reporting.getSpaceId(req, logger);
|
||||||
|
|
||||||
|
// Encrypt request headers to store for the running report job to authenticate itself with Kibana
|
||||||
|
const headers = await this.encryptHeaders();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...job,
|
||||||
|
headers,
|
||||||
|
title: job.title,
|
||||||
|
objectType: jobParams.objectType,
|
||||||
|
browserTimezone: jobParams.browserTimezone,
|
||||||
|
version,
|
||||||
|
spaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the report to ReportingStore to show as pending
|
||||||
|
const report = await store.addReport(
|
||||||
|
new Report({
|
||||||
|
jobtype: jobType,
|
||||||
|
created_by: user ? user.username : false,
|
||||||
|
payload,
|
||||||
|
migration_version: version,
|
||||||
|
space_id: spaceId || DEFAULT_SPACE_ID,
|
||||||
|
meta: {
|
||||||
|
// telemetry fields
|
||||||
|
objectType: jobParams.objectType,
|
||||||
|
layout: jobParams.layout?.id,
|
||||||
|
isDeprecated: job.isDeprecated,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`);
|
||||||
|
|
||||||
|
// Schedule the report with Task Manager
|
||||||
|
const task = await reporting.scheduleTask(req, report.toReportTaskJSON());
|
||||||
|
logger.info(
|
||||||
|
`Scheduled ${name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log the action with event log
|
||||||
|
reporting.getEventLogger(report, task).logScheduleTask();
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRequest(params: RequestParams) {
|
||||||
|
const { exportTypeId, jobParams } = params;
|
||||||
|
const { reporting, req, res, path } = this.opts;
|
||||||
|
|
||||||
|
const counters = getCounters(
|
||||||
|
req.route.method,
|
||||||
|
path.replace(/{exportType}/, exportTypeId),
|
||||||
|
reporting.getUsageCounter()
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkErrorResponse = await this.checkLicenseAndTimezone(
|
||||||
|
exportTypeId,
|
||||||
|
jobParams.browserTimezone
|
||||||
|
);
|
||||||
|
if (checkErrorResponse) {
|
||||||
|
return checkErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
let report: Report | undefined;
|
||||||
|
try {
|
||||||
|
report = await this.enqueueJob(params);
|
||||||
|
const { basePath } = reporting.getServerInfo();
|
||||||
|
const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX;
|
||||||
|
|
||||||
|
// return task manager's task information and the download URL
|
||||||
|
counters.usageCounter();
|
||||||
|
const eventTracker = reporting.getEventTracker(
|
||||||
|
report._id,
|
||||||
|
exportTypeId,
|
||||||
|
jobParams.objectType
|
||||||
|
);
|
||||||
|
eventTracker?.createReport({
|
||||||
|
isDeprecated: Boolean(report.payload.isDeprecated),
|
||||||
|
isPublicApi: path.match(/internal/) === null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.ok<ReportingJobResponse>({
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: {
|
||||||
|
path: `${publicDownloadPath}/${report._id}`,
|
||||||
|
job: report.toApiJSON(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return this.handleError(err, counters, report?.jobtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { handleUnavailable } from './request_handler';
|
||||||
|
export { GenerateRequestHandler } from './generate_request_handler';
|
||||||
|
export { ScheduleRequestHandler } from './schedule_request_handler';
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { transformRawScheduledReportToReport } from './transform_raw_scheduled_report';
|
||||||
|
export { transformRawScheduledReportToTaskParams } from './transform_raw_scheduled_report_to_task';
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SavedObject } from '@kbn/core/server';
|
||||||
|
import { ScheduledReportApiJSON, ScheduledReportType } from '../../../../types';
|
||||||
|
|
||||||
|
export function transformRawScheduledReportToReport(
|
||||||
|
rawScheduledReport: SavedObject<ScheduledReportType>
|
||||||
|
): ScheduledReportApiJSON {
|
||||||
|
const parsedPayload = JSON.parse(rawScheduledReport.attributes.payload);
|
||||||
|
return {
|
||||||
|
id: rawScheduledReport.id,
|
||||||
|
jobtype: rawScheduledReport.attributes.jobType,
|
||||||
|
created_at: rawScheduledReport.attributes.createdAt,
|
||||||
|
created_by: rawScheduledReport.attributes.createdBy as string | false,
|
||||||
|
payload: parsedPayload,
|
||||||
|
meta: rawScheduledReport.attributes.meta,
|
||||||
|
migration_version: rawScheduledReport.attributes.migrationVersion,
|
||||||
|
schedule: rawScheduledReport.attributes.schedule,
|
||||||
|
notification: rawScheduledReport.attributes.notification,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SavedObject } from '@kbn/core/server';
|
||||||
|
import { ScheduledReportType } from '../../../../types';
|
||||||
|
import { ScheduledReportTaskParamsWithoutSpaceId } from '../../../../lib/tasks';
|
||||||
|
|
||||||
|
export function transformRawScheduledReportToTaskParams(
|
||||||
|
rawScheduledReport: SavedObject<ScheduledReportType>
|
||||||
|
): ScheduledReportTaskParamsWithoutSpaceId {
|
||||||
|
return {
|
||||||
|
id: rawScheduledReport.id,
|
||||||
|
jobtype: rawScheduledReport.attributes.jobType,
|
||||||
|
schedule: rawScheduledReport.attributes.schedule,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,208 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Boom from '@hapi/boom';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { schema, TypeOf } from '@kbn/config-schema';
|
||||||
|
import type {
|
||||||
|
IKibanaResponse,
|
||||||
|
KibanaRequest,
|
||||||
|
KibanaResponseFactory,
|
||||||
|
Logger,
|
||||||
|
} from '@kbn/core/server';
|
||||||
|
import { i18n } from '@kbn/i18n';
|
||||||
|
import type { BaseParams } from '@kbn/reporting-common/types';
|
||||||
|
import { cryptoFactory } from '@kbn/reporting-server';
|
||||||
|
import rison from '@kbn/rison';
|
||||||
|
|
||||||
|
import { RruleSchedule } from '@kbn/task-manager-plugin/server';
|
||||||
|
import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest';
|
||||||
|
import { checkParamsVersion } from '../../../lib';
|
||||||
|
import { type Counters } from '..';
|
||||||
|
import type { ReportingCore } from '../../..';
|
||||||
|
import type { ReportingRequestHandlerContext, ReportingUser } from '../../../types';
|
||||||
|
|
||||||
|
export const handleUnavailable = (res: KibanaResponseFactory) => {
|
||||||
|
return res.custom({ statusCode: 503, body: 'Not Available' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParamsValidation = schema.recordOf(schema.string(), schema.string());
|
||||||
|
const QueryValidation = schema.nullable(
|
||||||
|
schema.recordOf(schema.string(), schema.maybe(schema.string()))
|
||||||
|
);
|
||||||
|
const BodyValidation = schema.nullable(
|
||||||
|
schema.recordOf(schema.string(), schema.maybe(schema.any()))
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ConstructorOpts<
|
||||||
|
Params extends typeof ParamsValidation,
|
||||||
|
Query extends typeof QueryValidation,
|
||||||
|
Body extends typeof BodyValidation
|
||||||
|
> {
|
||||||
|
reporting: ReportingCore;
|
||||||
|
user: ReportingUser;
|
||||||
|
context: ReportingRequestHandlerContext;
|
||||||
|
path: string;
|
||||||
|
req: KibanaRequest<TypeOf<Params>, TypeOf<Query>, TypeOf<Body>>;
|
||||||
|
res: KibanaResponseFactory;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestParams {
|
||||||
|
exportTypeId: string;
|
||||||
|
jobParams: BaseParams;
|
||||||
|
id?: string;
|
||||||
|
schedule?: RruleSchedule;
|
||||||
|
notification?: RawNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the common parts of requests to generate or schedule a report
|
||||||
|
* Serves report job handling in the context of the request to generate the report
|
||||||
|
*/
|
||||||
|
export abstract class RequestHandler<
|
||||||
|
Params extends typeof ParamsValidation,
|
||||||
|
Query extends typeof QueryValidation,
|
||||||
|
Body extends typeof BodyValidation,
|
||||||
|
Output extends Record<string, any>
|
||||||
|
> {
|
||||||
|
constructor(protected readonly opts: ConstructorOpts<Params, Query, Body>) {}
|
||||||
|
|
||||||
|
public static getValidation() {
|
||||||
|
throw new Error('getValidation() must be implemented in a subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract enqueueJob(params: RequestParams): Promise<Output>;
|
||||||
|
|
||||||
|
public abstract handleRequest(params: RequestParams): Promise<IKibanaResponse>;
|
||||||
|
|
||||||
|
public getJobParams(): BaseParams {
|
||||||
|
let jobParamsRison: null | string = null;
|
||||||
|
const req = this.opts.req;
|
||||||
|
const res = this.opts.res;
|
||||||
|
|
||||||
|
if (req.body) {
|
||||||
|
const { jobParams: jobParamsPayload } = req.body;
|
||||||
|
jobParamsRison = jobParamsPayload ? jobParamsPayload : null;
|
||||||
|
} else if (req.query?.jobParams) {
|
||||||
|
const { jobParams: queryJobParams } = req.query;
|
||||||
|
if (queryJobParams) {
|
||||||
|
jobParamsRison = queryJobParams;
|
||||||
|
} else {
|
||||||
|
jobParamsRison = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jobParamsRison) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 400,
|
||||||
|
body: 'A jobParams RISON string is required in the querystring or POST body',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobParams;
|
||||||
|
|
||||||
|
try {
|
||||||
|
jobParams = rison.decode(jobParamsRison) as BaseParams | null;
|
||||||
|
if (!jobParams) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 400,
|
||||||
|
body: 'Missing jobParams!',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 400,
|
||||||
|
body: `invalid rison: ${jobParamsRison}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async createJob(exportTypeId: string, jobParams: BaseParams) {
|
||||||
|
const exportType = this.opts.reporting.getExportTypesRegistry().getById(exportTypeId);
|
||||||
|
|
||||||
|
if (exportType == null) {
|
||||||
|
throw new Error(`Export type ${exportTypeId} does not exist in the registry!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exportType.createJob) {
|
||||||
|
throw new Error(`Export type ${exportTypeId} is not a valid instance!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Ensure the incoming params have a version field (should be set by the UI)
|
||||||
|
const version = checkParamsVersion(jobParams, this.opts.logger);
|
||||||
|
|
||||||
|
// 2. Create a payload object by calling exportType.createJob(), and adding some automatic parameters
|
||||||
|
const job = await exportType.createJob(jobParams, this.opts.context, this.opts.req);
|
||||||
|
|
||||||
|
return { job, version, jobType: exportType.jobType, name: exportType.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async checkLicenseAndTimezone(
|
||||||
|
exportTypeId: string,
|
||||||
|
browserTimezone: string
|
||||||
|
): Promise<IKibanaResponse | null> {
|
||||||
|
const { reporting, context, res } = this.opts;
|
||||||
|
|
||||||
|
// ensure the async dependencies are loaded
|
||||||
|
if (!context.reporting) {
|
||||||
|
return handleUnavailable(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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browserTimezone && !moment.tz.zone(browserTimezone)) {
|
||||||
|
return res.badRequest({
|
||||||
|
body: `Invalid timezone "${browserTimezone ?? ''}".`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async encryptHeaders() {
|
||||||
|
const { encryptionKey } = this.opts.reporting.getConfig();
|
||||||
|
const crypto = cryptoFactory(encryptionKey);
|
||||||
|
return await crypto.encrypt(this.opts.req.headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected handleError(err: Error | Boom.Boom, counters?: Counters, jobtype?: string) {
|
||||||
|
this.opts.logger.error(err);
|
||||||
|
|
||||||
|
if (err instanceof Boom.Boom) {
|
||||||
|
const statusCode = err.output.statusCode;
|
||||||
|
counters?.errorCounter(jobtype, statusCode);
|
||||||
|
|
||||||
|
return this.opts.res.customError({
|
||||||
|
statusCode,
|
||||||
|
body: err.output.payload.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
counters?.errorCounter(jobtype, 500);
|
||||||
|
|
||||||
|
return this.opts.res.customError({
|
||||||
|
statusCode: 500,
|
||||||
|
body:
|
||||||
|
err?.message ||
|
||||||
|
i18n.translate('xpack.reporting.errorHandler.unknownError', {
|
||||||
|
defaultMessage: 'Unknown error',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,791 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock('uuid', () => ({ v4: () => 'mock-report-id' }));
|
||||||
|
|
||||||
|
import rison from '@kbn/rison';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuditLogger,
|
||||||
|
FakeRawRequest,
|
||||||
|
KibanaRequest,
|
||||||
|
KibanaResponseFactory,
|
||||||
|
SavedObjectsClientContract,
|
||||||
|
} from '@kbn/core/server';
|
||||||
|
import { coreMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks';
|
||||||
|
import { JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common';
|
||||||
|
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
|
||||||
|
import { ReportingCore } from '../../..';
|
||||||
|
import {
|
||||||
|
createMockPluginSetup,
|
||||||
|
createMockPluginStart,
|
||||||
|
createMockReportingCore,
|
||||||
|
} from '../../../test_helpers';
|
||||||
|
import { ReportingRequestHandlerContext, ReportingSetup } from '../../../types';
|
||||||
|
import { ScheduleRequestHandler } from './schedule_request_handler';
|
||||||
|
import { TaskStatus } from '@kbn/task-manager-plugin/server';
|
||||||
|
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
|
||||||
|
const getMockContext = () =>
|
||||||
|
({
|
||||||
|
core: coreMock.createRequestHandlerContext(),
|
||||||
|
} as unknown as ReportingRequestHandlerContext);
|
||||||
|
|
||||||
|
const getMockRequest = () =>
|
||||||
|
({
|
||||||
|
url: { port: '5601', search: '', pathname: '/foo' },
|
||||||
|
route: { path: '/foo', options: {} },
|
||||||
|
} as KibanaRequest<unknown, unknown, unknown>);
|
||||||
|
|
||||||
|
const getMockResponseFactory = () =>
|
||||||
|
({
|
||||||
|
...httpServerMock.createResponseFactory(),
|
||||||
|
forbidden: (obj: unknown) => obj,
|
||||||
|
unauthorized: (obj: unknown) => obj,
|
||||||
|
customError: (err: unknown) => err,
|
||||||
|
} as unknown as KibanaResponseFactory);
|
||||||
|
|
||||||
|
const mockLogger = loggingSystemMock.createLogger();
|
||||||
|
const mockJobParams: JobParamsPDFV2 = {
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
objectType: 'cool_object_type',
|
||||||
|
title: 'cool_title',
|
||||||
|
version: 'unknown',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeRawRequest: FakeRawRequest = {
|
||||||
|
headers: {
|
||||||
|
authorization: `ApiKey skdjtq4u543yt3rhewrh`,
|
||||||
|
},
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Handle request to schedule', () => {
|
||||||
|
let reportingCore: ReportingCore;
|
||||||
|
let mockContext: ReturnType<typeof getMockContext>;
|
||||||
|
let mockRequest: ReturnType<typeof getMockRequest>;
|
||||||
|
let mockResponseFactory: ReturnType<typeof getMockResponseFactory>;
|
||||||
|
let requestHandler: ScheduleRequestHandler;
|
||||||
|
let soClient: SavedObjectsClientContract;
|
||||||
|
let auditLogger: AuditLogger;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const mockConfig = createMockConfigSchema({});
|
||||||
|
reportingCore = await createMockReportingCore(
|
||||||
|
mockConfig,
|
||||||
|
createMockPluginSetup({}),
|
||||||
|
await createMockPluginStart(
|
||||||
|
{
|
||||||
|
licensing: {
|
||||||
|
...licensingMock.createStart(),
|
||||||
|
license$: new BehaviorSubject({
|
||||||
|
isAvailable: true,
|
||||||
|
isActive: true,
|
||||||
|
type: 'platinum',
|
||||||
|
getFeature: () => true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mockConfig
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
mockRequest = getMockRequest();
|
||||||
|
|
||||||
|
mockResponseFactory = getMockResponseFactory();
|
||||||
|
(mockResponseFactory.ok as jest.Mock) = jest.fn((args: unknown) => args);
|
||||||
|
(mockResponseFactory.forbidden as jest.Mock) = jest.fn((args: unknown) => args);
|
||||||
|
(mockResponseFactory.badRequest as jest.Mock) = jest.fn((args: unknown) => args);
|
||||||
|
|
||||||
|
mockContext = getMockContext();
|
||||||
|
mockContext.reporting = Promise.resolve({} as ReportingSetup);
|
||||||
|
|
||||||
|
auditLogger = await reportingCore.getAuditLogger(fakeRawRequest as unknown as KibanaRequest);
|
||||||
|
auditLogger.log = jest.fn();
|
||||||
|
soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest);
|
||||||
|
soClient.create = jest.fn().mockImplementation(async (_, opts) => {
|
||||||
|
return {
|
||||||
|
id: 'foo',
|
||||||
|
attributes: opts,
|
||||||
|
type: 'scheduled-report',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(reportingCore, 'scheduleRecurringTask').mockResolvedValue({
|
||||||
|
id: 'task-id',
|
||||||
|
scheduledAt: new Date(),
|
||||||
|
attempts: 0,
|
||||||
|
status: TaskStatus.Idle,
|
||||||
|
runAt: new Date(),
|
||||||
|
startedAt: new Date(),
|
||||||
|
retryAt: new Date(),
|
||||||
|
state: {},
|
||||||
|
ownerId: 'reporting',
|
||||||
|
taskType: 'reporting:printable_pdf_v2',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({
|
||||||
|
isSufficientlySecure: true,
|
||||||
|
hasPermanentEncryptionKey: true,
|
||||||
|
areNotificationsEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
requestHandler = new ScheduleRequestHandler({
|
||||||
|
reporting: reportingCore,
|
||||||
|
user: { username: 'testymcgee' },
|
||||||
|
context: mockContext,
|
||||||
|
path: '/api/reporting/test/generate/pdf',
|
||||||
|
// @ts-ignore
|
||||||
|
req: mockRequest,
|
||||||
|
res: mockResponseFactory,
|
||||||
|
logger: mockLogger,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enqueueJob', () => {
|
||||||
|
test('creates a scheduled_report saved object and schedules task', async () => {
|
||||||
|
const report = await requestHandler.enqueueJob({
|
||||||
|
exportTypeId: 'printablePdfV2',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id, created_at: _created_at, payload, ...snapObj } = report;
|
||||||
|
expect(snapObj).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"created_by": "testymcgee",
|
||||||
|
"jobtype": "printable_pdf_v2",
|
||||||
|
"meta": Object {
|
||||||
|
"isDeprecated": false,
|
||||||
|
"layout": "preserve_layout",
|
||||||
|
"objectType": "cool_object_type",
|
||||||
|
},
|
||||||
|
"migration_version": "unknown",
|
||||||
|
"notification": undefined,
|
||||||
|
"schedule": Object {
|
||||||
|
"rrule": Object {
|
||||||
|
"freq": 1,
|
||||||
|
"interval": 2,
|
||||||
|
"tzid": "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(payload).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"browserTimezone": "UTC",
|
||||||
|
"isDeprecated": false,
|
||||||
|
"layout": Object {
|
||||||
|
"id": "preserve_layout",
|
||||||
|
},
|
||||||
|
"locatorParams": Array [],
|
||||||
|
"objectType": "cool_object_type",
|
||||||
|
"title": "cool_title",
|
||||||
|
"version": "unknown",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(auditLogger.log).toHaveBeenCalledWith({
|
||||||
|
event: {
|
||||||
|
action: 'scheduled_report_schedule',
|
||||||
|
category: ['database'],
|
||||||
|
outcome: 'unknown',
|
||||||
|
type: ['creation'],
|
||||||
|
},
|
||||||
|
kibana: {
|
||||||
|
saved_object: { id: 'mock-report-id', name: 'cool_title', type: 'scheduled_report' },
|
||||||
|
},
|
||||||
|
message: 'User is creating scheduled report [id=mock-report-id] [name=cool_title]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(soClient.create).toHaveBeenCalledWith(
|
||||||
|
'scheduled_report',
|
||||||
|
{
|
||||||
|
jobType: 'printable_pdf_v2',
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
createdBy: 'testymcgee',
|
||||||
|
title: 'cool_title',
|
||||||
|
enabled: true,
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
schedule: {
|
||||||
|
rrule: {
|
||||||
|
freq: 1,
|
||||||
|
interval: 2,
|
||||||
|
tzid: 'UTC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
migrationVersion: 'unknown',
|
||||||
|
meta: {
|
||||||
|
objectType: 'cool_object_type',
|
||||||
|
layout: 'preserve_layout',
|
||||||
|
isDeprecated: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'mock-report-id' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reportingCore.scheduleRecurringTask).toHaveBeenCalledWith(mockRequest, {
|
||||||
|
id: 'foo',
|
||||||
|
jobtype: 'printable_pdf_v2',
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a scheduled_report saved object with notification', async () => {
|
||||||
|
const report = await requestHandler.enqueueJob({
|
||||||
|
exportTypeId: 'printablePdfV2',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
|
||||||
|
notification: { email: { to: ['a@b.com'] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { id, created_at: _created_at, payload, ...snapObj } = report;
|
||||||
|
expect(snapObj).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"created_by": "testymcgee",
|
||||||
|
"jobtype": "printable_pdf_v2",
|
||||||
|
"meta": Object {
|
||||||
|
"isDeprecated": false,
|
||||||
|
"layout": "preserve_layout",
|
||||||
|
"objectType": "cool_object_type",
|
||||||
|
},
|
||||||
|
"migration_version": "unknown",
|
||||||
|
"notification": Object {
|
||||||
|
"email": Object {
|
||||||
|
"to": Array [
|
||||||
|
"a@b.com",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"schedule": Object {
|
||||||
|
"rrule": Object {
|
||||||
|
"freq": 1,
|
||||||
|
"interval": 2,
|
||||||
|
"tzid": "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(payload).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"browserTimezone": "UTC",
|
||||||
|
"isDeprecated": false,
|
||||||
|
"layout": Object {
|
||||||
|
"id": "preserve_layout",
|
||||||
|
},
|
||||||
|
"locatorParams": Array [],
|
||||||
|
"objectType": "cool_object_type",
|
||||||
|
"title": "cool_title",
|
||||||
|
"version": "unknown",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(auditLogger.log).toHaveBeenCalledWith({
|
||||||
|
event: {
|
||||||
|
action: 'scheduled_report_schedule',
|
||||||
|
category: ['database'],
|
||||||
|
outcome: 'unknown',
|
||||||
|
type: ['creation'],
|
||||||
|
},
|
||||||
|
kibana: {
|
||||||
|
saved_object: { id: 'mock-report-id', name: 'cool_title', type: 'scheduled_report' },
|
||||||
|
},
|
||||||
|
message: 'User is creating scheduled report [id=mock-report-id] [name=cool_title]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(soClient.create).toHaveBeenCalledWith(
|
||||||
|
'scheduled_report',
|
||||||
|
{
|
||||||
|
jobType: 'printable_pdf_v2',
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
createdBy: 'testymcgee',
|
||||||
|
title: 'cool_title',
|
||||||
|
enabled: true,
|
||||||
|
payload: JSON.stringify(payload),
|
||||||
|
schedule: {
|
||||||
|
rrule: {
|
||||||
|
freq: 1,
|
||||||
|
interval: 2,
|
||||||
|
tzid: 'UTC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
migrationVersion: 'unknown',
|
||||||
|
meta: {
|
||||||
|
objectType: 'cool_object_type',
|
||||||
|
layout: 'preserve_layout',
|
||||||
|
isDeprecated: false,
|
||||||
|
},
|
||||||
|
notification: { email: { to: ['a@b.com'] } },
|
||||||
|
},
|
||||||
|
{ id: 'mock-report-id' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws errors from so client create', async () => {
|
||||||
|
soClient.create = jest.fn().mockImplementationOnce(async () => {
|
||||||
|
throw new Error('SO create error');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestHandler.enqueueJob({
|
||||||
|
exportTypeId: 'printablePdfV2',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } },
|
||||||
|
})
|
||||||
|
).rejects.toThrowErrorMatchingInlineSnapshot(`"SO create error"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getJobParams', () => {
|
||||||
|
test('parse jobParams from body', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = { jobParams: rison.encode(mockJobParams) };
|
||||||
|
expect(requestHandler.getJobParams()).toEqual(mockJobParams);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing job params', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
requestHandler.getJobParams();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles null job params', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = { jobParams: rison.encode(null) };
|
||||||
|
requestHandler.getJobParams();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid rison', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = { jobParams: mockJobParams };
|
||||||
|
try {
|
||||||
|
requestHandler.getJobParams();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSchedule', () => {
|
||||||
|
test('parse schedule from body', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
};
|
||||||
|
expect(requestHandler.getSchedule()).toEqual({ rrule: { freq: 1, interval: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles missing schedule', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
};
|
||||||
|
requestHandler.getSchedule();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles null schedule', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: null,
|
||||||
|
};
|
||||||
|
requestHandler.getSchedule();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty schedule', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: {},
|
||||||
|
};
|
||||||
|
requestHandler.getSchedule();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles null rrule schedule', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: null },
|
||||||
|
};
|
||||||
|
requestHandler.getSchedule();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty rrule schedule', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: {} },
|
||||||
|
};
|
||||||
|
requestHandler.getSchedule();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
expect(error?.body).toBe('A schedule is required to create a scheduled report.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNotification', () => {
|
||||||
|
test('parse notification from body', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: { email: { to: ['a@b.com'] } },
|
||||||
|
};
|
||||||
|
expect(requestHandler.getNotification()).toEqual({ email: { to: ['a@b.com'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parse notification from body when no to defined', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: { email: { bcc: ['a@b.com'] } },
|
||||||
|
};
|
||||||
|
expect(requestHandler.getNotification()).toEqual({ email: { bcc: ['a@b.com'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if notification object is empty', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: {},
|
||||||
|
};
|
||||||
|
expect(requestHandler.getNotification()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if notification object is null', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: null,
|
||||||
|
};
|
||||||
|
expect(requestHandler.getNotification()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if notification.email object is empty', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: { email: {} },
|
||||||
|
};
|
||||||
|
expect(requestHandler.getNotification()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if notification.email arrays are all empty', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: { email: { to: [], cc: [], bcc: [] } },
|
||||||
|
};
|
||||||
|
expect(requestHandler.getNotification()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if notification.email object is null', () => {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: { email: null },
|
||||||
|
};
|
||||||
|
expect(requestHandler.getNotification()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid email address', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(reportingCore, 'validateNotificationEmails')
|
||||||
|
.mockReturnValueOnce('not valid emails: foo');
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: { email: { to: ['foo'] } },
|
||||||
|
};
|
||||||
|
requestHandler.getNotification();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
expect(error?.body).toBe('Invalid email address(es): not valid emails: foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles too many recipients', () => {
|
||||||
|
let error: { statusCode: number; body: string } | undefined;
|
||||||
|
try {
|
||||||
|
// @ts-ignore body is a read-only property
|
||||||
|
mockRequest.body = {
|
||||||
|
jobParams: rison.encode(mockJobParams),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: {
|
||||||
|
email: {
|
||||||
|
to: [
|
||||||
|
'1@elastic.co',
|
||||||
|
'2@elastic.co',
|
||||||
|
'3@elastic.co',
|
||||||
|
'4@elastic.co',
|
||||||
|
'5@elastic.co',
|
||||||
|
'6@elastic.co',
|
||||||
|
'7@elastic.co',
|
||||||
|
],
|
||||||
|
cc: [
|
||||||
|
'8@elastic.co',
|
||||||
|
'9@elastic.co',
|
||||||
|
'10@elastic.co',
|
||||||
|
'11@elastic.co',
|
||||||
|
'12@elastic.co',
|
||||||
|
'13@elastic.co',
|
||||||
|
'14@elastic.co',
|
||||||
|
'15@elastic.co',
|
||||||
|
'16@elastic.co',
|
||||||
|
'17@elastic.co',
|
||||||
|
],
|
||||||
|
bcc: [
|
||||||
|
'18@elastic.co',
|
||||||
|
'19@elastic.co',
|
||||||
|
'20@elastic.co',
|
||||||
|
'21@elastic.co',
|
||||||
|
'22@elastic.co',
|
||||||
|
'23@elastic.co',
|
||||||
|
'24@elastic.co',
|
||||||
|
'25@elastic.co',
|
||||||
|
'26@elastic.co',
|
||||||
|
'27@elastic.co',
|
||||||
|
'28@elastic.co',
|
||||||
|
'29@elastic.co',
|
||||||
|
'30@elastic.co',
|
||||||
|
'31@elastic.co',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
requestHandler.getNotification();
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error?.statusCode).toBe(400);
|
||||||
|
expect(error?.body).toBe(
|
||||||
|
'Maximum number of recipients exceeded: cannot specify more than 30 recipients.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleRequest', () => {
|
||||||
|
test('disallows invalid export type', async () => {
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'neanderthals',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "Invalid export-type of neanderthals",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disallows unsupporting license', async () => {
|
||||||
|
(reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({
|
||||||
|
scheduledReports: {
|
||||||
|
enableLinks: false,
|
||||||
|
message: `seeing this means the license isn't supported`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'csv_searchsource',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "seeing this means the license isn't supported",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disallows invalid browser timezone', async () => {
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'csv_searchsource',
|
||||||
|
jobParams: {
|
||||||
|
...mockJobParams,
|
||||||
|
browserTimezone: 'America/Amsterdam',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "Invalid timezone \\"America/Amsterdam\\".",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disallows scheduling when user is "false"', async () => {
|
||||||
|
requestHandler = new ScheduleRequestHandler({
|
||||||
|
reporting: reportingCore,
|
||||||
|
user: false,
|
||||||
|
context: mockContext,
|
||||||
|
path: '/api/reporting/test/generate/pdf',
|
||||||
|
// @ts-ignore
|
||||||
|
req: mockRequest,
|
||||||
|
res: mockResponseFactory,
|
||||||
|
logger: mockLogger,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'csv_searchsource',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "User must be authenticated to schedule a report",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disallows scheduling when reportingHealth.hasPermanentEncryptionKey = false', async () => {
|
||||||
|
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
|
||||||
|
isSufficientlySecure: true,
|
||||||
|
hasPermanentEncryptionKey: false,
|
||||||
|
areNotificationsEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'csv_searchsource',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "Permanent encryption key must be set for scheduled reporting",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disallows scheduling when reportingHealth.isSufficientlySecure=false', async () => {
|
||||||
|
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
|
||||||
|
isSufficientlySecure: false,
|
||||||
|
hasPermanentEncryptionKey: true,
|
||||||
|
areNotificationsEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'csv_searchsource',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "Security and API keys must be enabled for scheduled reporting",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles errors from so client create', async () => {
|
||||||
|
soClient.create = jest.fn().mockImplementationOnce(async () => {
|
||||||
|
throw new Error('SO create error');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await requestHandler.handleRequest({
|
||||||
|
exportTypeId: 'csv_searchsource',
|
||||||
|
jobParams: mockJobParams,
|
||||||
|
})
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
Object {
|
||||||
|
"body": "SO create error",
|
||||||
|
"statusCode": 500,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(auditLogger.log).toHaveBeenCalledWith({
|
||||||
|
error: {
|
||||||
|
code: 'Error',
|
||||||
|
message: 'SO create error',
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
action: 'scheduled_report_schedule',
|
||||||
|
category: ['database'],
|
||||||
|
outcome: 'failure',
|
||||||
|
type: ['creation'],
|
||||||
|
},
|
||||||
|
kibana: {
|
||||||
|
saved_object: {
|
||||||
|
id: 'mock-report-id',
|
||||||
|
type: 'scheduled_report',
|
||||||
|
name: 'cool_title',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'Failed attempt to create scheduled report [id=mock-report-id] [name=cool_title]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,249 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
import { schema } from '@kbn/config-schema';
|
||||||
|
import { isEmpty, omit } from 'lodash';
|
||||||
|
import { RruleSchedule, scheduleRruleSchema } from '@kbn/task-manager-plugin/server';
|
||||||
|
import { SavedObjectsUtils } from '@kbn/core/server';
|
||||||
|
import { IKibanaResponse } from '@kbn/core/server';
|
||||||
|
import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest';
|
||||||
|
import { rawNotificationSchema } from '../../../saved_objects/scheduled_report/schemas/v1';
|
||||||
|
import {
|
||||||
|
ScheduledReportApiJSON,
|
||||||
|
ScheduledReportType,
|
||||||
|
ScheduledReportingJobResponse,
|
||||||
|
} from '../../../types';
|
||||||
|
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects';
|
||||||
|
import { RequestHandler, RequestParams } from './request_handler';
|
||||||
|
import {
|
||||||
|
transformRawScheduledReportToReport,
|
||||||
|
transformRawScheduledReportToTaskParams,
|
||||||
|
} from './lib';
|
||||||
|
import { ScheduledReportAuditAction, scheduledReportAuditEvent } from '../audit_events';
|
||||||
|
|
||||||
|
// Using the limit specified in the cloud email service limits
|
||||||
|
// https://www.elastic.co/docs/explore-analyze/alerts-cases/watcher/enable-watcher#cloud-email-service-limits
|
||||||
|
const MAX_ALLOWED_EMAILS = 30;
|
||||||
|
|
||||||
|
const validation = {
|
||||||
|
params: schema.object({ exportType: schema.string({ minLength: 2 }) }),
|
||||||
|
body: schema.object({
|
||||||
|
schedule: scheduleRruleSchema,
|
||||||
|
notification: schema.maybe(rawNotificationSchema),
|
||||||
|
jobParams: schema.string(),
|
||||||
|
}),
|
||||||
|
query: schema.nullable(schema.object({})),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the common parts of requests to generate a report
|
||||||
|
* Serves report job handling in the context of the request to generate the report
|
||||||
|
*/
|
||||||
|
export class ScheduleRequestHandler extends RequestHandler<
|
||||||
|
(typeof validation)['params'],
|
||||||
|
(typeof validation)['query'],
|
||||||
|
(typeof validation)['body'],
|
||||||
|
ScheduledReportApiJSON
|
||||||
|
> {
|
||||||
|
protected async checkLicenseAndTimezone(
|
||||||
|
exportTypeId: string,
|
||||||
|
browserTimezone: string
|
||||||
|
): Promise<IKibanaResponse | null> {
|
||||||
|
const { reporting, res } = this.opts;
|
||||||
|
const licenseInfo = await reporting.getLicenseInfo();
|
||||||
|
const licenseResults = licenseInfo.scheduledReports;
|
||||||
|
|
||||||
|
if (!licenseResults.enableLinks) {
|
||||||
|
return res.forbidden({ body: licenseResults.message });
|
||||||
|
}
|
||||||
|
return super.checkLicenseAndTimezone(exportTypeId, browserTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getValidation() {
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSchedule(): RruleSchedule {
|
||||||
|
let rruleDef: null | RruleSchedule['rrule'] = null;
|
||||||
|
const req = this.opts.req;
|
||||||
|
const res = this.opts.res;
|
||||||
|
|
||||||
|
const { schedule } = req.body;
|
||||||
|
const { rrule } = schedule ?? {};
|
||||||
|
rruleDef = rrule;
|
||||||
|
|
||||||
|
if (isEmpty(rruleDef)) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 400,
|
||||||
|
body: 'A schedule is required to create a scheduled report.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getNotification(): RawNotification | undefined {
|
||||||
|
const { reporting, req, res } = this.opts;
|
||||||
|
|
||||||
|
const { notification } = req.body;
|
||||||
|
if (isEmpty(notification) || isEmpty(notification.email)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEmails = new Set([
|
||||||
|
...(notification.email.to || []),
|
||||||
|
...(notification.email.bcc || []),
|
||||||
|
...(notification.email.cc || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (allEmails.size === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allEmails.size > MAX_ALLOWED_EMAILS) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 400,
|
||||||
|
body: `Maximum number of recipients exceeded: cannot specify more than ${MAX_ALLOWED_EMAILS} recipients.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidEmails = reporting.validateNotificationEmails([...allEmails]);
|
||||||
|
if (invalidEmails) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 400,
|
||||||
|
body: `Invalid email address(es): ${invalidEmails}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enqueueJob(params: RequestParams) {
|
||||||
|
const { id, exportTypeId, jobParams, schedule, notification } = params;
|
||||||
|
const { reporting, logger, req, user } = this.opts;
|
||||||
|
|
||||||
|
const soClient = await reporting.getScopedSoClient(req);
|
||||||
|
const auditLogger = await reporting.getAuditLogger(req);
|
||||||
|
const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams);
|
||||||
|
|
||||||
|
const reportId = id || SavedObjectsUtils.generateId();
|
||||||
|
auditLogger.log(
|
||||||
|
scheduledReportAuditEvent({
|
||||||
|
action: ScheduledReportAuditAction.SCHEDULE,
|
||||||
|
savedObject: { type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, id: reportId, name: job.title },
|
||||||
|
outcome: 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...job,
|
||||||
|
title: job.title,
|
||||||
|
objectType: jobParams.objectType,
|
||||||
|
browserTimezone: jobParams.browserTimezone,
|
||||||
|
version,
|
||||||
|
spaceId: reporting.getSpaceId(req, logger),
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO - extract saved object references before persisting
|
||||||
|
|
||||||
|
const attributes = {
|
||||||
|
createdAt: moment.utc().toISOString(),
|
||||||
|
// we've already checked that user exists in handleRequest
|
||||||
|
// this fallback is just to satisfy the type
|
||||||
|
createdBy: user ? user.username : 'unknown',
|
||||||
|
enabled: true,
|
||||||
|
jobType,
|
||||||
|
meta: {
|
||||||
|
// telemetry fields
|
||||||
|
isDeprecated: job.isDeprecated,
|
||||||
|
layout: jobParams.layout?.id,
|
||||||
|
objectType: jobParams.objectType,
|
||||||
|
},
|
||||||
|
migrationVersion: version,
|
||||||
|
...(notification ? { notification } : {}),
|
||||||
|
title: job.title,
|
||||||
|
payload: JSON.stringify(omit(payload, 'forceNow')),
|
||||||
|
schedule: schedule!,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a scheduled_report saved object
|
||||||
|
const report = await soClient.create<ScheduledReportType>(
|
||||||
|
SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
attributes,
|
||||||
|
{ id: reportId }
|
||||||
|
);
|
||||||
|
logger.debug(`Successfully created scheduled report: ${report.id}`);
|
||||||
|
|
||||||
|
// Schedule the report with Task Manager
|
||||||
|
const task = await reporting.scheduleRecurringTask(
|
||||||
|
req,
|
||||||
|
transformRawScheduledReportToTaskParams(report)
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Scheduled "${name}" reporting task. Task ID: task:${task.id}. Report ID: ${report.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return transformRawScheduledReportToReport(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleRequest(params: RequestParams) {
|
||||||
|
const { exportTypeId, jobParams } = params;
|
||||||
|
const { reporting, req, res } = this.opts;
|
||||||
|
|
||||||
|
const checkErrorResponse = await this.checkLicenseAndTimezone(
|
||||||
|
exportTypeId,
|
||||||
|
jobParams.browserTimezone
|
||||||
|
);
|
||||||
|
if (checkErrorResponse) {
|
||||||
|
return checkErrorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check that security requirements are met
|
||||||
|
const reportingHealth = await reporting.getHealthInfo();
|
||||||
|
if (!reportingHealth.hasPermanentEncryptionKey) {
|
||||||
|
return res.forbidden({
|
||||||
|
body: `Permanent encryption key must be set for scheduled reporting`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!reportingHealth.isSufficientlySecure) {
|
||||||
|
return res.forbidden({
|
||||||
|
body: `Security and API keys must be enabled for scheduled reporting`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// check that username exists
|
||||||
|
if (!this.opts.user || !this.opts.user.username) {
|
||||||
|
return res.forbidden({
|
||||||
|
body: `User must be authenticated to schedule a report`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLogger = await reporting.getAuditLogger(req);
|
||||||
|
|
||||||
|
let report: ScheduledReportApiJSON | undefined;
|
||||||
|
const id = SavedObjectsUtils.generateId();
|
||||||
|
try {
|
||||||
|
report = await this.enqueueJob({ ...params, id });
|
||||||
|
return res.ok<ScheduledReportingJobResponse>({
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: {
|
||||||
|
job: report,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
auditLogger.log(
|
||||||
|
scheduledReportAuditEvent({
|
||||||
|
action: ScheduledReportAuditAction.SCHEDULE,
|
||||||
|
savedObject: { type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, id, name: jobParams.title },
|
||||||
|
error: err,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return this.handleError(err, undefined, report?.jobtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,4 +5,4 @@
|
||||||
* 2.0.
|
* 2.0.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { handleUnavailable, RequestHandler } from './request_handler';
|
export { scheduledQueryFactory } from './scheduled_query';
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,362 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
KibanaRequest,
|
||||||
|
KibanaResponseFactory,
|
||||||
|
SavedObject,
|
||||||
|
SavedObjectsFindResponse,
|
||||||
|
SavedObjectsFindResult,
|
||||||
|
} from '@kbn/core/server';
|
||||||
|
import type { Logger } from '@kbn/core/server';
|
||||||
|
import { REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY } from '@kbn/reporting-server';
|
||||||
|
import { SearchResponse } from '@elastic/elasticsearch/lib/api/types';
|
||||||
|
import { RRule } from '@kbn/rrule';
|
||||||
|
import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils';
|
||||||
|
import { ReportApiJSON } from '@kbn/reporting-common/types';
|
||||||
|
import type { ReportingCore } from '../../..';
|
||||||
|
import type {
|
||||||
|
ListScheduledReportApiJSON,
|
||||||
|
ReportingUser,
|
||||||
|
ScheduledReportType,
|
||||||
|
} from '../../../types';
|
||||||
|
import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects';
|
||||||
|
import { ScheduledReportAuditAction, scheduledReportAuditEvent } from '../audit_events';
|
||||||
|
|
||||||
|
export const MAX_SCHEDULED_REPORT_LIST_SIZE = 100;
|
||||||
|
export const DEFAULT_SCHEDULED_REPORT_LIST_SIZE = 10;
|
||||||
|
|
||||||
|
const SCHEDULED_REPORT_ID_FIELD = 'scheduled_report_id';
|
||||||
|
const CREATED_AT_FIELD = 'created_at';
|
||||||
|
const getUsername = (user: ReportingUser) => (user ? user.username : false);
|
||||||
|
|
||||||
|
interface ApiResponse {
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
data: ListScheduledReportApiJSON[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmptyApiResponse = (page: number, perPage: number) => ({
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
total: 0,
|
||||||
|
data: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BulkOperationError {
|
||||||
|
message: string;
|
||||||
|
status?: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BulkDisableResult {
|
||||||
|
scheduled_report_ids: string[];
|
||||||
|
errors: BulkOperationError[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreatedAtSearchResponse = SearchResponse<{ created_at: string }>;
|
||||||
|
|
||||||
|
export function transformSingleResponse(
|
||||||
|
logger: Logger,
|
||||||
|
so: SavedObjectsFindResult<ScheduledReportType>,
|
||||||
|
lastResponse?: CreatedAtSearchResponse
|
||||||
|
) {
|
||||||
|
const id = so.id;
|
||||||
|
const lastRunForId = (lastResponse?.hits.hits ?? []).find(
|
||||||
|
(hit) => hit.fields?.[SCHEDULED_REPORT_ID_FIELD]?.[0] === id
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule = so.attributes.schedule;
|
||||||
|
const _rrule = new RRule({
|
||||||
|
...schedule.rrule,
|
||||||
|
dtstart: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload: ReportApiJSON['payload'] | undefined;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(so.attributes.payload);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to parse payload for scheduled report ${id}: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
created_at: so.attributes.createdAt,
|
||||||
|
created_by: so.attributes.createdBy,
|
||||||
|
enabled: so.attributes.enabled,
|
||||||
|
jobtype: so.attributes.jobType,
|
||||||
|
last_run: lastRunForId?._source?.[CREATED_AT_FIELD],
|
||||||
|
next_run: _rrule.after(new Date())?.toISOString(),
|
||||||
|
notification: so.attributes.notification,
|
||||||
|
payload,
|
||||||
|
schedule: so.attributes.schedule,
|
||||||
|
space_id: so.namespaces?.[0] ?? DEFAULT_SPACE_ID,
|
||||||
|
title: so.attributes.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformResponse(
|
||||||
|
logger: Logger,
|
||||||
|
result: SavedObjectsFindResponse<ScheduledReportType>,
|
||||||
|
lastResponse?: CreatedAtSearchResponse
|
||||||
|
): ApiResponse {
|
||||||
|
return {
|
||||||
|
page: result.page,
|
||||||
|
per_page: result.per_page,
|
||||||
|
total: result.total,
|
||||||
|
data: result.saved_objects.map((so) => transformSingleResponse(logger, so, lastResponse)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduledQueryFactory {
|
||||||
|
list(
|
||||||
|
logger: Logger,
|
||||||
|
req: KibanaRequest,
|
||||||
|
res: KibanaResponseFactory,
|
||||||
|
user: ReportingUser,
|
||||||
|
page: number,
|
||||||
|
size: number
|
||||||
|
): Promise<ApiResponse>;
|
||||||
|
bulkDisable(
|
||||||
|
logger: Logger,
|
||||||
|
req: KibanaRequest,
|
||||||
|
res: KibanaResponseFactory,
|
||||||
|
ids: string[],
|
||||||
|
user: ReportingUser
|
||||||
|
): Promise<BulkDisableResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQueryFactory {
|
||||||
|
return {
|
||||||
|
async list(logger, req, res, user, page = 1, size = DEFAULT_SCHEDULED_REPORT_LIST_SIZE) {
|
||||||
|
try {
|
||||||
|
const esClient = await reportingCore.getEsClient();
|
||||||
|
const auditLogger = await reportingCore.getAuditLogger(req);
|
||||||
|
const savedObjectsClient = await reportingCore.getScopedSoClient(req);
|
||||||
|
const username = getUsername(user);
|
||||||
|
|
||||||
|
// if user has Manage Reporting privileges, we can list
|
||||||
|
// scheduled reports for all users in this space, otherwise
|
||||||
|
// we will filter only to the scheduled reports created by the user
|
||||||
|
const canManageReporting = await reportingCore.canManageReportingForSpace(req);
|
||||||
|
|
||||||
|
const response = await savedObjectsClient.find<ScheduledReportType>({
|
||||||
|
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
page,
|
||||||
|
perPage: size,
|
||||||
|
...(!canManageReporting
|
||||||
|
? { filter: `scheduled_report.attributes.createdBy: "${username}"` }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return getEmptyApiResponse(page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledReportIdsAndName = response?.saved_objects.map((so) => ({
|
||||||
|
id: so.id,
|
||||||
|
name: so.attributes.title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!scheduledReportIdsAndName || scheduledReportIdsAndName.length === 0) {
|
||||||
|
return getEmptyApiResponse(page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduledReportIdsAndName.forEach(({ id, name }) =>
|
||||||
|
auditLogger.log(
|
||||||
|
scheduledReportAuditEvent({
|
||||||
|
action: ScheduledReportAuditAction.LIST,
|
||||||
|
savedObject: {
|
||||||
|
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastRunResponse;
|
||||||
|
try {
|
||||||
|
lastRunResponse = (await esClient.asInternalUser.search({
|
||||||
|
index: REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY,
|
||||||
|
size,
|
||||||
|
_source: [CREATED_AT_FIELD],
|
||||||
|
sort: [{ [CREATED_AT_FIELD]: { order: 'desc' } }],
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
filter: [
|
||||||
|
{
|
||||||
|
terms: {
|
||||||
|
[SCHEDULED_REPORT_ID_FIELD]: scheduledReportIdsAndName.map(({ id }) => id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
collapse: { field: SCHEDULED_REPORT_ID_FIELD },
|
||||||
|
})) as CreatedAtSearchResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// if no scheduled reports have run yet, we will get an error from the collapse query
|
||||||
|
// ignore these and return an empty last run
|
||||||
|
logger.warn(`Error getting last run for scheduled reports: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformResponse(logger, response, lastRunResponse);
|
||||||
|
} catch (error) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 500,
|
||||||
|
body: `Error listing scheduled reports: ${error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async bulkDisable(logger, req, res, ids, user) {
|
||||||
|
try {
|
||||||
|
const savedObjectsClient = await reportingCore.getScopedSoClient(req);
|
||||||
|
const taskManager = await reportingCore.getTaskManager();
|
||||||
|
const auditLogger = await reportingCore.getAuditLogger(req);
|
||||||
|
|
||||||
|
const bulkErrors: BulkOperationError[] = [];
|
||||||
|
const disabledScheduledReportIds: Set<string> = new Set();
|
||||||
|
let taskIdsToDisable: string[] = [];
|
||||||
|
|
||||||
|
const username = getUsername(user);
|
||||||
|
|
||||||
|
// if user has Manage Reporting privileges, they can disable
|
||||||
|
// scheduled reports for all users in this space
|
||||||
|
const canManageReporting = await reportingCore.canManageReportingForSpace(req);
|
||||||
|
|
||||||
|
const bulkGetResult = await savedObjectsClient.bulkGet<ScheduledReportType>(
|
||||||
|
ids.map((id) => ({ id, type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const scheduledReportSavedObjectsToUpdate: Array<SavedObject<ScheduledReportType>> = [];
|
||||||
|
for (const so of bulkGetResult.saved_objects) {
|
||||||
|
if (so.error) {
|
||||||
|
bulkErrors.push({
|
||||||
|
message: so.error.message,
|
||||||
|
status: so.error.statusCode,
|
||||||
|
id: so.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// check if user is allowed to update this scheduled report
|
||||||
|
if (so.attributes.createdBy !== username && !canManageReporting) {
|
||||||
|
bulkErrors.push({
|
||||||
|
message: `Not found.`,
|
||||||
|
status: 404,
|
||||||
|
id: so.id,
|
||||||
|
});
|
||||||
|
logger.warn(
|
||||||
|
`User "${username}" attempted to disable scheduled report "${so.id}" created by "${so.attributes.createdBy}" without sufficient privileges.`
|
||||||
|
);
|
||||||
|
auditLogger.log(
|
||||||
|
scheduledReportAuditEvent({
|
||||||
|
action: ScheduledReportAuditAction.DISABLE,
|
||||||
|
savedObject: {
|
||||||
|
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
id: so.id,
|
||||||
|
name: so?.attributes?.title,
|
||||||
|
},
|
||||||
|
error: new Error(`Not found.`),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (so.attributes.enabled === false) {
|
||||||
|
logger.debug(`Scheduled report ${so.id} is already disabled`);
|
||||||
|
disabledScheduledReportIds.add(so.id);
|
||||||
|
} else {
|
||||||
|
auditLogger.log(
|
||||||
|
scheduledReportAuditEvent({
|
||||||
|
action: ScheduledReportAuditAction.DISABLE,
|
||||||
|
savedObject: {
|
||||||
|
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
id: so.id,
|
||||||
|
name: so.attributes.title,
|
||||||
|
},
|
||||||
|
outcome: 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
scheduledReportSavedObjectsToUpdate.push(so);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing to update, return early
|
||||||
|
if (scheduledReportSavedObjectsToUpdate.length > 0) {
|
||||||
|
const bulkUpdateResult = await savedObjectsClient.bulkUpdate<ScheduledReportType>(
|
||||||
|
scheduledReportSavedObjectsToUpdate.map((so) => ({
|
||||||
|
id: so.id,
|
||||||
|
type: so.type,
|
||||||
|
attributes: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const so of bulkUpdateResult.saved_objects) {
|
||||||
|
if (so.error) {
|
||||||
|
bulkErrors.push({
|
||||||
|
message: so.error.message,
|
||||||
|
status: so.error.statusCode,
|
||||||
|
id: so.id,
|
||||||
|
});
|
||||||
|
auditLogger.log(
|
||||||
|
scheduledReportAuditEvent({
|
||||||
|
action: ScheduledReportAuditAction.DISABLE,
|
||||||
|
savedObject: {
|
||||||
|
type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
id: so.id,
|
||||||
|
name: so?.attributes?.title,
|
||||||
|
},
|
||||||
|
error: new Error(so.error.message),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
taskIdsToDisable.push(so.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
scheduled_report_ids: [...disabledScheduledReportIds],
|
||||||
|
errors: bulkErrors,
|
||||||
|
total: disabledScheduledReportIds.size + bulkErrors.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// it's possible that the scheduled_report saved object was disabled but
|
||||||
|
// task disabling failed so add the list of already disabled IDs
|
||||||
|
// task manager filters out disabled tasks so this will not cause extra load
|
||||||
|
taskIdsToDisable = taskIdsToDisable.concat([...disabledScheduledReportIds]);
|
||||||
|
|
||||||
|
const resultFromDisablingTasks = await taskManager.bulkDisable(taskIdsToDisable);
|
||||||
|
for (const error of resultFromDisablingTasks.errors) {
|
||||||
|
bulkErrors.push({
|
||||||
|
message: `Scheduled report disabled but task disabling failed due to: ${error.error.message}`,
|
||||||
|
status: error.error.statusCode,
|
||||||
|
id: error.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const result of resultFromDisablingTasks.tasks) {
|
||||||
|
disabledScheduledReportIds.add(result.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduled_report_ids: [...disabledScheduledReportIds],
|
||||||
|
errors: bulkErrors,
|
||||||
|
total: disabledScheduledReportIds.size + bulkErrors.length,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw res.customError({
|
||||||
|
statusCode: 500,
|
||||||
|
body: `Error disabling scheduled reports: ${error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -11,7 +11,9 @@ import { registerDeprecationsRoutes } from './internal/deprecations/deprecations
|
||||||
import { registerDiagnosticRoutes } from './internal/diagnostic';
|
import { registerDiagnosticRoutes } from './internal/diagnostic';
|
||||||
import { registerHealthRoute } from './internal/health';
|
import { registerHealthRoute } from './internal/health';
|
||||||
import { registerGenerationRoutesInternal } from './internal/generate/generate_from_jobparams';
|
import { registerGenerationRoutesInternal } from './internal/generate/generate_from_jobparams';
|
||||||
|
import { registerScheduleRoutesInternal } from './internal/schedule/schedule_from_jobparams';
|
||||||
import { registerJobInfoRoutesInternal } from './internal/management/jobs';
|
import { registerJobInfoRoutesInternal } from './internal/management/jobs';
|
||||||
|
import { registerScheduledRoutesInternal } from './internal/management/scheduled';
|
||||||
import { registerGenerationRoutesPublic } from './public/generate_from_jobparams';
|
import { registerGenerationRoutesPublic } from './public/generate_from_jobparams';
|
||||||
import { registerJobInfoRoutesPublic } from './public/jobs';
|
import { registerJobInfoRoutesPublic } from './public/jobs';
|
||||||
|
|
||||||
|
@ -20,7 +22,9 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) {
|
||||||
registerHealthRoute(reporting, logger);
|
registerHealthRoute(reporting, logger);
|
||||||
registerDiagnosticRoutes(reporting, logger);
|
registerDiagnosticRoutes(reporting, logger);
|
||||||
registerGenerationRoutesInternal(reporting, logger);
|
registerGenerationRoutesInternal(reporting, logger);
|
||||||
|
registerScheduleRoutesInternal(reporting, logger);
|
||||||
registerJobInfoRoutesInternal(reporting);
|
registerJobInfoRoutesInternal(reporting);
|
||||||
|
registerScheduledRoutesInternal(reporting, logger);
|
||||||
registerGenerationRoutesPublic(reporting, logger);
|
registerGenerationRoutesPublic(reporting, logger);
|
||||||
registerJobInfoRoutesPublic(reporting);
|
registerJobInfoRoutesPublic(reporting);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type { Logger } from '@kbn/core/server';
|
||||||
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
||||||
import type { ReportingCore } from '../../..';
|
import type { ReportingCore } from '../../..';
|
||||||
import { authorizedUserPreRouting } from '../../common';
|
import { authorizedUserPreRouting } from '../../common';
|
||||||
import { RequestHandler } from '../../common/generate';
|
import { GenerateRequestHandler } from '../../common/request_handler';
|
||||||
|
|
||||||
const { GENERATE_PREFIX } = INTERNAL_ROUTES;
|
const { GENERATE_PREFIX } = INTERNAL_ROUTES;
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge
|
||||||
requiredPrivileges: kibanaAccessControlTags,
|
requiredPrivileges: kibanaAccessControlTags,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
validate: RequestHandler.getValidation(),
|
validate: GenerateRequestHandler.getValidation(),
|
||||||
options: {
|
options: {
|
||||||
tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`),
|
tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`),
|
||||||
access: 'internal',
|
access: 'internal',
|
||||||
|
@ -38,17 +38,20 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge
|
||||||
},
|
},
|
||||||
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
||||||
try {
|
try {
|
||||||
const requestHandler = new RequestHandler(
|
const requestHandler = new GenerateRequestHandler({
|
||||||
reporting,
|
reporting,
|
||||||
user,
|
user,
|
||||||
context,
|
context,
|
||||||
path,
|
path,
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
logger
|
logger,
|
||||||
);
|
});
|
||||||
const jobParams = requestHandler.getJobParams();
|
const jobParams = requestHandler.getJobParams();
|
||||||
return await requestHandler.handleGenerateRequest(req.params.exportType, jobParams);
|
return await requestHandler.handleRequest({
|
||||||
|
exportTypeId: req.params.exportType,
|
||||||
|
jobParams,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof KibanaResponse) {
|
if (err instanceof KibanaResponse) {
|
||||||
return err;
|
return err;
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
||||||
import { ROUTE_TAG_CAN_REDIRECT } from '@kbn/security-plugin/server';
|
import { ROUTE_TAG_CAN_REDIRECT } from '@kbn/security-plugin/server';
|
||||||
import { ReportingCore } from '../../..';
|
import { ReportingCore } from '../../..';
|
||||||
import { authorizedUserPreRouting, getCounters } from '../../common';
|
import { authorizedUserPreRouting, getCounters } from '../../common';
|
||||||
import { handleUnavailable } from '../../common/generate';
|
import { handleUnavailable } from '../../common/request_handler';
|
||||||
import {
|
import {
|
||||||
commonJobsRouteHandlerFactory,
|
commonJobsRouteHandlerFactory,
|
||||||
jobManagementPreRouting,
|
jobManagementPreRouting,
|
||||||
|
|
|
@ -0,0 +1,142 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { schema } from '@kbn/config-schema';
|
||||||
|
import type { Logger } from '@kbn/core/server';
|
||||||
|
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
||||||
|
import { KibanaResponse } from '@kbn/core-http-router-server-internal';
|
||||||
|
import { ReportingCore } from '../../..';
|
||||||
|
import { authorizedUserPreRouting, getCounters } from '../../common';
|
||||||
|
import { handleUnavailable } from '../../common/request_handler';
|
||||||
|
import { scheduledQueryFactory } from '../../common/scheduled';
|
||||||
|
import {
|
||||||
|
DEFAULT_SCHEDULED_REPORT_LIST_SIZE,
|
||||||
|
MAX_SCHEDULED_REPORT_LIST_SIZE,
|
||||||
|
} from '../../common/scheduled/scheduled_query';
|
||||||
|
|
||||||
|
const { SCHEDULED } = INTERNAL_ROUTES;
|
||||||
|
|
||||||
|
export function registerScheduledRoutesInternal(reporting: ReportingCore, logger: Logger) {
|
||||||
|
const setupDeps = reporting.getPluginSetupDeps();
|
||||||
|
const { router } = setupDeps;
|
||||||
|
const scheduledQuery = scheduledQueryFactory(reporting);
|
||||||
|
|
||||||
|
const registerInternalGetList = () => {
|
||||||
|
// list scheduled jobs in the queue, paginated
|
||||||
|
const path = SCHEDULED.LIST;
|
||||||
|
router.get(
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
enabled: false,
|
||||||
|
reason:
|
||||||
|
'This route is opted out from authorization because reporting uses its own authorization model.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
query: schema.object({
|
||||||
|
page: schema.string({ defaultValue: '1' }),
|
||||||
|
size: schema.string({
|
||||||
|
defaultValue: `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`,
|
||||||
|
validate: (value: string) => {
|
||||||
|
try {
|
||||||
|
const size = parseInt(value, 10);
|
||||||
|
if (size < 1 || size > MAX_SCHEDULED_REPORT_LIST_SIZE) {
|
||||||
|
return `size must be between 1 and ${MAX_SCHEDULED_REPORT_LIST_SIZE}: size: ${value}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return `size must be an integer: size: ${value}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
options: { access: 'internal' },
|
||||||
|
},
|
||||||
|
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
||||||
|
try {
|
||||||
|
const counters = getCounters(req.route.method, path, reporting.getUsageCounter());
|
||||||
|
|
||||||
|
// ensure the async dependencies are loaded
|
||||||
|
if (!context.reporting) {
|
||||||
|
return handleUnavailable(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
page: queryPage = '1',
|
||||||
|
size: querySize = `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`,
|
||||||
|
} = req.query;
|
||||||
|
const page = parseInt(queryPage, 10) || 1;
|
||||||
|
const size = Math.min(
|
||||||
|
MAX_SCHEDULED_REPORT_LIST_SIZE,
|
||||||
|
parseInt(querySize, 10) || DEFAULT_SCHEDULED_REPORT_LIST_SIZE
|
||||||
|
);
|
||||||
|
const results = await scheduledQuery.list(logger, req, res, user, page, size);
|
||||||
|
|
||||||
|
counters.usageCounter();
|
||||||
|
|
||||||
|
return res.ok({ body: results, headers: { 'content-type': 'application/json' } });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof KibanaResponse) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerInternalPatchBulkDisable = () => {
|
||||||
|
// allow scheduled reports to be disabled
|
||||||
|
const path = SCHEDULED.BULK_DISABLE;
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
enabled: false,
|
||||||
|
reason: 'This route is opted out from authorization',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
body: schema.object({
|
||||||
|
ids: schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
options: { access: 'internal' },
|
||||||
|
},
|
||||||
|
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
||||||
|
try {
|
||||||
|
const counters = getCounters(req.route.method, path, reporting.getUsageCounter());
|
||||||
|
|
||||||
|
// ensure the async dependencies are loaded
|
||||||
|
if (!context.reporting) {
|
||||||
|
return handleUnavailable(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ids } = req.body;
|
||||||
|
|
||||||
|
const results = await scheduledQuery.bulkDisable(logger, req, res, ids, user);
|
||||||
|
|
||||||
|
counters.usageCounter();
|
||||||
|
|
||||||
|
return res.ok({ body: results, headers: { 'content-type': 'application/json' } });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof KibanaResponse) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerInternalGetList();
|
||||||
|
registerInternalPatchBulkDisable();
|
||||||
|
}
|
|
@ -0,0 +1,360 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import rison from '@kbn/rison';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import supertest from 'supertest';
|
||||||
|
|
||||||
|
import { setupServer } from '@kbn/core-test-helpers-test-utils';
|
||||||
|
import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks';
|
||||||
|
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
|
||||||
|
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
||||||
|
import { PdfExportType } from '@kbn/reporting-export-types-pdf';
|
||||||
|
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
|
||||||
|
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
|
||||||
|
|
||||||
|
import { ReportingCore } from '../../../..';
|
||||||
|
import { reportingMock } from '../../../../mocks';
|
||||||
|
import {
|
||||||
|
createMockPluginSetup,
|
||||||
|
createMockPluginStart,
|
||||||
|
createMockReportingCore,
|
||||||
|
} from '../../../../test_helpers';
|
||||||
|
import { ReportingRequestHandlerContext } from '../../../../types';
|
||||||
|
import { registerScheduleRoutesInternal } from '../schedule_from_jobparams';
|
||||||
|
import { FakeRawRequest, KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server';
|
||||||
|
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||||
|
|
||||||
|
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
|
||||||
|
|
||||||
|
const fakeRawRequest: FakeRawRequest = {
|
||||||
|
headers: {
|
||||||
|
authorization: `ApiKey skdjtq4u543yt3rhewrh`,
|
||||||
|
},
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe(`POST ${INTERNAL_ROUTES.SCHEDULE_PREFIX}`, () => {
|
||||||
|
const reportingSymbol = Symbol('reporting');
|
||||||
|
let server: SetupServerReturn['server'];
|
||||||
|
let httpSetup: SetupServerReturn['httpSetup'];
|
||||||
|
let mockExportTypesRegistry: ExportTypesRegistry;
|
||||||
|
let reportingCore: ReportingCore;
|
||||||
|
let soClient: SavedObjectsClientContract;
|
||||||
|
|
||||||
|
const mockConfigSchema = createMockConfigSchema({
|
||||||
|
queue: { indexInterval: 'year', timeout: 10000, pollEnabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockLogger = loggingSystemMock.createLogger();
|
||||||
|
const mockCoreSetup = coreMock.createSetup();
|
||||||
|
const auditLogger = auditLoggerMock.create();
|
||||||
|
const mockPdfExportType = new PdfExportType(
|
||||||
|
mockCoreSetup,
|
||||||
|
mockConfigSchema,
|
||||||
|
mockLogger,
|
||||||
|
coreMock.createPluginInitializerContext(mockConfigSchema)
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
({ server, httpSetup } = await setupServer(reportingSymbol));
|
||||||
|
httpSetup.registerRouteHandlerContext<ReportingRequestHandlerContext, 'reporting'>(
|
||||||
|
reportingSymbol,
|
||||||
|
'reporting',
|
||||||
|
() => reportingMock.createStart()
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockSetupDeps = createMockPluginSetup({
|
||||||
|
security: { license: { isEnabled: () => true, getFeature: () => true } },
|
||||||
|
router: httpSetup.createRouter(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockStartDeps = await createMockPluginStart(
|
||||||
|
{
|
||||||
|
licensing: {
|
||||||
|
...licensingMock.createStart(),
|
||||||
|
license$: new BehaviorSubject({
|
||||||
|
isActive: true,
|
||||||
|
isAvailable: true,
|
||||||
|
type: 'gold',
|
||||||
|
getFeature: () => true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
securityService: {
|
||||||
|
authc: {
|
||||||
|
apiKeys: { areAPIKeysEnabled: () => true },
|
||||||
|
getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }),
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
asScoped: () => auditLogger,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mockConfigSchema
|
||||||
|
);
|
||||||
|
|
||||||
|
reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps);
|
||||||
|
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({
|
||||||
|
isSufficientlySecure: true,
|
||||||
|
hasPermanentEncryptionKey: true,
|
||||||
|
areNotificationsEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockExportTypesRegistry = new ExportTypesRegistry();
|
||||||
|
mockExportTypesRegistry.register(mockPdfExportType);
|
||||||
|
|
||||||
|
soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest);
|
||||||
|
soClient.create = jest.fn().mockImplementation(async (_, opts) => {
|
||||||
|
return {
|
||||||
|
id: 'foo',
|
||||||
|
attributes: opts,
|
||||||
|
type: 'scheduled-report',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 if there are no job params', async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.expect(400)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot(
|
||||||
|
'"[request body]: expected a plain object value, but found [null] instead."'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 if job params body is invalid', async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({ jobParams: `foo:`, schedule: { rrule: { freq: 1, interval: 2 } } })
|
||||||
|
.expect(400)
|
||||||
|
.then(({ body }) => expect(body.message).toMatchInlineSnapshot('"invalid rison: foo:"'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 export type is invalid', async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/TonyHawksProSkater2`)
|
||||||
|
.send({
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
jobParams: rison.encode({ title: `abc` }),
|
||||||
|
})
|
||||||
|
.expect(400)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot('"Invalid export-type of TonyHawksProSkater2"')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 on invalid browser timezone', async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
})
|
||||||
|
.expect(400)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot(`"Invalid timezone \\"America/Amsterdam\\"."`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 on invalid rrule', async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
|
||||||
|
schedule: { rrule: { freq: 6, interval: 2 } },
|
||||||
|
})
|
||||||
|
.expect(400)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot(`
|
||||||
|
"[request body.schedule.rrule]: types that failed validation:
|
||||||
|
- [request body.schedule.rrule.0.freq]: expected value to equal [1]
|
||||||
|
- [request body.schedule.rrule.1.freq]: expected value to equal [2]
|
||||||
|
- [request body.schedule.rrule.2.freq]: expected value to equal [3]"
|
||||||
|
`)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 on invalid notification list', async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: {
|
||||||
|
email: {
|
||||||
|
to: 'single@email.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(400)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot(
|
||||||
|
`"[request body.notification.email.to]: could not parse array value from json input"`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 on empty notification list', async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
notification: {
|
||||||
|
email: {
|
||||||
|
to: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(400)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot(
|
||||||
|
`"[request body.notification.email]: At least one email address is required"`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 on when no permanent encryption key', async () => {
|
||||||
|
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
|
||||||
|
isSufficientlySecure: true,
|
||||||
|
hasPermanentEncryptionKey: false,
|
||||||
|
areNotificationsEnabled: false,
|
||||||
|
});
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({ title: `abc` }),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
})
|
||||||
|
.expect(403)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot(
|
||||||
|
`"Permanent encryption key must be set for scheduled reporting"`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 on when not sufficiently secure', async () => {
|
||||||
|
jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({
|
||||||
|
isSufficientlySecure: false,
|
||||||
|
hasPermanentEncryptionKey: true,
|
||||||
|
areNotificationsEnabled: false,
|
||||||
|
});
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({ title: `abc` }),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
})
|
||||||
|
.expect(403)
|
||||||
|
.then(({ body }) =>
|
||||||
|
expect(body.message).toMatchInlineSnapshot(
|
||||||
|
`"Security and API keys must be enabled for scheduled reporting"`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 if job handler throws an error', async () => {
|
||||||
|
soClient.create = jest.fn().mockRejectedValue('silly');
|
||||||
|
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({ title: `abc` }),
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
})
|
||||||
|
.expect(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`returns 200 if job handler doesn't error`, async () => {
|
||||||
|
registerScheduleRoutesInternal(reportingCore, mockLogger);
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
await supertest(httpSetup.server.listener)
|
||||||
|
.post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`)
|
||||||
|
.send({
|
||||||
|
jobParams: rison.encode({
|
||||||
|
title: `abc`,
|
||||||
|
layout: { id: 'test' },
|
||||||
|
objectType: 'canvas workpad',
|
||||||
|
}),
|
||||||
|
notification: {
|
||||||
|
email: {
|
||||||
|
bcc: ['single@email.com'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
})
|
||||||
|
.expect(200)
|
||||||
|
.then(({ body }) => {
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
job: {
|
||||||
|
created_by: 'Tom Riddle',
|
||||||
|
id: 'foo',
|
||||||
|
jobtype: 'printable_pdf_v2',
|
||||||
|
payload: {
|
||||||
|
isDeprecated: false,
|
||||||
|
layout: {
|
||||||
|
id: 'test',
|
||||||
|
},
|
||||||
|
objectType: 'canvas workpad',
|
||||||
|
title: 'abc',
|
||||||
|
version: '7.14.0',
|
||||||
|
},
|
||||||
|
schedule: { rrule: { freq: 1, interval: 2 } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { KibanaResponse } from '@kbn/core-http-router-server-internal';
|
||||||
|
import type { Logger } from '@kbn/core/server';
|
||||||
|
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
||||||
|
import type { ReportingCore } from '../../..';
|
||||||
|
import { authorizedUserPreRouting } from '../../common';
|
||||||
|
import { ScheduleRequestHandler } from '../../common/request_handler';
|
||||||
|
|
||||||
|
const { SCHEDULE_PREFIX } = INTERNAL_ROUTES;
|
||||||
|
|
||||||
|
export function registerScheduleRoutesInternal(reporting: ReportingCore, logger: Logger) {
|
||||||
|
const setupDeps = reporting.getPluginSetupDeps();
|
||||||
|
const { router } = setupDeps;
|
||||||
|
|
||||||
|
const kibanaAccessControlTags = ['generateReport'];
|
||||||
|
|
||||||
|
const registerInternalPostScheduleEndpoint = () => {
|
||||||
|
const path = `${SCHEDULE_PREFIX}/{exportType}`;
|
||||||
|
router.post(
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
security: {
|
||||||
|
authz: {
|
||||||
|
requiredPrivileges: kibanaAccessControlTags,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
validate: ScheduleRequestHandler.getValidation(),
|
||||||
|
options: {
|
||||||
|
tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`),
|
||||||
|
access: 'internal',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
||||||
|
try {
|
||||||
|
const requestHandler = new ScheduleRequestHandler({
|
||||||
|
reporting,
|
||||||
|
user,
|
||||||
|
context,
|
||||||
|
path,
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const jobParams = requestHandler.getJobParams();
|
||||||
|
const schedule = requestHandler.getSchedule();
|
||||||
|
const notification = requestHandler.getNotification();
|
||||||
|
|
||||||
|
return await requestHandler.handleRequest({
|
||||||
|
exportTypeId: req.params.exportType,
|
||||||
|
jobParams,
|
||||||
|
schedule,
|
||||||
|
notification,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof KibanaResponse) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerInternalPostScheduleEndpoint();
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ import type { Logger } from '@kbn/core/server';
|
||||||
import { PUBLIC_ROUTES } from '@kbn/reporting-common';
|
import { PUBLIC_ROUTES } from '@kbn/reporting-common';
|
||||||
import type { ReportingCore } from '../..';
|
import type { ReportingCore } from '../..';
|
||||||
import { authorizedUserPreRouting } from '../common';
|
import { authorizedUserPreRouting } from '../common';
|
||||||
import { RequestHandler } from '../common/generate';
|
import { GenerateRequestHandler } from '../common/request_handler';
|
||||||
|
|
||||||
export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: Logger) {
|
export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: Logger) {
|
||||||
const setupDeps = reporting.getPluginSetupDeps();
|
const setupDeps = reporting.getPluginSetupDeps();
|
||||||
|
@ -28,7 +28,7 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger:
|
||||||
requiredPrivileges: kibanaAccessControlTags,
|
requiredPrivileges: kibanaAccessControlTags,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
validate: RequestHandler.getValidation(),
|
validate: GenerateRequestHandler.getValidation(),
|
||||||
options: {
|
options: {
|
||||||
tags: kibanaAccessControlTags.map((controlAccessTag) => `access:${controlAccessTag}`),
|
tags: kibanaAccessControlTags.map((controlAccessTag) => `access:${controlAccessTag}`),
|
||||||
access: 'public',
|
access: 'public',
|
||||||
|
@ -36,19 +36,19 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger:
|
||||||
},
|
},
|
||||||
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
||||||
try {
|
try {
|
||||||
const requestHandler = new RequestHandler(
|
const requestHandler = new GenerateRequestHandler({
|
||||||
reporting,
|
reporting,
|
||||||
user,
|
user,
|
||||||
context,
|
context,
|
||||||
path,
|
path,
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
logger
|
logger,
|
||||||
);
|
});
|
||||||
return await requestHandler.handleGenerateRequest(
|
return await requestHandler.handleRequest({
|
||||||
req.params.exportType,
|
exportTypeId: req.params.exportType,
|
||||||
requestHandler.getJobParams()
|
jobParams: requestHandler.getJobParams(),
|
||||||
);
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof KibanaResponse) {
|
if (err instanceof KibanaResponse) {
|
||||||
return err;
|
return err;
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { SavedObjectsServiceSetup } from '@kbn/core/server';
|
||||||
|
import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
|
||||||
|
import { scheduledReportMappings, scheduledReportModelVersions } from './scheduled_report';
|
||||||
|
|
||||||
|
export const SCHEDULED_REPORT_SAVED_OBJECT_TYPE = 'scheduled_report';
|
||||||
|
|
||||||
|
export function setupSavedObjects(savedObjects: SavedObjectsServiceSetup) {
|
||||||
|
savedObjects.registerType({
|
||||||
|
name: SCHEDULED_REPORT_SAVED_OBJECT_TYPE,
|
||||||
|
indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX,
|
||||||
|
hidden: true,
|
||||||
|
namespaceType: 'multiple',
|
||||||
|
mappings: scheduledReportMappings,
|
||||||
|
management: { importableAndExportable: false },
|
||||||
|
modelVersions: scheduledReportModelVersions,
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { scheduledReportMappings } from './mappings';
|
||||||
|
export { scheduledReportModelVersions } from './model_versions';
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SavedObjectsTypeMappingDefinition } from '@kbn/core/server';
|
||||||
|
|
||||||
|
export const scheduledReportMappings: SavedObjectsTypeMappingDefinition = {
|
||||||
|
dynamic: false,
|
||||||
|
properties: {
|
||||||
|
createdBy: {
|
||||||
|
type: 'keyword',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server';
|
||||||
|
import { rawScheduledReportSchemaV1 } from './schemas';
|
||||||
|
|
||||||
|
export const scheduledReportModelVersions: SavedObjectsModelVersionMap = {
|
||||||
|
'1': {
|
||||||
|
changes: [],
|
||||||
|
schemas: {
|
||||||
|
forwardCompatibility: rawScheduledReportSchemaV1.extends({}, { unknowns: 'ignore' }),
|
||||||
|
create: rawScheduledReportSchemaV1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,8 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { rawScheduledReportSchema as rawScheduledReportSchemaV1 } from './v1';
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TypeOf } from '@kbn/config-schema';
|
||||||
|
import type { rawNotificationSchema, rawScheduledReportSchema } from './v1';
|
||||||
|
|
||||||
|
export type RawNotification = TypeOf<typeof rawNotificationSchema>;
|
||||||
|
export type RawScheduledReport = TypeOf<typeof rawScheduledReportSchema>;
|
|
@ -0,0 +1,57 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { schema } from '@kbn/config-schema';
|
||||||
|
import { scheduleRruleSchema } from '@kbn/task-manager-plugin/server';
|
||||||
|
|
||||||
|
const rawLayoutIdSchema = schema.oneOf([
|
||||||
|
schema.literal('preserve_layout'),
|
||||||
|
schema.literal('print'),
|
||||||
|
schema.literal('canvas'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const rawNotificationSchema = schema.object({
|
||||||
|
email: schema.maybe(
|
||||||
|
schema.object(
|
||||||
|
{
|
||||||
|
to: schema.maybe(schema.arrayOf(schema.string())),
|
||||||
|
bcc: schema.maybe(schema.arrayOf(schema.string())),
|
||||||
|
cc: schema.maybe(schema.arrayOf(schema.string())),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validate: (value) => {
|
||||||
|
const allEmails = new Set([
|
||||||
|
...(value.to || []),
|
||||||
|
...(value.bcc || []),
|
||||||
|
...(value.cc || []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (allEmails.size === 0) {
|
||||||
|
return 'At least one email address is required';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rawScheduledReportSchema = schema.object({
|
||||||
|
createdAt: schema.string(),
|
||||||
|
createdBy: schema.string(),
|
||||||
|
enabled: schema.boolean(),
|
||||||
|
jobType: schema.string(),
|
||||||
|
meta: schema.object({
|
||||||
|
isDeprecated: schema.maybe(schema.boolean()),
|
||||||
|
layout: schema.maybe(rawLayoutIdSchema),
|
||||||
|
objectType: schema.string(),
|
||||||
|
}),
|
||||||
|
migrationVersion: schema.maybe(schema.string()),
|
||||||
|
notification: schema.maybe(rawNotificationSchema),
|
||||||
|
payload: schema.string(),
|
||||||
|
schedule: scheduleRruleSchema,
|
||||||
|
title: schema.string(),
|
||||||
|
});
|
|
@ -0,0 +1,115 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
|
||||||
|
import { notificationsMock } from '@kbn/notifications-plugin/server/mocks';
|
||||||
|
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
|
||||||
|
import { set } from '@kbn/safer-lodash-set';
|
||||||
|
import { ReportingCore } from '../..';
|
||||||
|
import { createMockReportingCore } from '../../test_helpers';
|
||||||
|
import { EmailNotificationService } from './email_notification_service';
|
||||||
|
|
||||||
|
describe('EmailNotificationService', () => {
|
||||||
|
const notifications = notificationsMock.createStart();
|
||||||
|
let mockEsClient: ReturnType<typeof elasticsearchServiceMock.createElasticsearchClient>;
|
||||||
|
let emailNotificationService: EmailNotificationService;
|
||||||
|
let mockCore: ReportingCore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
notifications.isEmailServiceAvailable.mockReturnValue(true);
|
||||||
|
emailNotificationService = new EmailNotificationService({
|
||||||
|
notifications,
|
||||||
|
});
|
||||||
|
const reportingConfig = {
|
||||||
|
index: '.reporting-test',
|
||||||
|
queue: { indexInterval: 'week' },
|
||||||
|
statefulSettings: { enabled: true },
|
||||||
|
};
|
||||||
|
mockCore = await createMockReportingCore(createMockConfigSchema(reportingConfig));
|
||||||
|
mockEsClient = (await mockCore.getEsClient()).asInternalUser as typeof mockEsClient;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notify()', async () => {
|
||||||
|
const base64Content = Buffer.from('test-output').toString('base64');
|
||||||
|
mockEsClient.search.mockResponse(
|
||||||
|
set<any>({}, 'hits.hits.0._source', {
|
||||||
|
jobtype: 'pdf',
|
||||||
|
output: {
|
||||||
|
content: base64Content,
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await emailNotificationService.notify({
|
||||||
|
reporting: mockCore,
|
||||||
|
index: '.reporting-test-1234',
|
||||||
|
id: '1234',
|
||||||
|
filename: 'test-report.pdf',
|
||||||
|
contentType: 'test-content-type',
|
||||||
|
relatedObject: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
type: 'scheduled-report',
|
||||||
|
namespace: 'space1',
|
||||||
|
},
|
||||||
|
emailParams: {
|
||||||
|
to: ['test@test.com'],
|
||||||
|
subject: 'Scheduled report for 04/18/2025',
|
||||||
|
spaceId: 'space1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(notifications.getEmailService().sendAttachmentEmail).toHaveBeenCalledWith({
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
|
||||||
|
contentType: 'test-content-type',
|
||||||
|
encoding: 'base64',
|
||||||
|
filename: 'test-report.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
context: {
|
||||||
|
relatedObjects: [
|
||||||
|
{
|
||||||
|
id: 'report-so-id',
|
||||||
|
namespace: 'space1',
|
||||||
|
type: 'scheduled-report',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
message: 'Your scheduled report is attached for you to download or share.',
|
||||||
|
spaceId: 'space1',
|
||||||
|
subject: 'Scheduled report for 04/18/2025',
|
||||||
|
to: ['test@test.com'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws an error when the email service is not available', async () => {
|
||||||
|
notifications.isEmailServiceAvailable.mockReturnValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
emailNotificationService.notify({
|
||||||
|
reporting: mockCore,
|
||||||
|
index: '.reporting-test-1234',
|
||||||
|
id: '1234',
|
||||||
|
filename: 'test-report.pdf',
|
||||||
|
contentType: 'test-content-type',
|
||||||
|
relatedObject: {
|
||||||
|
id: 'report-so-id',
|
||||||
|
type: 'scheduled-report',
|
||||||
|
namespace: 'space1',
|
||||||
|
},
|
||||||
|
emailParams: {
|
||||||
|
to: ['test@test.com'],
|
||||||
|
subject: 'Scheduled report for 04/18/2025',
|
||||||
|
spaceId: 'space1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).rejects.toThrowErrorMatchingInlineSnapshot(`"Email notification service is not available"`);
|
||||||
|
|
||||||
|
expect(notifications.getEmailService().sendAttachmentEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,81 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
|
||||||
|
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
||||||
|
import type { NotificationService, NotifyArgs } from './types';
|
||||||
|
import { ReportingCore } from '../..';
|
||||||
|
import { getContentStream } from '../../lib';
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
content: string;
|
||||||
|
contentType?: string;
|
||||||
|
encoding?: string;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailNotificationService implements NotificationService {
|
||||||
|
private readonly notifications: NotificationsPluginStart;
|
||||||
|
|
||||||
|
constructor({ notifications }: { notifications: NotificationsPluginStart }) {
|
||||||
|
this.notifications = notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAttachments(
|
||||||
|
reporting: ReportingCore,
|
||||||
|
index: string,
|
||||||
|
id: string,
|
||||||
|
filename: string,
|
||||||
|
contentType?: string | null
|
||||||
|
): Promise<Attachment[]> {
|
||||||
|
const stream = await getContentStream(reporting, { id, index });
|
||||||
|
const buffers: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
buffers.push(chunk);
|
||||||
|
}
|
||||||
|
const content = Buffer.concat(buffers);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
content: content.toString('base64'),
|
||||||
|
...(contentType ? { contentType } : {}),
|
||||||
|
filename,
|
||||||
|
encoding: 'base64',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async notify({
|
||||||
|
reporting,
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
contentType,
|
||||||
|
filename,
|
||||||
|
relatedObject,
|
||||||
|
emailParams,
|
||||||
|
}: NotifyArgs) {
|
||||||
|
if (!this.notifications.isEmailServiceAvailable()) {
|
||||||
|
throw new Error('Email notification service is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await this.getAttachments(reporting, index, id, filename, contentType);
|
||||||
|
const { to, bcc, cc, subject, spaceId } = emailParams;
|
||||||
|
const message = 'Your scheduled report is attached for you to download or share.';
|
||||||
|
await this.notifications.getEmailService().sendAttachmentEmail({
|
||||||
|
to,
|
||||||
|
bcc,
|
||||||
|
cc,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
attachments,
|
||||||
|
spaceId: spaceId ?? DEFAULT_SPACE_ID,
|
||||||
|
context: {
|
||||||
|
relatedObjects: [relatedObject],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
import { RelatedSavedObject } from '@kbn/notifications-plugin/server/services/types';
|
||||||
|
import { ReportingCore } from '../..';
|
||||||
|
|
||||||
|
export interface NotifyArgs {
|
||||||
|
reporting: ReportingCore;
|
||||||
|
index: string;
|
||||||
|
id: string;
|
||||||
|
contentType?: string | null;
|
||||||
|
filename: string;
|
||||||
|
relatedObject: RelatedSavedObject;
|
||||||
|
emailParams: {
|
||||||
|
to?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
cc?: string[];
|
||||||
|
subject: string;
|
||||||
|
spaceId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationService {
|
||||||
|
notify: (args: NotifyArgs) => Promise<void>;
|
||||||
|
}
|
|
@ -15,8 +15,10 @@ import {
|
||||||
docLinksServiceMock,
|
docLinksServiceMock,
|
||||||
elasticsearchServiceMock,
|
elasticsearchServiceMock,
|
||||||
loggingSystemMock,
|
loggingSystemMock,
|
||||||
|
savedObjectsClientMock,
|
||||||
statusServiceMock,
|
statusServiceMock,
|
||||||
} from '@kbn/core/server/mocks';
|
} from '@kbn/core/server/mocks';
|
||||||
|
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
|
||||||
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
|
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
|
||||||
import { discoverPluginMock } from '@kbn/discover-plugin/server/mocks';
|
import { discoverPluginMock } from '@kbn/discover-plugin/server/mocks';
|
||||||
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
|
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
|
||||||
|
@ -40,10 +42,22 @@ export const createMockPluginSetup = (
|
||||||
setupMock: Partial<Record<keyof ReportingInternalSetup, any>>
|
setupMock: Partial<Record<keyof ReportingInternalSetup, any>>
|
||||||
): ReportingInternalSetup => {
|
): ReportingInternalSetup => {
|
||||||
return {
|
return {
|
||||||
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
|
actions: {
|
||||||
|
...actionsMock.createSetup(),
|
||||||
|
getActionsConfigurationUtilities: jest.fn().mockReturnValue({
|
||||||
|
validateEmailAddresses: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup({ canEncrypt: true }),
|
||||||
features: featuresPluginMock.createSetup(),
|
features: featuresPluginMock.createSetup(),
|
||||||
basePath: { set: jest.fn() },
|
basePath: { set: jest.fn() },
|
||||||
router: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() },
|
router: {
|
||||||
|
get: jest.fn(),
|
||||||
|
patch: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
security: securityMock.createSetup(),
|
security: securityMock.createSetup(),
|
||||||
taskManager: taskManagerMock.createSetup(),
|
taskManager: taskManagerMock.createSetup(),
|
||||||
logger: loggingSystemMock.createLogger(),
|
logger: loggingSystemMock.createLogger(),
|
||||||
|
@ -56,6 +70,7 @@ export const createMockPluginSetup = (
|
||||||
const coreSetupMock = coreMock.createSetup();
|
const coreSetupMock = coreMock.createSetup();
|
||||||
const coreStartMock = coreMock.createStart();
|
const coreStartMock = coreMock.createStart();
|
||||||
const logger = loggingSystemMock.createLogger();
|
const logger = loggingSystemMock.createLogger();
|
||||||
|
const savedObjectsClient = savedObjectsClientMock.create();
|
||||||
|
|
||||||
const createMockReportingStore = async (config: ReportingConfigType) => {
|
const createMockReportingStore = async (config: ReportingConfigType) => {
|
||||||
const mockConfigSchema = createMockConfigSchema(config);
|
const mockConfigSchema = createMockConfigSchema(config);
|
||||||
|
@ -71,7 +86,10 @@ export const createMockPluginStart = async (
|
||||||
return {
|
return {
|
||||||
analytics: coreSetupMock.analytics,
|
analytics: coreSetupMock.analytics,
|
||||||
esClient: elasticsearchServiceMock.createClusterClient(),
|
esClient: elasticsearchServiceMock.createClusterClient(),
|
||||||
savedObjects: { getScopedClient: jest.fn() },
|
savedObjects: {
|
||||||
|
getScopedClient: jest.fn().mockReturnValue(savedObjectsClient),
|
||||||
|
createInternalRepository: jest.fn().mockReturnValue(savedObjectsClient),
|
||||||
|
},
|
||||||
uiSettings: { asScopedToClient: () => ({ get: jest.fn() }) },
|
uiSettings: { asScopedToClient: () => ({ get: jest.fn() }) },
|
||||||
discover: discoverPluginMock.createStartContract(),
|
discover: discoverPluginMock.createStartContract(),
|
||||||
data: dataPluginMock.createStartContract(),
|
data: dataPluginMock.createStartContract(),
|
||||||
|
|
|
@ -12,7 +12,7 @@ import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server';
|
||||||
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
||||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server';
|
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server';
|
||||||
import type { LicensingPluginStart } from '@kbn/licensing-plugin/server';
|
import type { LicensingPluginStart } from '@kbn/licensing-plugin/server';
|
||||||
import type { UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types';
|
import type { ReportSource, UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types';
|
||||||
import type { ReportApiJSON } from '@kbn/reporting-common/types';
|
import type { ReportApiJSON } from '@kbn/reporting-common/types';
|
||||||
import type { ReportingConfigType } from '@kbn/reporting-server';
|
import type { ReportingConfigType } from '@kbn/reporting-server';
|
||||||
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server';
|
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server';
|
||||||
|
@ -21,18 +21,24 @@ import type {
|
||||||
PngScreenshotOptions as BasePngScreenshotOptions,
|
PngScreenshotOptions as BasePngScreenshotOptions,
|
||||||
ScreenshottingStart,
|
ScreenshottingStart,
|
||||||
} from '@kbn/screenshotting-plugin/server';
|
} from '@kbn/screenshotting-plugin/server';
|
||||||
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
|
import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server';
|
||||||
import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
|
import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
|
||||||
import type {
|
import type {
|
||||||
|
RruleSchedule,
|
||||||
TaskManagerSetupContract,
|
TaskManagerSetupContract,
|
||||||
TaskManagerStartContract,
|
TaskManagerStartContract,
|
||||||
} from '@kbn/task-manager-plugin/server';
|
} from '@kbn/task-manager-plugin/server';
|
||||||
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
|
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
|
||||||
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
|
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
|
||||||
|
|
||||||
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
|
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
|
||||||
import type { AuthenticatedUser } from '@kbn/core-security-common';
|
import type { AuthenticatedUser } from '@kbn/core-security-common';
|
||||||
import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
|
import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server';
|
||||||
|
import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server';
|
||||||
|
import {
|
||||||
|
RawNotification,
|
||||||
|
RawScheduledReport,
|
||||||
|
} from './saved_objects/scheduled_report/schemas/latest';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin Setup Contract
|
* Plugin Setup Contract
|
||||||
|
@ -50,6 +56,7 @@ export type ReportingUser = { username: AuthenticatedUser['username'] } | false;
|
||||||
export type ScrollConfig = ReportingConfigType['csv']['scroll'];
|
export type ScrollConfig = ReportingConfigType['csv']['scroll'];
|
||||||
|
|
||||||
export interface ReportingSetupDeps {
|
export interface ReportingSetupDeps {
|
||||||
|
actions: ActionsPluginSetupContract;
|
||||||
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
|
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
|
||||||
features: FeaturesPluginSetup;
|
features: FeaturesPluginSetup;
|
||||||
screenshotMode: ScreenshotModePluginSetup;
|
screenshotMode: ScreenshotModePluginSetup;
|
||||||
|
@ -66,6 +73,7 @@ export interface ReportingStartDeps {
|
||||||
licensing: LicensingPluginStart;
|
licensing: LicensingPluginStart;
|
||||||
notifications: NotificationsPluginStart;
|
notifications: NotificationsPluginStart;
|
||||||
taskManager: TaskManagerStartContract;
|
taskManager: TaskManagerStartContract;
|
||||||
|
security?: SecurityPluginStart;
|
||||||
screenshotting?: ScreenshottingStart;
|
screenshotting?: ScreenshottingStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,6 +100,44 @@ export interface ReportingJobResponse {
|
||||||
job: ReportApiJSON;
|
job: ReportApiJSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScheduledReportApiJSON = Omit<
|
||||||
|
ReportSource,
|
||||||
|
'attempts' | 'migration_version' | 'output' | 'payload' | 'status'
|
||||||
|
> & {
|
||||||
|
id: string;
|
||||||
|
migration_version?: string;
|
||||||
|
notification?: RawNotification;
|
||||||
|
payload: Omit<ReportSource['payload'], 'headers'>;
|
||||||
|
schedule: RruleSchedule;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ScheduledReportingJobResponse {
|
||||||
|
/**
|
||||||
|
* Details of a new report job that was requested
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
job: ScheduledReportApiJSON;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduledReportType = Omit<RawScheduledReport, 'schedule'> & {
|
||||||
|
schedule: RruleSchedule;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ListScheduledReportApiJSON {
|
||||||
|
id: string;
|
||||||
|
created_at: RawScheduledReport['createdAt'];
|
||||||
|
created_by: RawScheduledReport['createdBy'];
|
||||||
|
enabled: RawScheduledReport['enabled'];
|
||||||
|
jobtype: RawScheduledReport['jobType'];
|
||||||
|
last_run: string | undefined;
|
||||||
|
next_run: string | undefined;
|
||||||
|
notification: RawScheduledReport['notification'];
|
||||||
|
payload?: ReportApiJSON['payload'];
|
||||||
|
schedule: RruleSchedule;
|
||||||
|
space_id: string;
|
||||||
|
title: RawScheduledReport['title'];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PdfScreenshotOptions extends Omit<BasePdfScreenshotOptions, 'timeouts' | 'urls'> {
|
export interface PdfScreenshotOptions extends Omit<BasePdfScreenshotOptions, 'timeouts' | 'urls'> {
|
||||||
urls: UrlOrUrlLocatorTuple[];
|
urls: UrlOrUrlLocatorTuple[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
},
|
},
|
||||||
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"],
|
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"],
|
||||||
"kbn_references": [
|
"kbn_references": [
|
||||||
|
"@kbn/actions-plugin",
|
||||||
"@kbn/core",
|
"@kbn/core",
|
||||||
"@kbn/data-plugin",
|
"@kbn/data-plugin",
|
||||||
"@kbn/discover-plugin",
|
"@kbn/discover-plugin",
|
||||||
|
@ -52,7 +53,11 @@
|
||||||
"@kbn/react-kibana-mount",
|
"@kbn/react-kibana-mount",
|
||||||
"@kbn/core-security-common",
|
"@kbn/core-security-common",
|
||||||
"@kbn/core-http-server-utils",
|
"@kbn/core-http-server-utils",
|
||||||
|
"@kbn/core-saved-objects-server",
|
||||||
|
"@kbn/rrule",
|
||||||
"@kbn/notifications-plugin",
|
"@kbn/notifications-plugin",
|
||||||
|
"@kbn/spaces-utils",
|
||||||
|
"@kbn/logging-mocks",
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"target/**/*",
|
"target/**/*",
|
||||||
|
|
|
@ -479,7 +479,9 @@ Array [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -570,7 +572,9 @@ Array [
|
||||||
},
|
},
|
||||||
"name": "Generate CSV reports",
|
"name": "Generate CSV reports",
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -646,7 +650,9 @@ Array [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -706,7 +712,9 @@ Array [
|
||||||
"minimumLicense": "gold",
|
"minimumLicense": "gold",
|
||||||
"name": "Generate PDF or PNG reports",
|
"name": "Generate PDF or PNG reports",
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -822,7 +830,9 @@ Array [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -850,7 +860,9 @@ Array [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -942,7 +954,9 @@ Array [
|
||||||
"minimumLicense": "gold",
|
"minimumLicense": "gold",
|
||||||
"name": "Generate PDF or PNG reports",
|
"name": "Generate PDF or PNG reports",
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
@ -962,7 +976,9 @@ Array [
|
||||||
},
|
},
|
||||||
"name": "Generate CSV reports from Discover session panels",
|
"name": "Generate CSV reports from Discover session panels",
|
||||||
"savedObject": Object {
|
"savedObject": Object {
|
||||||
"all": Array [],
|
"all": Array [
|
||||||
|
"scheduled_report",
|
||||||
|
],
|
||||||
"read": Array [],
|
"read": Array [],
|
||||||
},
|
},
|
||||||
"ui": Array [
|
"ui": Array [
|
||||||
|
|
|
@ -802,7 +802,7 @@ const reportingFeatures: {
|
||||||
defaultMessage: 'Generate CSV reports',
|
defaultMessage: 'Generate CSV reports',
|
||||||
}),
|
}),
|
||||||
includeIn: 'all',
|
includeIn: 'all',
|
||||||
savedObject: { all: [], read: [] },
|
savedObject: { all: ['scheduled_report'], read: [] },
|
||||||
management: { insightsAndAlerting: ['reporting'] },
|
management: { insightsAndAlerting: ['reporting'] },
|
||||||
api: ['generateReport'],
|
api: ['generateReport'],
|
||||||
ui: ['generateCsv'],
|
ui: ['generateCsv'],
|
||||||
|
@ -830,7 +830,7 @@ const reportingFeatures: {
|
||||||
),
|
),
|
||||||
includeIn: 'all',
|
includeIn: 'all',
|
||||||
minimumLicense: 'gold',
|
minimumLicense: 'gold',
|
||||||
savedObject: { all: [], read: [] },
|
savedObject: { all: ['scheduled_report'], read: [] },
|
||||||
management: { insightsAndAlerting: ['reporting'] },
|
management: { insightsAndAlerting: ['reporting'] },
|
||||||
api: ['generateReport'],
|
api: ['generateReport'],
|
||||||
ui: ['generateScreenshot'],
|
ui: ['generateScreenshot'],
|
||||||
|
@ -844,7 +844,7 @@ const reportingFeatures: {
|
||||||
defaultMessage: 'Generate CSV reports from Discover session panels',
|
defaultMessage: 'Generate CSV reports from Discover session panels',
|
||||||
}),
|
}),
|
||||||
includeIn: 'all',
|
includeIn: 'all',
|
||||||
savedObject: { all: [], read: [] },
|
savedObject: { all: ['scheduled_report'], read: [] },
|
||||||
management: { insightsAndAlerting: ['reporting'] },
|
management: { insightsAndAlerting: ['reporting'] },
|
||||||
api: ['downloadCsv'],
|
api: ['downloadCsv'],
|
||||||
ui: ['downloadCsv'],
|
ui: ['downloadCsv'],
|
||||||
|
@ -872,7 +872,7 @@ const reportingFeatures: {
|
||||||
),
|
),
|
||||||
includeIn: 'all',
|
includeIn: 'all',
|
||||||
minimumLicense: 'gold',
|
minimumLicense: 'gold',
|
||||||
savedObject: { all: [], read: [] },
|
savedObject: { all: ['scheduled_report'], read: [] },
|
||||||
management: { insightsAndAlerting: ['reporting'] },
|
management: { insightsAndAlerting: ['reporting'] },
|
||||||
api: ['generateReport'],
|
api: ['generateReport'],
|
||||||
ui: ['generateScreenshot'],
|
ui: ['generateScreenshot'],
|
||||||
|
|
|
@ -43,8 +43,9 @@ export interface PlainTextEmail {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachmentEmail extends PlainTextEmail {
|
export interface AttachmentEmail extends Omit<PlainTextEmail, 'to'> {
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
|
to?: string[];
|
||||||
bcc?: string[];
|
bcc?: string[];
|
||||||
cc?: string[];
|
cc?: string[];
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
|
|
@ -156,6 +156,70 @@ describe('send_email module', () => {
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('handles email with attachments', async () => {
|
||||||
|
const sendEmailOptions = getSendEmailOptions({ transport: { service: 'other' } });
|
||||||
|
const result = await sendEmail(
|
||||||
|
mockLogger,
|
||||||
|
{
|
||||||
|
...sendEmailOptions,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
|
||||||
|
contentType: 'test-content-type',
|
||||||
|
encoding: 'base64',
|
||||||
|
filename: 'report.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
connectorTokenClient,
|
||||||
|
connectorUsageCollector
|
||||||
|
);
|
||||||
|
expect(result).toBe(sendMailMockResult);
|
||||||
|
expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"auth": Object {
|
||||||
|
"pass": "changeme",
|
||||||
|
"user": "elastic",
|
||||||
|
},
|
||||||
|
"host": undefined,
|
||||||
|
"port": undefined,
|
||||||
|
"secure": false,
|
||||||
|
"tls": Object {
|
||||||
|
"rejectUnauthorized": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
|
Array [
|
||||||
|
Object {
|
||||||
|
"attachments": Array [
|
||||||
|
Object {
|
||||||
|
"content": "dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==",
|
||||||
|
"contentType": "test-content-type",
|
||||||
|
"encoding": "base64",
|
||||||
|
"filename": "report.pdf",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"bcc": Array [],
|
||||||
|
"cc": Array [
|
||||||
|
"bob@example.com",
|
||||||
|
"robert@example.com",
|
||||||
|
],
|
||||||
|
"from": "fred@example.com",
|
||||||
|
"html": "<p>a message</p>
|
||||||
|
",
|
||||||
|
"subject": "a subject",
|
||||||
|
"text": "a message",
|
||||||
|
"to": Array [
|
||||||
|
"jim@example.com",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
test('uses OAuth 2.0 Client Credentials authentication for email using "exchange_server" service', async () => {
|
test('uses OAuth 2.0 Client Credentials authentication for email using "exchange_server" service', async () => {
|
||||||
const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock;
|
const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock;
|
||||||
const getOAuthClientCredentialsAccessTokenMock =
|
const getOAuthClientCredentialsAccessTokenMock =
|
||||||
|
@ -197,6 +261,7 @@ describe('send_email module', () => {
|
||||||
expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(`
|
expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Object {
|
Object {
|
||||||
|
"attachments": Array [],
|
||||||
"headers": Object {
|
"headers": Object {
|
||||||
"Authorization": "Bearer dfjsdfgdjhfgsjdf",
|
"Authorization": "Bearer dfjsdfgdjhfgsjdf",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
|
@ -78,6 +78,7 @@ export async function sendEmail(
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const { transport, content } = options;
|
const { transport, content } = options;
|
||||||
const { message, messageHTML } = content;
|
const { message, messageHTML } = content;
|
||||||
|
const attachments = options.attachments ?? [];
|
||||||
|
|
||||||
const renderedMessage = messageHTML ?? htmlFromMarkdown(logger, message);
|
const renderedMessage = messageHTML ?? htmlFromMarkdown(logger, message);
|
||||||
|
|
||||||
|
@ -87,10 +88,17 @@ export async function sendEmail(
|
||||||
options,
|
options,
|
||||||
renderedMessage,
|
renderedMessage,
|
||||||
connectorTokenClient,
|
connectorTokenClient,
|
||||||
connectorUsageCollector
|
connectorUsageCollector,
|
||||||
|
attachments
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return await sendEmailWithNodemailer(logger, options, renderedMessage, connectorUsageCollector);
|
return await sendEmailWithNodemailer(
|
||||||
|
logger,
|
||||||
|
options,
|
||||||
|
renderedMessage,
|
||||||
|
connectorUsageCollector,
|
||||||
|
attachments
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,7 +108,8 @@ export async function sendEmailWithExchange(
|
||||||
options: SendEmailOptions,
|
options: SendEmailOptions,
|
||||||
messageHTML: string,
|
messageHTML: string,
|
||||||
connectorTokenClient: ConnectorTokenClientContract,
|
connectorTokenClient: ConnectorTokenClientContract,
|
||||||
connectorUsageCollector: ConnectorUsageCollector
|
connectorUsageCollector: ConnectorUsageCollector,
|
||||||
|
attachments: Attachment[]
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const { transport, configurationUtilities, connectorId } = options;
|
const { transport, configurationUtilities, connectorId } = options;
|
||||||
const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport;
|
const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport;
|
||||||
|
@ -167,6 +176,7 @@ export async function sendEmailWithExchange(
|
||||||
options,
|
options,
|
||||||
headers,
|
headers,
|
||||||
messageHTML,
|
messageHTML,
|
||||||
|
attachments,
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
configurationUtilities,
|
configurationUtilities,
|
||||||
|
@ -180,7 +190,8 @@ async function sendEmailWithNodemailer(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
options: SendEmailOptions,
|
options: SendEmailOptions,
|
||||||
messageHTML: string,
|
messageHTML: string,
|
||||||
connectorUsageCollector: ConnectorUsageCollector
|
connectorUsageCollector: ConnectorUsageCollector,
|
||||||
|
attachments: Attachment[]
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const { transport, routing, content, configurationUtilities, hasAuth } = options;
|
const { transport, routing, content, configurationUtilities, hasAuth } = options;
|
||||||
const { service } = transport;
|
const { service } = transport;
|
||||||
|
@ -197,6 +208,7 @@ async function sendEmailWithNodemailer(
|
||||||
subject,
|
subject,
|
||||||
html: messageHTML,
|
html: messageHTML,
|
||||||
text: message,
|
text: message,
|
||||||
|
...(attachments.length > 0 && { attachments }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The transport options do not seem to be exposed as a type, and we reference
|
// The transport options do not seem to be exposed as a type, and we reference
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.moc
|
||||||
import type { CustomHostSettings } from '@kbn/actions-plugin/server/config';
|
import type { CustomHostSettings } from '@kbn/actions-plugin/server/config';
|
||||||
import type { ProxySettings } from '@kbn/actions-plugin/server/types';
|
import type { ProxySettings } from '@kbn/actions-plugin/server/types';
|
||||||
import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
|
import { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
|
||||||
import { sendEmailGraphApi } from './send_email_graph_api';
|
import { sendEmailGraphApi, sendEmailWithAttachments } from './send_email_graph_api';
|
||||||
|
|
||||||
const createAxiosInstanceMock = axios.create as jest.Mock;
|
const createAxiosInstanceMock = axios.create as jest.Mock;
|
||||||
const axiosInstanceMock = jest.fn();
|
const axiosInstanceMock = jest.fn();
|
||||||
|
@ -24,6 +24,7 @@ const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
||||||
|
|
||||||
describe('sendEmailGraphApi', () => {
|
describe('sendEmailGraphApi', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
createAxiosInstanceMock.mockReturnValue(axiosInstanceMock);
|
createAxiosInstanceMock.mockReturnValue(axiosInstanceMock);
|
||||||
});
|
});
|
||||||
const configurationUtilities = actionsConfigMock.create();
|
const configurationUtilities = actionsConfigMock.create();
|
||||||
|
@ -42,6 +43,7 @@ describe('sendEmailGraphApi', () => {
|
||||||
options: getSendEmailOptions(),
|
options: getSendEmailOptions(),
|
||||||
messageHTML: 'test1',
|
messageHTML: 'test1',
|
||||||
headers: {},
|
headers: {},
|
||||||
|
attachments: [],
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
configurationUtilities,
|
configurationUtilities,
|
||||||
|
@ -137,12 +139,13 @@ describe('sendEmailGraphApi', () => {
|
||||||
options: getSendEmailOptions(),
|
options: getSendEmailOptions(),
|
||||||
messageHTML: 'test2',
|
messageHTML: 'test2',
|
||||||
headers: { Authorization: 'Bearer 1234567' },
|
headers: { Authorization: 'Bearer 1234567' },
|
||||||
|
attachments: [],
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
configurationUtilities,
|
configurationUtilities,
|
||||||
connectorUsageCollector
|
connectorUsageCollector
|
||||||
);
|
);
|
||||||
expect(axiosInstanceMock.mock.calls[1]).toMatchInlineSnapshot(`
|
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
"https://graph.microsoft.com/v1.0/users/fred@example.com/sendMail",
|
"https://graph.microsoft.com/v1.0/users/fred@example.com/sendMail",
|
||||||
Object {
|
Object {
|
||||||
|
@ -235,12 +238,13 @@ describe('sendEmailGraphApi', () => {
|
||||||
options: getSendEmailOptions(),
|
options: getSendEmailOptions(),
|
||||||
messageHTML: 'test3',
|
messageHTML: 'test3',
|
||||||
headers: {},
|
headers: {},
|
||||||
|
attachments: [],
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
configurationUtilities,
|
configurationUtilities,
|
||||||
connectorUsageCollector
|
connectorUsageCollector
|
||||||
);
|
);
|
||||||
expect(axiosInstanceMock.mock.calls[2]).toMatchInlineSnapshot(`
|
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
"https://test/users/fred@example.com/sendMail",
|
"https://test/users/fred@example.com/sendMail",
|
||||||
Object {
|
Object {
|
||||||
|
@ -334,7 +338,7 @@ describe('sendEmailGraphApi', () => {
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendEmailGraphApi(
|
sendEmailGraphApi(
|
||||||
{ options: getSendEmailOptions(), messageHTML: 'test1', headers: {} },
|
{ options: getSendEmailOptions(), messageHTML: 'test1', headers: {}, attachments: [] },
|
||||||
logger,
|
logger,
|
||||||
configurationUtilities,
|
configurationUtilities,
|
||||||
connectorUsageCollector
|
connectorUsageCollector
|
||||||
|
@ -349,6 +353,243 @@ describe('sendEmailGraphApi', () => {
|
||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('sendEmailWithAttachments', () => {
|
||||||
|
test('email adds a small attachment', async () => {
|
||||||
|
const connectorUsageCollector = new ConnectorUsageCollector({
|
||||||
|
logger,
|
||||||
|
connectorId: 'test-connector-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// create draft
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 201,
|
||||||
|
data: { id: 'draftId' },
|
||||||
|
});
|
||||||
|
// add small attachment
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 201,
|
||||||
|
});
|
||||||
|
// send draft
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 202,
|
||||||
|
});
|
||||||
|
const axiosInstance = axios.create();
|
||||||
|
|
||||||
|
await sendEmailWithAttachments({
|
||||||
|
sendEmailOptions: {
|
||||||
|
options: getSendEmailOptions(),
|
||||||
|
messageHTML: 'test1',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
|
||||||
|
contentType: 'test-content-type',
|
||||||
|
encoding: 'base64',
|
||||||
|
filename: 'report.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axiosInstanceMock).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
const [createDraftUrl, createDraft] = axiosInstanceMock.mock.calls[0];
|
||||||
|
expect(createDraftUrl).toEqual(
|
||||||
|
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages'
|
||||||
|
);
|
||||||
|
expect(createDraft.data).toEqual({
|
||||||
|
bccRecipients: [],
|
||||||
|
body: {
|
||||||
|
content: 'test1',
|
||||||
|
contentType: 'HTML',
|
||||||
|
},
|
||||||
|
ccRecipients: [
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: 'bob@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: 'robert@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subject: 'a subject',
|
||||||
|
toRecipients: [
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: 'jim@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [addAttachmentUrl, addAttachment] = axiosInstanceMock.mock.calls[1];
|
||||||
|
expect(addAttachmentUrl).toEqual(
|
||||||
|
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/attachments'
|
||||||
|
);
|
||||||
|
expect(addAttachment.data).toEqual({
|
||||||
|
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||||
|
contentBytes: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
|
||||||
|
contentType: 'test-content-type',
|
||||||
|
name: 'report.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [sendDraftUrl] = axiosInstanceMock.mock.calls[2];
|
||||||
|
expect(sendDraftUrl).toEqual(
|
||||||
|
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/send'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email uploads a large attachment', async () => {
|
||||||
|
const connectorUsageCollector = new ConnectorUsageCollector({
|
||||||
|
logger,
|
||||||
|
connectorId: 'test-connector-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// create draft
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 201,
|
||||||
|
data: { id: 'draftId' },
|
||||||
|
});
|
||||||
|
// create upload session
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 201,
|
||||||
|
data: { uploadUrl: 'http://test-upload-session.com' },
|
||||||
|
});
|
||||||
|
// upload attachment 1/3
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
// upload attachment 2/3
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
// upload attachment 3/3
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
// close upload session
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 204,
|
||||||
|
});
|
||||||
|
// send draft
|
||||||
|
axiosInstanceMock.mockReturnValueOnce({
|
||||||
|
status: 202,
|
||||||
|
});
|
||||||
|
const axiosInstance = axios.create();
|
||||||
|
|
||||||
|
await sendEmailWithAttachments(
|
||||||
|
{
|
||||||
|
sendEmailOptions: {
|
||||||
|
options: getSendEmailOptions(),
|
||||||
|
messageHTML: 'test1',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
content: 'dGVzdC1vdXRwdXR0ZXN0LW91dHB1dA==',
|
||||||
|
contentType: 'test-content-type',
|
||||||
|
encoding: 'base64',
|
||||||
|
filename: 'report.pdf',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
},
|
||||||
|
30,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
expect(axiosInstanceMock).toHaveBeenCalledTimes(7);
|
||||||
|
|
||||||
|
const [createDraftUrl, createDraft] = axiosInstanceMock.mock.calls[0];
|
||||||
|
expect(createDraftUrl).toEqual(
|
||||||
|
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages'
|
||||||
|
);
|
||||||
|
expect(createDraft.data).toEqual({
|
||||||
|
bccRecipients: [],
|
||||||
|
body: {
|
||||||
|
content: 'test1',
|
||||||
|
contentType: 'HTML',
|
||||||
|
},
|
||||||
|
ccRecipients: [
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: 'bob@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: 'robert@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
subject: 'a subject',
|
||||||
|
toRecipients: [
|
||||||
|
{
|
||||||
|
emailAddress: {
|
||||||
|
address: 'jim@example.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [createUploadSessionUrl, createUploadSession] = axiosInstanceMock.mock.calls[1];
|
||||||
|
expect(createUploadSessionUrl).toEqual(
|
||||||
|
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/attachments/createUploadSession'
|
||||||
|
);
|
||||||
|
expect(createUploadSession.data).toEqual({
|
||||||
|
AttachmentItem: {
|
||||||
|
attachmentType: 'file',
|
||||||
|
name: 'report.pdf',
|
||||||
|
size: 22,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [uploadAttachmentUrl1, uploadAttachment1] = axiosInstanceMock.mock.calls[2];
|
||||||
|
expect(uploadAttachmentUrl1).toEqual('http://test-upload-session.com');
|
||||||
|
expect(uploadAttachment1.data).toBeTruthy();
|
||||||
|
expect(uploadAttachment1.headers).toEqual({
|
||||||
|
'Content-Length': '10',
|
||||||
|
'Content-Range': 'bytes 0-9/22',
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [uploadAttachmentUrl2, uploadAttachment2] = axiosInstanceMock.mock.calls[3];
|
||||||
|
expect(uploadAttachmentUrl2).toEqual('http://test-upload-session.com');
|
||||||
|
expect(uploadAttachment2.data).toBeTruthy();
|
||||||
|
expect(uploadAttachment2.headers).toEqual({
|
||||||
|
'Content-Length': '10',
|
||||||
|
'Content-Range': 'bytes 10-19/22',
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [uploadAttachmentUrl3, uploadAttachment3] = axiosInstanceMock.mock.calls[4];
|
||||||
|
expect(uploadAttachmentUrl3).toEqual('http://test-upload-session.com');
|
||||||
|
expect(uploadAttachment3.data).toBeTruthy();
|
||||||
|
expect(uploadAttachment3.headers).toEqual({
|
||||||
|
'Content-Length': '2',
|
||||||
|
'Content-Range': 'bytes 20-21/22',
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [closeUploadSessionUrl] = axiosInstanceMock.mock.calls[5];
|
||||||
|
expect(closeUploadSessionUrl).toEqual('http://test-upload-session.com');
|
||||||
|
|
||||||
|
const [sendDraftUrl] = axiosInstanceMock.mock.calls[6];
|
||||||
|
expect(sendDraftUrl).toEqual(
|
||||||
|
'https://graph.microsoft.com/v1.0/users/fred@example.com/messages/draftId/send'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function getSendEmailOptions(
|
function getSendEmailOptions(
|
||||||
|
|
|
@ -14,6 +14,10 @@ import { request } from '@kbn/actions-plugin/server/lib/axios_utils';
|
||||||
import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
|
import type { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
|
||||||
import type { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
|
import type { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
|
||||||
import type { SendEmailOptions } from './send_email';
|
import type { SendEmailOptions } from './send_email';
|
||||||
|
import type { Attachment } from '.';
|
||||||
|
|
||||||
|
const SMALL_ATTACHMENT_LIMIT = 3 * 1024 * 1024; // 3mb
|
||||||
|
const ATTACHMENT_CHUNK_SIZE = 2 * 1024 * 1024; // 2mb
|
||||||
|
|
||||||
export async function sendEmailGraphApi(
|
export async function sendEmailGraphApi(
|
||||||
sendEmailOptions: SendEmailGraphApiOptions,
|
sendEmailOptions: SendEmailGraphApiOptions,
|
||||||
|
@ -22,11 +26,54 @@ export async function sendEmailGraphApi(
|
||||||
connectorUsageCollector: ConnectorUsageCollector,
|
connectorUsageCollector: ConnectorUsageCollector,
|
||||||
axiosInstance?: AxiosInstance
|
axiosInstance?: AxiosInstance
|
||||||
): Promise<AxiosResponse> {
|
): Promise<AxiosResponse> {
|
||||||
const { options, headers, messageHTML } = sendEmailOptions;
|
|
||||||
|
|
||||||
// Create a new axios instance if one is not provided
|
// Create a new axios instance if one is not provided
|
||||||
axiosInstance = axiosInstance ?? axios.create();
|
axiosInstance = axiosInstance ?? axios.create();
|
||||||
|
|
||||||
|
const { attachments } = sendEmailOptions;
|
||||||
|
if (attachments.length > 0) {
|
||||||
|
logger.debug('[MS Exchange] sending email with attachments');
|
||||||
|
return sendEmailWithAttachments({
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendEmail({
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendEmailGraphApiOptions {
|
||||||
|
options: SendEmailOptions;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
messageHTML: string;
|
||||||
|
attachments: Attachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendEmailParams {
|
||||||
|
sendEmailOptions: SendEmailGraphApiOptions;
|
||||||
|
logger: Logger;
|
||||||
|
configurationUtilities: ActionsConfigurationUtilities;
|
||||||
|
connectorUsageCollector: ConnectorUsageCollector;
|
||||||
|
axiosInstance: AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail({
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
}: SendEmailParams): Promise<AxiosResponse> {
|
||||||
|
const { options, headers, messageHTML } = sendEmailOptions;
|
||||||
|
|
||||||
// POST /users/{id | userPrincipalName}/sendMail
|
// POST /users/{id | userPrincipalName}/sendMail
|
||||||
const res = await request({
|
const res = await request({
|
||||||
axios: axiosInstance,
|
axios: axiosInstance,
|
||||||
|
@ -46,15 +93,58 @@ export async function sendEmailGraphApi(
|
||||||
}
|
}
|
||||||
const errString = stringify(res.data);
|
const errString = stringify(res.data);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`error thrown sending Microsoft Exchange email for clientID: ${sendEmailOptions.options.transport.clientId}: ${errString}`
|
`error thrown sending Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}`
|
||||||
);
|
);
|
||||||
throw new Error(errString);
|
throw new Error(errString);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendEmailGraphApiOptions {
|
export async function sendEmailWithAttachments(
|
||||||
options: SendEmailOptions;
|
params: SendEmailParams,
|
||||||
headers: Record<string, string>;
|
smallAttachmentLimit: number = SMALL_ATTACHMENT_LIMIT,
|
||||||
messageHTML: string;
|
attachmentChunkSize: number = ATTACHMENT_CHUNK_SIZE
|
||||||
|
): Promise<AxiosResponse> {
|
||||||
|
const logger = params.logger.get('ms-exchange');
|
||||||
|
logger.debug('Creating draft email');
|
||||||
|
const emailId = await createDraft(params);
|
||||||
|
|
||||||
|
const attachments = params.sendEmailOptions.attachments;
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const size = Buffer.byteLength(attachment.content);
|
||||||
|
if (size < smallAttachmentLimit) {
|
||||||
|
// If attachment is smaller than the limit, add the attachment to the draft email
|
||||||
|
logger.debug('Attachment is smaller than 2Mb, attaching to draft');
|
||||||
|
await addAttachment(emailId, attachment, params);
|
||||||
|
} else {
|
||||||
|
// If attachment is larger than the limit,
|
||||||
|
// create an upload session and upload attachment in chunks to the draft email
|
||||||
|
const buffer = Buffer.from(attachment.content, attachment.encoding as BufferEncoding);
|
||||||
|
const bufferSize = buffer.length;
|
||||||
|
logger.debug('Attachment is larger than 2Mb, creating upload session');
|
||||||
|
const uploadUrl = await createUploadSession(emailId, attachment.filename, bufferSize, params);
|
||||||
|
logger.debug(`UploadUrl: ${uploadUrl}`);
|
||||||
|
|
||||||
|
const chunks = getAttachmentChunks(buffer, bufferSize, attachmentChunkSize);
|
||||||
|
let start = 0;
|
||||||
|
let count = 1;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const end = start + chunk.length - 1;
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': `${chunk.length}`,
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${bufferSize}`,
|
||||||
|
};
|
||||||
|
logger.debug(`Uploading chunk ${count} of ${chunks.length}`);
|
||||||
|
await uploadAttachmentChunk(uploadUrl, chunk, headers, params);
|
||||||
|
start = start + chunk.length;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
logger.debug('Closing upload session');
|
||||||
|
await closeUploadSession(uploadUrl, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Sending draft email');
|
||||||
|
return sendDraft(emailId, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMessage(emailOptions: SendEmailOptions, messageHTML: string) {
|
function getMessage(emailOptions: SendEmailOptions, messageHTML: string) {
|
||||||
|
@ -86,3 +176,254 @@ function getMessage(emailOptions: SendEmailOptions, messageHTML: string) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAttachmentChunks(buffer: Buffer, size: number, attachmentChunkSize: number): Buffer[] {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let start = 0;
|
||||||
|
while (start < size) {
|
||||||
|
const end = Math.min(start + attachmentChunkSize, size);
|
||||||
|
const chunk = buffer.subarray(start, end);
|
||||||
|
chunks.push(chunk);
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDraft({
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
}: SendEmailParams): Promise<string> {
|
||||||
|
const { options, headers, messageHTML } = sendEmailOptions;
|
||||||
|
|
||||||
|
// POST /users/{id | userPrincipalName}/messages
|
||||||
|
const { message } = getMessage(options, messageHTML);
|
||||||
|
const res = await request({
|
||||||
|
axios: axiosInstance,
|
||||||
|
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
|
||||||
|
options.routing.from
|
||||||
|
}/messages`,
|
||||||
|
method: 'post',
|
||||||
|
logger,
|
||||||
|
data: message,
|
||||||
|
headers,
|
||||||
|
configurationUtilities,
|
||||||
|
validateStatus: () => true,
|
||||||
|
connectorUsageCollector,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 201) {
|
||||||
|
const errString = stringify(res.data);
|
||||||
|
logger.warn(
|
||||||
|
`error thrown creating Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}`
|
||||||
|
);
|
||||||
|
throw new Error(errString);
|
||||||
|
}
|
||||||
|
return res.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendDraft(
|
||||||
|
emailId: string,
|
||||||
|
{
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
}: SendEmailParams
|
||||||
|
): Promise<AxiosResponse> {
|
||||||
|
const { options, headers } = sendEmailOptions;
|
||||||
|
|
||||||
|
// POST /users/{id | userPrincipalName}/messages/{emailId}/send
|
||||||
|
const res = await request({
|
||||||
|
axios: axiosInstance,
|
||||||
|
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
|
||||||
|
options.routing.from
|
||||||
|
}/messages/${emailId}/send`,
|
||||||
|
method: 'post',
|
||||||
|
logger,
|
||||||
|
data: {},
|
||||||
|
headers,
|
||||||
|
configurationUtilities,
|
||||||
|
validateStatus: () => true,
|
||||||
|
connectorUsageCollector,
|
||||||
|
});
|
||||||
|
if (res.status === 202) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
const errString = stringify(res.data);
|
||||||
|
logger.warn(
|
||||||
|
`error thrown sending Microsoft Exchange email with attachments for clientID: ${options.transport.clientId}: ${errString}`
|
||||||
|
);
|
||||||
|
throw new Error(errString);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUploadSession(
|
||||||
|
emailId: string,
|
||||||
|
name: string,
|
||||||
|
size: number,
|
||||||
|
{
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
}: SendEmailParams
|
||||||
|
): Promise<string> {
|
||||||
|
const { options, headers } = sendEmailOptions;
|
||||||
|
|
||||||
|
// POST /users/{id | userPrincipalName}/messages/{emailId}/attachments/createUploadSession
|
||||||
|
const res = await request({
|
||||||
|
axios: axiosInstance,
|
||||||
|
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
|
||||||
|
options.routing.from
|
||||||
|
}/messages/${emailId}/attachments/createUploadSession`,
|
||||||
|
method: 'post',
|
||||||
|
logger,
|
||||||
|
data: {
|
||||||
|
AttachmentItem: {
|
||||||
|
attachmentType: 'file',
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
configurationUtilities,
|
||||||
|
validateStatus: () => true,
|
||||||
|
connectorUsageCollector,
|
||||||
|
});
|
||||||
|
if (res.status !== 201) {
|
||||||
|
const errString = stringify(res.data);
|
||||||
|
logger.warn(
|
||||||
|
`error thrown creating Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}`
|
||||||
|
);
|
||||||
|
throw new Error(errString);
|
||||||
|
}
|
||||||
|
return res.data.uploadUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeUploadSession(
|
||||||
|
uploadUrl: string,
|
||||||
|
{
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
}: SendEmailParams
|
||||||
|
): Promise<AxiosResponse> {
|
||||||
|
const { options } = sendEmailOptions;
|
||||||
|
|
||||||
|
const res = await request({
|
||||||
|
axios: axiosInstance,
|
||||||
|
url: uploadUrl,
|
||||||
|
method: 'delete',
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
validateStatus: () => true,
|
||||||
|
connectorUsageCollector,
|
||||||
|
});
|
||||||
|
if (res.status === 204) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
const errString = stringify(`${res.status} ${res.statusText}`);
|
||||||
|
logger.warn(
|
||||||
|
`error thrown closing Microsoft Exchange attachment upload session for clientID: ${options.transport.clientId}: ${errString}`
|
||||||
|
);
|
||||||
|
throw new Error(errString);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addAttachment(
|
||||||
|
emailId: string,
|
||||||
|
attachment: Attachment,
|
||||||
|
{
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
}: SendEmailParams
|
||||||
|
): Promise<AxiosResponse> {
|
||||||
|
const { options, headers } = sendEmailOptions;
|
||||||
|
const responseSettings = configurationUtilities.getResponseSettings();
|
||||||
|
|
||||||
|
// POST /users/{id | userPrincipalName}/messages/{emailId}/attachments
|
||||||
|
const res = await request({
|
||||||
|
axios: axiosInstance,
|
||||||
|
url: `${configurationUtilities.getMicrosoftGraphApiUrl()}/users/${
|
||||||
|
options.routing.from
|
||||||
|
}/messages/${emailId}/attachments`,
|
||||||
|
method: 'post',
|
||||||
|
logger,
|
||||||
|
data: {
|
||||||
|
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||||
|
name: attachment.filename,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
contentBytes: attachment.content,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
configurationUtilities: {
|
||||||
|
...configurationUtilities,
|
||||||
|
// override maxContentLength config for requests with attachments
|
||||||
|
getResponseSettings: () => ({
|
||||||
|
...responseSettings,
|
||||||
|
maxContentLength: SMALL_ATTACHMENT_LIMIT,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
connectorUsageCollector,
|
||||||
|
});
|
||||||
|
if (res.status === 201) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
const errString = stringify(res.data);
|
||||||
|
logger.warn(
|
||||||
|
`error thrown adding attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}`
|
||||||
|
);
|
||||||
|
throw new Error(errString);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAttachmentChunk(
|
||||||
|
uploadUrl: string,
|
||||||
|
chunk: Buffer,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
{
|
||||||
|
sendEmailOptions,
|
||||||
|
logger,
|
||||||
|
configurationUtilities,
|
||||||
|
connectorUsageCollector,
|
||||||
|
axiosInstance,
|
||||||
|
}: SendEmailParams
|
||||||
|
): Promise<void> {
|
||||||
|
const { options } = sendEmailOptions;
|
||||||
|
const responseSettings = configurationUtilities.getResponseSettings();
|
||||||
|
|
||||||
|
const res = await request({
|
||||||
|
axios: axiosInstance,
|
||||||
|
url: uploadUrl,
|
||||||
|
method: 'put',
|
||||||
|
logger,
|
||||||
|
data: chunk,
|
||||||
|
headers,
|
||||||
|
configurationUtilities: {
|
||||||
|
...configurationUtilities,
|
||||||
|
// Override maxContentLength config for requests with attachments
|
||||||
|
getResponseSettings: () => ({
|
||||||
|
...responseSettings,
|
||||||
|
maxContentLength: SMALL_ATTACHMENT_LIMIT,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
validateStatus: () => true,
|
||||||
|
connectorUsageCollector,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status !== 200 && res.status !== 201) {
|
||||||
|
const errString = stringify(res.data);
|
||||||
|
logger.warn(
|
||||||
|
`error thrown uploading attachment to Microsoft Exchange email for clientID: ${options.transport.clientId}: ${errString}`
|
||||||
|
);
|
||||||
|
throw new Error(errString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -16,4 +16,5 @@ export const CONCURRENCY_ALLOW_LIST_BY_TASK_TYPE: string[] = [
|
||||||
|
|
||||||
// task types requiring a concurrency
|
// task types requiring a concurrency
|
||||||
'report:execute',
|
'report:execute',
|
||||||
|
'report:execute-scheduled',
|
||||||
];
|
];
|
||||||
|
|
|
@ -24,7 +24,9 @@ export type {
|
||||||
} from './task';
|
} from './task';
|
||||||
|
|
||||||
export { Frequency, Weekday } from '@kbn/rrule';
|
export { Frequency, Weekday } from '@kbn/rrule';
|
||||||
|
export { scheduleRruleSchema } from './saved_objects';
|
||||||
|
|
||||||
|
export type { RruleSchedule } from './task';
|
||||||
export { TaskStatus, TaskPriority, TaskCost } from './task';
|
export { TaskStatus, TaskPriority, TaskCost } from './task';
|
||||||
|
|
||||||
export type { TaskRegisterDefinition, TaskDefinitionRegistry } from './task_type_dictionary';
|
export type { TaskRegisterDefinition, TaskDefinitionRegistry } from './task_type_dictionary';
|
||||||
|
|
|
@ -159,7 +159,7 @@ describe('getFirstRunAt', () => {
|
||||||
freq: 2, // Weekly
|
freq: 2, // Weekly
|
||||||
interval: 1,
|
interval: 1,
|
||||||
tzid: 'UTC',
|
tzid: 'UTC',
|
||||||
byweekday: [1], // Monday
|
byweekday: ['1'], // Monday
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -182,7 +182,7 @@ describe('getFirstRunAt', () => {
|
||||||
freq: 2, // Weekly
|
freq: 2, // Weekly
|
||||||
interval: 1,
|
interval: 1,
|
||||||
tzid: 'UTC',
|
tzid: 'UTC',
|
||||||
byweekday: [1], // Monday
|
byweekday: ['MO'], // Monday
|
||||||
byhour: [12],
|
byhour: [12],
|
||||||
byminute: [15],
|
byminute: [15],
|
||||||
},
|
},
|
||||||
|
@ -257,7 +257,7 @@ describe('getFirstRunAt', () => {
|
||||||
freq: 1, // Monthly
|
freq: 1, // Monthly
|
||||||
interval: 1,
|
interval: 1,
|
||||||
tzid: 'UTC',
|
tzid: 'UTC',
|
||||||
byweekday: [3], // Wednesday
|
byweekday: ['3'], // Wednesday
|
||||||
byhour: [12],
|
byhour: [12],
|
||||||
byminute: [17],
|
byminute: [17],
|
||||||
},
|
},
|
||||||
|
|
|
@ -14,6 +14,8 @@ import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task';
|
||||||
import { TASK_MANAGER_INDEX } from '../constants';
|
import { TASK_MANAGER_INDEX } from '../constants';
|
||||||
import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_versions';
|
import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_versions';
|
||||||
|
|
||||||
|
export { scheduleRruleSchema } from './schemas/task';
|
||||||
|
|
||||||
export const TASK_SO_NAME = 'task';
|
export const TASK_SO_NAME = 'task';
|
||||||
export const BACKGROUND_TASK_NODE_SO_NAME = 'background-task-node';
|
export const BACKGROUND_TASK_NODE_SO_NAME = 'background-task-node';
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,14 @@ export const taskSchemaV3 = taskSchemaV2.extends({
|
||||||
priority: schema.maybe(schema.number()),
|
priority: schema.maybe(schema.number()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const scheduleIntervalSchema = schema.object({
|
||||||
|
interval: schema.string({ validate: validateDuration }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const scheduleRruleSchema = schema.object({
|
||||||
|
rrule: rruleSchedule,
|
||||||
|
});
|
||||||
|
|
||||||
export const taskSchemaV4 = taskSchemaV3.extends({
|
export const taskSchemaV4 = taskSchemaV3.extends({
|
||||||
apiKey: schema.maybe(schema.string()),
|
apiKey: schema.maybe(schema.string()),
|
||||||
userScope: schema.maybe(
|
userScope: schema.maybe(
|
||||||
|
@ -67,14 +75,5 @@ export const taskSchemaV4 = taskSchemaV3.extends({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const taskSchemaV5 = taskSchemaV4.extends({
|
export const taskSchemaV5 = taskSchemaV4.extends({
|
||||||
schedule: schema.maybe(
|
schedule: schema.maybe(schema.oneOf([scheduleIntervalSchema, scheduleRruleSchema])),
|
||||||
schema.oneOf([
|
|
||||||
schema.object({
|
|
||||||
interval: schema.string({ validate: validateDuration }),
|
|
||||||
}),
|
|
||||||
schema.object({
|
|
||||||
rrule: rruleSchedule,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,7 +11,7 @@ import type { ObjectType, TypeOf } from '@kbn/config-schema';
|
||||||
import { schema } from '@kbn/config-schema';
|
import { schema } from '@kbn/config-schema';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
import type { KibanaRequest } from '@kbn/core/server';
|
import type { KibanaRequest } from '@kbn/core/server';
|
||||||
import type { Frequency, Weekday } from '@kbn/rrule';
|
import type { Frequency } from '@kbn/rrule';
|
||||||
import { isErr, tryAsResult } from './lib/result_type';
|
import { isErr, tryAsResult } from './lib/result_type';
|
||||||
import type { Interval } from './lib/intervals';
|
import type { Interval } from './lib/intervals';
|
||||||
import { isInterval, parseIntervalAsMillisecond } from './lib/intervals';
|
import { isInterval, parseIntervalAsMillisecond } from './lib/intervals';
|
||||||
|
@ -259,8 +259,9 @@ export interface IntervalSchedule {
|
||||||
rrule?: never;
|
rrule?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Rrule = RruleMonthly | RruleWeekly | RruleDaily;
|
||||||
export interface RruleSchedule {
|
export interface RruleSchedule {
|
||||||
rrule: RruleMonthly | RruleWeekly | RruleDaily;
|
rrule: Rrule;
|
||||||
interval?: never;
|
interval?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -269,17 +270,16 @@ interface RruleCommon {
|
||||||
interval: number;
|
interval: number;
|
||||||
tzid: string;
|
tzid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RruleMonthly extends RruleCommon {
|
interface RruleMonthly extends RruleCommon {
|
||||||
freq: Frequency.MONTHLY;
|
freq: Frequency.MONTHLY;
|
||||||
bymonthday?: number[];
|
bymonthday?: number[];
|
||||||
byhour?: number[];
|
byhour?: number[];
|
||||||
byminute?: number[];
|
byminute?: number[];
|
||||||
byweekday?: Weekday[];
|
byweekday?: string[];
|
||||||
}
|
}
|
||||||
interface RruleWeekly extends RruleCommon {
|
interface RruleWeekly extends RruleCommon {
|
||||||
freq: Frequency.WEEKLY;
|
freq: Frequency.WEEKLY;
|
||||||
byweekday?: Weekday[];
|
byweekday?: string[];
|
||||||
byhour?: number[];
|
byhour?: number[];
|
||||||
byminute?: number[];
|
byminute?: number[];
|
||||||
bymonthday?: never;
|
bymonthday?: never;
|
||||||
|
@ -288,7 +288,7 @@ interface RruleDaily extends RruleCommon {
|
||||||
freq: Frequency.DAILY;
|
freq: Frequency.DAILY;
|
||||||
byhour?: number[];
|
byhour?: number[];
|
||||||
byminute?: number[];
|
byminute?: number[];
|
||||||
byweekday?: Weekday[];
|
byweekday?: string[];
|
||||||
bymonthday?: never;
|
bymonthday?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,9 @@ export const REMOVED_TYPES: string[] = [
|
||||||
export const SHARED_CONCURRENCY_TASKS: string[][] = [
|
export const SHARED_CONCURRENCY_TASKS: string[][] = [
|
||||||
// for testing
|
// for testing
|
||||||
['sampleTaskSharedConcurrencyType1', 'sampleTaskSharedConcurrencyType2'],
|
['sampleTaskSharedConcurrencyType1', 'sampleTaskSharedConcurrencyType2'],
|
||||||
|
|
||||||
|
// reporting
|
||||||
|
['report:execute', 'report:execute-scheduled'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -127,6 +127,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
'inventory',
|
'inventory',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenanceWindow',
|
'maintenanceWindow',
|
||||||
|
'manageReporting',
|
||||||
'maps_v2',
|
'maps_v2',
|
||||||
'osquery',
|
'osquery',
|
||||||
'rulesSettings',
|
'rulesSettings',
|
||||||
|
@ -210,6 +211,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
'fleet',
|
'fleet',
|
||||||
'fleetv2',
|
'fleetv2',
|
||||||
'entityManager',
|
'entityManager',
|
||||||
|
'manageReporting',
|
||||||
];
|
];
|
||||||
|
|
||||||
const features = body.filter(
|
const features = body.filter(
|
||||||
|
|
|
@ -208,6 +208,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
|
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
|
manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'],
|
apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'],
|
||||||
discover: [
|
discover: [
|
||||||
'all',
|
'all',
|
||||||
|
|
|
@ -81,6 +81,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'],
|
aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
inventory: ['all', 'read', 'minimal_all', 'minimal_read'],
|
inventory: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
|
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
|
manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
entityManager: ['all', 'read', 'minimal_all', 'minimal_read'],
|
entityManager: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
},
|
},
|
||||||
global: ['all', 'read'],
|
global: ['all', 'read'],
|
||||||
|
@ -312,6 +313,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
|
dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
|
manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||||
apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'],
|
apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'],
|
||||||
discover: [
|
discover: [
|
||||||
'all',
|
'all',
|
||||||
|
|
|
@ -171,6 +171,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
'osquery:telemetry-packs',
|
'osquery:telemetry-packs',
|
||||||
'osquery:telemetry-saved-queries',
|
'osquery:telemetry-saved-queries',
|
||||||
'report:execute',
|
'report:execute',
|
||||||
|
'report:execute-scheduled',
|
||||||
'risk_engine:risk_scoring',
|
'risk_engine:risk_scoring',
|
||||||
'search:agentless-connectors-manager',
|
'search:agentless-connectors-manager',
|
||||||
'security-solution-ea-asset-criticality-ecs-migration',
|
'security-solution-ea-asset-criticality-ecs-migration',
|
||||||
|
|
|
@ -0,0 +1,167 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from '@kbn/expect';
|
||||||
|
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
|
||||||
|
import { FtrProviderContext } from '../ftr_provider_context';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
|
const reportingAPI = getService('reportingAPI');
|
||||||
|
|
||||||
|
describe('Disable Scheduled Reports', () => {
|
||||||
|
const scheduledReportIds: string[] = [];
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await reportingAPI.initEcommerce();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await reportingAPI.teardownEcommerce();
|
||||||
|
await reportingAPI.deleteAllReports();
|
||||||
|
await reportingAPI.deleteScheduledReports(scheduledReportIds);
|
||||||
|
await reportingAPI.deleteTasks(scheduledReportIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow reporting user to disable their own scheduled report', async () => {
|
||||||
|
const report = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'dashboard',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.status).to.eql(200);
|
||||||
|
const reportId = report.body.job.id;
|
||||||
|
|
||||||
|
scheduledReportIds.push(reportId);
|
||||||
|
|
||||||
|
// report created by reporting user, reporting user should be able to disable
|
||||||
|
const res = await reportingAPI.disableScheduledReports(
|
||||||
|
[reportId],
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).to.eql({ scheduled_report_ids: [reportId], errors: [], total: 1 });
|
||||||
|
|
||||||
|
const soResult = await reportingAPI.getScheduledReports(reportId);
|
||||||
|
expect(soResult.body._source.scheduled_report.enabled).to.eql(false);
|
||||||
|
const taskResult = await reportingAPI.getTask(reportId);
|
||||||
|
expect(taskResult.body._source?.task.enabled).to.eql(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not allow user to disable other users reports when no ManageReporting feature privilege', async () => {
|
||||||
|
const report = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'visualization',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.status).to.eql(200);
|
||||||
|
const reportId = report.body.job.id;
|
||||||
|
|
||||||
|
scheduledReportIds.push(reportId);
|
||||||
|
|
||||||
|
// report created by manage reporting user, reporting user should not be able to disable
|
||||||
|
const res = await reportingAPI.disableScheduledReports(
|
||||||
|
[reportId],
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).to.eql({
|
||||||
|
scheduled_report_ids: [],
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `Not found.`,
|
||||||
|
status: 404,
|
||||||
|
id: reportId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const soResult = await reportingAPI.getScheduledReports(reportId);
|
||||||
|
expect(soResult.body._source.scheduled_report.enabled).to.eql(true);
|
||||||
|
const taskResult = await reportingAPI.getTask(reportId);
|
||||||
|
expect(taskResult.body._source?.task.enabled).to.eql(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow user to disable other users reports when they have ManageReporting feature privilege', async () => {
|
||||||
|
const report1 = await reportingAPI.scheduleCsv(
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'allowed search',
|
||||||
|
objectType: 'search',
|
||||||
|
searchSource: {
|
||||||
|
version: true,
|
||||||
|
fields: [{ field: '*', include_unmapped: true }],
|
||||||
|
index: '5193f870-d861-11e9-a311-0fa548c5f953',
|
||||||
|
} as unknown as SerializedSearchSourceFields,
|
||||||
|
columns: [],
|
||||||
|
version: '7.13.0',
|
||||||
|
},
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
const report2 = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'visualization',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report1.status).to.eql(200);
|
||||||
|
expect(report2.status).to.eql(200);
|
||||||
|
|
||||||
|
const report1Id = report1.body.job.id;
|
||||||
|
const report2Id = report2.body.job.id;
|
||||||
|
|
||||||
|
scheduledReportIds.push(report1Id);
|
||||||
|
scheduledReportIds.push(report2Id);
|
||||||
|
|
||||||
|
// manage reporting user should be able to disable their own report and reporting user report
|
||||||
|
const res = await reportingAPI.disableScheduledReports(
|
||||||
|
[report1Id, report2Id],
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res).to.eql({ scheduled_report_ids: [report1Id, report2Id], errors: [], total: 2 });
|
||||||
|
|
||||||
|
const soResult1 = await reportingAPI.getScheduledReports(report1Id);
|
||||||
|
expect(soResult1.body._source.scheduled_report.enabled).to.eql(false);
|
||||||
|
const soResult2 = await reportingAPI.getScheduledReports(report2Id);
|
||||||
|
expect(soResult2.body._source.scheduled_report.enabled).to.eql(false);
|
||||||
|
const taskResult1 = await reportingAPI.getTask(report1Id);
|
||||||
|
expect(taskResult1.body._source?.task.enabled).to.eql(false);
|
||||||
|
const taskResult2 = await reportingAPI.getTask(report2Id);
|
||||||
|
expect(taskResult2.body._source?.task.enabled).to.eql(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -17,6 +17,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
|
||||||
await reportingAPI.createTestReportingUserRole();
|
await reportingAPI.createTestReportingUserRole();
|
||||||
await reportingAPI.createDataAnalyst();
|
await reportingAPI.createDataAnalyst();
|
||||||
await reportingAPI.createTestReportingUser();
|
await reportingAPI.createTestReportingUser();
|
||||||
|
await reportingAPI.createManageReportingUserRole();
|
||||||
|
await reportingAPI.createManageReportingUser();
|
||||||
});
|
});
|
||||||
|
|
||||||
loadTestFile(require.resolve('./bwc_existing_indexes'));
|
loadTestFile(require.resolve('./bwc_existing_indexes'));
|
||||||
|
@ -25,6 +27,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
|
||||||
loadTestFile(require.resolve('./ilm_migration_apis'));
|
loadTestFile(require.resolve('./ilm_migration_apis'));
|
||||||
loadTestFile(require.resolve('./security_roles_privileges'));
|
loadTestFile(require.resolve('./security_roles_privileges'));
|
||||||
loadTestFile(require.resolve('./spaces'));
|
loadTestFile(require.resolve('./spaces'));
|
||||||
|
loadTestFile(require.resolve('./list_scheduled_reports'));
|
||||||
|
loadTestFile(require.resolve('./disable_scheduled_reports'));
|
||||||
loadTestFile(require.resolve('./list_jobs'));
|
loadTestFile(require.resolve('./list_jobs'));
|
||||||
|
|
||||||
// CSV-specific
|
// CSV-specific
|
||||||
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from '@kbn/expect';
|
||||||
|
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
|
||||||
|
import { JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common';
|
||||||
|
import { JobParamsCSV } from '@kbn/reporting-export-types-csv-common';
|
||||||
|
import { FtrProviderContext } from '../ftr_provider_context';
|
||||||
|
|
||||||
|
const pdfPayload: JobParamsPDFV2 = {
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'dashboard',
|
||||||
|
version: '7.14.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const csvPayload: JobParamsCSV = {
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'allowed search',
|
||||||
|
objectType: 'search',
|
||||||
|
searchSource: {
|
||||||
|
version: true,
|
||||||
|
fields: [{ field: '*', include_unmapped: true }],
|
||||||
|
index: '5193f870-d861-11e9-a311-0fa548c5f953',
|
||||||
|
} as unknown as SerializedSearchSourceFields,
|
||||||
|
columns: [],
|
||||||
|
version: '7.13.0',
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
|
const reportingAPI = getService('reportingAPI');
|
||||||
|
|
||||||
|
describe('List Scheduled Reports', () => {
|
||||||
|
let report1Id: string;
|
||||||
|
let report2Id: string;
|
||||||
|
let report3Id: string;
|
||||||
|
const scheduledReportIds: string[] = [];
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await reportingAPI.initEcommerce();
|
||||||
|
|
||||||
|
const report1 = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD,
|
||||||
|
pdfPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
const report2 = await reportingAPI.scheduleCsv(
|
||||||
|
csvPayload,
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
const report3 = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_PASSWORD,
|
||||||
|
pdfPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report1.status).to.eql(200);
|
||||||
|
expect(report2.status).to.eql(200);
|
||||||
|
expect(report3.status).to.eql(200);
|
||||||
|
|
||||||
|
report1Id = report1.body.job.id;
|
||||||
|
report2Id = report2.body.job.id;
|
||||||
|
report3Id = report3.body.job.id;
|
||||||
|
|
||||||
|
scheduledReportIds.push(report1Id);
|
||||||
|
scheduledReportIds.push(report2Id);
|
||||||
|
scheduledReportIds.push(report3Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await reportingAPI.teardownEcommerce();
|
||||||
|
await reportingAPI.deleteAllReports();
|
||||||
|
await reportingAPI.deleteScheduledReports(scheduledReportIds);
|
||||||
|
await reportingAPI.deleteTasks(scheduledReportIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return reports scheduled by the user when user does not have ManageReporting feature privilege', async () => {
|
||||||
|
const res = await reportingAPI.listScheduledReports(
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.total).to.eql(2);
|
||||||
|
|
||||||
|
for (const report of res.data) {
|
||||||
|
expect(report.created_by).to.eql(reportingAPI.REPORTING_USER_USERNAME);
|
||||||
|
expect([report1Id, report2Id]).to.contain(report.id);
|
||||||
|
expect(report.next_run).not.to.be(undefined);
|
||||||
|
expect(report.space_id).to.eql('default');
|
||||||
|
expect(report.schedule).to.have.property('rrule');
|
||||||
|
expect(report.enabled).to.be(true);
|
||||||
|
expect(report.payload).to.have.property('objectType');
|
||||||
|
expect(report.payload).to.have.property('browserTimezone');
|
||||||
|
expect(report.payload).to.have.property('title');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return reports scheduled by all users when user has ManageReporting feature privilege', async () => {
|
||||||
|
const res = await reportingAPI.listScheduledReports(
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.total).to.eql(3);
|
||||||
|
|
||||||
|
for (const report of res.data) {
|
||||||
|
expect([
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.MANAGE_REPORTING_USER_USERNAME,
|
||||||
|
]).to.contain(report.created_by);
|
||||||
|
expect([report1Id, report2Id, report3Id]).to.contain(report.id);
|
||||||
|
expect(report.next_run).not.to.be(undefined);
|
||||||
|
expect(report.space_id).to.eql('default');
|
||||||
|
expect(report.schedule).to.have.property('rrule');
|
||||||
|
expect(report.enabled).to.be(true);
|
||||||
|
expect(report.payload).to.have.property('objectType');
|
||||||
|
expect(report.payload).to.have.property('browserTimezone');
|
||||||
|
expect(report.payload).to.have.property('title');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
import expect from '@kbn/expect';
|
import expect from '@kbn/expect';
|
||||||
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
|
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
|
||||||
|
import { SearchHit } from '@elastic/elasticsearch/lib/api/types';
|
||||||
|
import { SerializedConcreteTaskInstance } from '@kbn/task-manager-plugin/server/task';
|
||||||
import { FtrProviderContext } from '../ftr_provider_context';
|
import { FtrProviderContext } from '../ftr_provider_context';
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
@ -14,13 +16,33 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
const reportingAPI = getService('reportingAPI');
|
const reportingAPI = getService('reportingAPI');
|
||||||
const supertest = getService('supertest');
|
const supertest = getService('supertest');
|
||||||
|
|
||||||
|
function testExpectedTask(
|
||||||
|
id: string,
|
||||||
|
jobtype: string,
|
||||||
|
task: SearchHit<{ task: SerializedConcreteTaskInstance }>
|
||||||
|
) {
|
||||||
|
expect(task._source?.task.taskType).to.eql('report:execute-scheduled');
|
||||||
|
|
||||||
|
const params = JSON.parse(task._source?.task.params ?? '');
|
||||||
|
expect(params.id).to.eql(id);
|
||||||
|
expect(params.jobtype).to.eql(jobtype);
|
||||||
|
|
||||||
|
expect(task._source?.task.apiKey).not.to.be(undefined);
|
||||||
|
expect(task._source?.task.schedule?.rrule).not.to.be(undefined);
|
||||||
|
|
||||||
|
expect(task._source?.task.schedule?.interval).to.be(undefined);
|
||||||
|
}
|
||||||
describe('Security Roles and Privileges for Applications', () => {
|
describe('Security Roles and Privileges for Applications', () => {
|
||||||
|
const scheduledReportIds: string[] = [];
|
||||||
|
const scheduledReportTaskIds: string[] = [];
|
||||||
before(async () => {
|
before(async () => {
|
||||||
await reportingAPI.initEcommerce();
|
await reportingAPI.initEcommerce();
|
||||||
});
|
});
|
||||||
after(async () => {
|
after(async () => {
|
||||||
await reportingAPI.teardownEcommerce();
|
await reportingAPI.teardownEcommerce();
|
||||||
await reportingAPI.deleteAllReports();
|
await reportingAPI.deleteAllReports();
|
||||||
|
await reportingAPI.deleteScheduledReports(scheduledReportIds);
|
||||||
|
await reportingAPI.deleteTasks(scheduledReportTaskIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Dashboard: Generate PDF report', () => {
|
describe('Dashboard: Generate PDF report', () => {
|
||||||
|
@ -162,6 +184,185 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Dashboard: Schedule PDF report', () => {
|
||||||
|
it('does not allow user that does not have the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.DATA_ANALYST_USERNAME,
|
||||||
|
reportingAPI.DATA_ANALYST_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF disallowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'dashboard',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does allow user with the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'dashboard',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
|
||||||
|
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
|
||||||
|
expect(soResult.status).to.eql(200);
|
||||||
|
expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed');
|
||||||
|
scheduledReportIds.push(res.body.job.id);
|
||||||
|
|
||||||
|
const taskResult = await reportingAPI.getTask(res.body.job.id);
|
||||||
|
expect(taskResult.status).to.eql(200);
|
||||||
|
testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body);
|
||||||
|
scheduledReportTaskIds.push(res.body.job.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visualize: Schedule PDF report', () => {
|
||||||
|
it('does not allow user that does not have the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.DATA_ANALYST_USERNAME,
|
||||||
|
reportingAPI.DATA_ANALYST_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF disallowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'visualization',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does allow user with the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'visualization',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
|
||||||
|
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
|
||||||
|
expect(soResult.status).to.eql(200);
|
||||||
|
expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed');
|
||||||
|
scheduledReportIds.push(res.body.job.id);
|
||||||
|
|
||||||
|
const taskResult = await reportingAPI.getTask(res.body.job.id);
|
||||||
|
expect(taskResult.status).to.eql(200);
|
||||||
|
testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body);
|
||||||
|
scheduledReportTaskIds.push(res.body.job.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Canvas: Schedule PDF report', () => {
|
||||||
|
it('does not allow user that does not have the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.DATA_ANALYST_USERNAME,
|
||||||
|
reportingAPI.DATA_ANALYST_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF disallowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'canvas',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does allow user with the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'canvas',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
|
||||||
|
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
|
||||||
|
expect(soResult.status).to.eql(200);
|
||||||
|
expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed');
|
||||||
|
scheduledReportIds.push(res.body.job.id);
|
||||||
|
|
||||||
|
const taskResult = await reportingAPI.getTask(res.body.job.id);
|
||||||
|
expect(taskResult.status).to.eql(200);
|
||||||
|
testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body);
|
||||||
|
scheduledReportTaskIds.push(res.body.job.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Discover: Schedule CSV report', () => {
|
||||||
|
it('does not allow user that does not have the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.scheduleCsv(
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
searchSource: {} as SerializedSearchSourceFields,
|
||||||
|
objectType: 'search',
|
||||||
|
title: 'test disallowed',
|
||||||
|
version: '7.14.0',
|
||||||
|
},
|
||||||
|
reportingAPI.DATA_ANALYST_USERNAME,
|
||||||
|
reportingAPI.DATA_ANALYST_PASSWORD
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does allow user with the role-based privilege', async () => {
|
||||||
|
const res = await reportingAPI.scheduleCsv(
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'allowed search',
|
||||||
|
objectType: 'search',
|
||||||
|
searchSource: {
|
||||||
|
version: true,
|
||||||
|
fields: [{ field: '*', include_unmapped: true }],
|
||||||
|
index: '5193f870-d861-11e9-a311-0fa548c5f953',
|
||||||
|
} as unknown as SerializedSearchSourceFields,
|
||||||
|
columns: [],
|
||||||
|
version: '7.13.0',
|
||||||
|
},
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(200);
|
||||||
|
|
||||||
|
const soResult = await reportingAPI.getScheduledReports(res.body.job.id);
|
||||||
|
expect(soResult.status).to.eql(200);
|
||||||
|
expect(soResult.body._source.scheduled_report.title).to.eql('allowed search');
|
||||||
|
scheduledReportIds.push(res.body.job.id);
|
||||||
|
|
||||||
|
const taskResult = await reportingAPI.getTask(res.body.job.id);
|
||||||
|
expect(taskResult.status).to.eql(200);
|
||||||
|
testExpectedTask(res.body.job.id, 'csv_searchsource', taskResult.body);
|
||||||
|
scheduledReportTaskIds.push(res.body.job.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// This tests the same API as x-pack/test/api_integration/apis/security/privileges.ts, but it uses the non-deprecated config
|
// This tests the same API as x-pack/test/api_integration/apis/security/privileges.ts, but it uses the non-deprecated config
|
||||||
it('should register reporting privileges with the security privileges API', async () => {
|
it('should register reporting privileges with the security privileges API', async () => {
|
||||||
await supertest
|
await supertest
|
||||||
|
|
|
@ -17,5 +17,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
|
||||||
});
|
});
|
||||||
|
|
||||||
loadTestFile(require.resolve('./csv/job_apis_csv'));
|
loadTestFile(require.resolve('./csv/job_apis_csv'));
|
||||||
|
loadTestFile(require.resolve('./schedule'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||||
|
* or more contributor license agreements. Licensed under the Elastic License
|
||||||
|
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||||
|
* 2.0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import expect from '@kbn/expect';
|
||||||
|
import { FtrProviderContext } from '../ftr_provider_context';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default function ({ getService }: FtrProviderContext) {
|
||||||
|
const reportingAPI = getService('reportingAPI');
|
||||||
|
|
||||||
|
describe('Scheduled Reports', () => {
|
||||||
|
before(async () => {
|
||||||
|
await reportingAPI.initLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await reportingAPI.teardownLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await reportingAPI.deleteAllReports();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when scheduling reports', async () => {
|
||||||
|
const res = await reportingAPI.schedulePdf(
|
||||||
|
reportingAPI.REPORTING_USER_USERNAME,
|
||||||
|
reportingAPI.REPORTING_USER_PASSWORD,
|
||||||
|
{
|
||||||
|
browserTimezone: 'UTC',
|
||||||
|
title: 'test PDF allowed',
|
||||||
|
layout: { id: 'preserve_layout' },
|
||||||
|
locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }],
|
||||||
|
objectType: 'dashboard',
|
||||||
|
version: '7.14.0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(res.status).to.eql(403);
|
||||||
|
expect(res.body.message).to.eql(
|
||||||
|
`Security and API keys must be enabled for scheduled reporting`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -15,6 +15,8 @@ import {
|
||||||
REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY,
|
REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY,
|
||||||
} from '@kbn/reporting-server';
|
} from '@kbn/reporting-server';
|
||||||
import rison from '@kbn/rison';
|
import rison from '@kbn/rison';
|
||||||
|
import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
|
||||||
|
import { RruleSchedule } from '@kbn/task-manager-plugin/server';
|
||||||
import { FtrProviderContext } from '../ftr_provider_context';
|
import { FtrProviderContext } from '../ftr_provider_context';
|
||||||
|
|
||||||
function removeWhitespace(str: string) {
|
function removeWhitespace(str: string) {
|
||||||
|
@ -39,6 +41,9 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
const REPORTING_USER_USERNAME = 'reporting_user';
|
const REPORTING_USER_USERNAME = 'reporting_user';
|
||||||
const REPORTING_USER_PASSWORD = 'reporting_user-password';
|
const REPORTING_USER_PASSWORD = 'reporting_user-password';
|
||||||
const REPORTING_ROLE = 'test_reporting_user';
|
const REPORTING_ROLE = 'test_reporting_user';
|
||||||
|
const MANAGE_REPORTING_USER_USERNAME = 'manage_reporting_user';
|
||||||
|
const MANAGE_REPORTING_USER_PASSWORD = 'manage_reporting_user-password';
|
||||||
|
const MANAGE_REPORTING_ROLE = 'manage_reporting_role';
|
||||||
|
|
||||||
const logTaskManagerHealth = async () => {
|
const logTaskManagerHealth = async () => {
|
||||||
// Check task manager health for analyzing test failures. See https://github.com/elastic/kibana/issues/114946
|
// Check task manager health for analyzing test failures. See https://github.com/elastic/kibana/issues/114946
|
||||||
|
@ -126,6 +131,36 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createManageReportingUserRole = async () => {
|
||||||
|
await security.role.create(MANAGE_REPORTING_ROLE, {
|
||||||
|
metadata: {},
|
||||||
|
elasticsearch: {
|
||||||
|
cluster: [],
|
||||||
|
indices: [
|
||||||
|
{
|
||||||
|
names: ['ecommerce'],
|
||||||
|
privileges: ['read', 'view_index_metadata'],
|
||||||
|
allow_restricted_indices: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
run_as: [],
|
||||||
|
},
|
||||||
|
kibana: [
|
||||||
|
{
|
||||||
|
base: [],
|
||||||
|
feature: {
|
||||||
|
manageReporting: ['all'],
|
||||||
|
dashboard: ['minimal_read', 'download_csv_report', 'generate_report'],
|
||||||
|
discover: ['minimal_read', 'generate_report'],
|
||||||
|
canvas: ['minimal_read', 'generate_report'],
|
||||||
|
visualize: ['minimal_read', 'generate_report'],
|
||||||
|
},
|
||||||
|
spaces: ['*'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const createDataAnalyst = async () => {
|
const createDataAnalyst = async () => {
|
||||||
await security.user.create('data_analyst', {
|
await security.user.create('data_analyst', {
|
||||||
password: 'data_analyst-password',
|
password: 'data_analyst-password',
|
||||||
|
@ -134,6 +169,14 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createManageReportingUser = async () => {
|
||||||
|
await security.user.create(MANAGE_REPORTING_USER_USERNAME, {
|
||||||
|
password: MANAGE_REPORTING_USER_PASSWORD,
|
||||||
|
roles: [MANAGE_REPORTING_ROLE],
|
||||||
|
full_name: 'Manage Reporting User',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const createTestReportingUser = async () => {
|
const createTestReportingUser = async () => {
|
||||||
await security.user.create(REPORTING_USER_USERNAME, {
|
await security.user.create(REPORTING_USER_USERNAME, {
|
||||||
password: REPORTING_USER_PASSWORD,
|
password: REPORTING_USER_PASSWORD,
|
||||||
|
@ -156,6 +199,19 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
.set('kbn-xsrf', 'xxx')
|
.set('kbn-xsrf', 'xxx')
|
||||||
.send({ jobParams });
|
.send({ jobParams });
|
||||||
};
|
};
|
||||||
|
const schedulePdf = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
job: JobParamsPDFV2,
|
||||||
|
schedule: RruleSchedule = { rrule: { freq: 1, interval: 1, tzid: 'UTC' } }
|
||||||
|
) => {
|
||||||
|
const jobParams = rison.encode(job);
|
||||||
|
return await supertestWithoutAuth
|
||||||
|
.post(`/internal/reporting/schedule/printablePdfV2`)
|
||||||
|
.auth(username, password)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.send({ jobParams, schedule });
|
||||||
|
};
|
||||||
const generatePng = async (
|
const generatePng = async (
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
@ -170,6 +226,19 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
.set('kbn-xsrf', 'xxx')
|
.set('kbn-xsrf', 'xxx')
|
||||||
.send({ jobParams });
|
.send({ jobParams });
|
||||||
};
|
};
|
||||||
|
const schedulePng = async (
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
job: JobParamsPNGV2,
|
||||||
|
schedule: RruleSchedule = { rrule: { freq: 1, interval: 1, tzid: 'UTC' } }
|
||||||
|
) => {
|
||||||
|
const jobParams = rison.encode(job);
|
||||||
|
return await supertestWithoutAuth
|
||||||
|
.post(`/internal/reporting/schedule/pngV2`)
|
||||||
|
.auth(username, password)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.send({ jobParams, schedule });
|
||||||
|
};
|
||||||
const generateCsv = async (
|
const generateCsv = async (
|
||||||
job: JobParamsCSV,
|
job: JobParamsCSV,
|
||||||
username = 'elastic',
|
username = 'elastic',
|
||||||
|
@ -184,6 +253,46 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
.set('kbn-xsrf', 'xxx')
|
.set('kbn-xsrf', 'xxx')
|
||||||
.send({ jobParams });
|
.send({ jobParams });
|
||||||
};
|
};
|
||||||
|
const scheduleCsv = async (
|
||||||
|
job: JobParamsCSV,
|
||||||
|
username = 'elastic',
|
||||||
|
password = process.env.TEST_KIBANA_PASS || 'changeme',
|
||||||
|
schedule: RruleSchedule = { rrule: { freq: 1, interval: 1, tzid: 'UTC' } }
|
||||||
|
) => {
|
||||||
|
const jobParams = rison.encode(job);
|
||||||
|
|
||||||
|
return await supertestWithoutAuth
|
||||||
|
.post(`/internal/reporting/schedule/csv_searchsource`)
|
||||||
|
.auth(username, password)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.send({ jobParams, schedule });
|
||||||
|
};
|
||||||
|
|
||||||
|
const listScheduledReports = async (
|
||||||
|
username = 'elastic',
|
||||||
|
password = process.env.TEST_KIBANA_PASS || 'changeme'
|
||||||
|
) => {
|
||||||
|
const res = await supertestWithoutAuth
|
||||||
|
.get(INTERNAL_ROUTES.SCHEDULED.LIST)
|
||||||
|
.auth(username, password)
|
||||||
|
.set('kbn-xsrf', 'xxx');
|
||||||
|
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableScheduledReports = async (
|
||||||
|
ids: string[],
|
||||||
|
username = 'elastic',
|
||||||
|
password = process.env.TEST_KIBANA_PASS || 'changeme'
|
||||||
|
) => {
|
||||||
|
const { body } = await supertestWithoutAuth
|
||||||
|
.patch(INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE)
|
||||||
|
.auth(username, password)
|
||||||
|
.set('kbn-xsrf', 'xxx')
|
||||||
|
.send({ ids })
|
||||||
|
.expect(200);
|
||||||
|
return body;
|
||||||
|
};
|
||||||
|
|
||||||
const postJob = async (
|
const postJob = async (
|
||||||
apiPath: string,
|
apiPath: string,
|
||||||
|
@ -221,7 +330,6 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
.get(`${INTERNAL_ROUTES.JOBS.LIST}?page=0&ids=${id}`)
|
.get(`${INTERNAL_ROUTES.JOBS.LIST}?page=0&ids=${id}`)
|
||||||
.auth(username, password)
|
.auth(username, password)
|
||||||
.set('kbn-xsrf', 'xxx')
|
.set('kbn-xsrf', 'xxx')
|
||||||
.send()
|
|
||||||
.expect(200);
|
.expect(200);
|
||||||
return job?.output?.error_code;
|
return job?.output?.error_code;
|
||||||
};
|
};
|
||||||
|
@ -290,6 +398,30 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
.expect(200);
|
.expect(200);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getScheduledReports = async (id: string) => {
|
||||||
|
return await esSupertest.get(
|
||||||
|
`/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteScheduledReports = async (ids: string[]) => {
|
||||||
|
return await Promise.all(
|
||||||
|
ids.map((id) =>
|
||||||
|
esSupertest.delete(`/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTask = async (taskId: string) => {
|
||||||
|
return await esSupertest.get(`/.kibana_task_manager/_doc/task:${taskId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTasks = async (ids: string[]) => {
|
||||||
|
return await Promise.all(
|
||||||
|
ids.map((id) => esSupertest.delete(`/.kibana_task_manager/_doc/task:${id}`))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logTaskManagerHealth,
|
logTaskManagerHealth,
|
||||||
initEcommerce,
|
initEcommerce,
|
||||||
|
@ -301,13 +433,21 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
REPORTING_USER_USERNAME,
|
REPORTING_USER_USERNAME,
|
||||||
REPORTING_USER_PASSWORD,
|
REPORTING_USER_PASSWORD,
|
||||||
REPORTING_ROLE,
|
REPORTING_ROLE,
|
||||||
|
MANAGE_REPORTING_USER_USERNAME,
|
||||||
|
MANAGE_REPORTING_USER_PASSWORD,
|
||||||
|
MANAGE_REPORTING_ROLE,
|
||||||
createDataAnalystRole,
|
createDataAnalystRole,
|
||||||
createDataAnalyst,
|
createDataAnalyst,
|
||||||
createTestReportingUserRole,
|
createTestReportingUserRole,
|
||||||
createTestReportingUser,
|
createTestReportingUser,
|
||||||
|
createManageReportingUserRole,
|
||||||
|
createManageReportingUser,
|
||||||
generatePdf,
|
generatePdf,
|
||||||
generatePng,
|
generatePng,
|
||||||
generateCsv,
|
generateCsv,
|
||||||
|
schedulePdf,
|
||||||
|
schedulePng,
|
||||||
|
scheduleCsv,
|
||||||
listReports,
|
listReports,
|
||||||
postJob,
|
postJob,
|
||||||
postJobJSON,
|
postJobJSON,
|
||||||
|
@ -317,5 +457,11 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
||||||
migrateReportingIndices,
|
migrateReportingIndices,
|
||||||
makeAllReportingIndicesUnmanaged,
|
makeAllReportingIndicesUnmanaged,
|
||||||
getJobErrorCode,
|
getJobErrorCode,
|
||||||
|
getScheduledReports,
|
||||||
|
deleteScheduledReports,
|
||||||
|
getTask,
|
||||||
|
deleteTasks,
|
||||||
|
listScheduledReports,
|
||||||
|
disableScheduledReports,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,6 +69,7 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
generalCases: 0,
|
generalCases: 0,
|
||||||
generalCasesV2: 0,
|
generalCasesV2: 0,
|
||||||
generalCasesV3: 0,
|
generalCasesV3: 0,
|
||||||
|
manageReporting: 0,
|
||||||
maps: 2,
|
maps: 2,
|
||||||
maps_v2: 2,
|
maps_v2: 2,
|
||||||
canvas: 2,
|
canvas: 2,
|
||||||
|
|
|
@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard/downloadCsv",
|
"ui:dashboard/downloadCsv",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
],
|
],
|
||||||
|
@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard/generateScreenshot",
|
"ui:dashboard/generateScreenshot",
|
||||||
"ui:dashboard_v2/generateScreenshot",
|
"ui:dashboard_v2/generateScreenshot",
|
||||||
],
|
],
|
||||||
|
@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:url/delete",
|
"saved_object:url/delete",
|
||||||
"saved_object:url/bulk_delete",
|
"saved_object:url/bulk_delete",
|
||||||
"saved_object:url/share_to_space",
|
"saved_object:url/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:visualize/show",
|
"ui:visualize/show",
|
||||||
"ui:visualize/delete",
|
"ui:visualize/delete",
|
||||||
"ui:visualize/save",
|
"ui:visualize/save",
|
||||||
|
@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
],
|
],
|
||||||
"generate_report": Array [
|
"generate_report": Array [
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/generateScreenshot",
|
"ui:dashboard_v2/generateScreenshot",
|
||||||
],
|
],
|
||||||
"minimal_all": Array [
|
"minimal_all": Array [
|
||||||
|
@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:url/delete",
|
"saved_object:url/delete",
|
||||||
"saved_object:url/bulk_delete",
|
"saved_object:url/bulk_delete",
|
||||||
"saved_object:url/share_to_space",
|
"saved_object:url/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:visualize_v2/show",
|
"ui:visualize_v2/show",
|
||||||
"ui:visualize_v2/delete",
|
"ui:visualize_v2/delete",
|
||||||
"ui:visualize_v2/save",
|
"ui:visualize_v2/save",
|
||||||
|
@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover/generateCsv",
|
"ui:discover/generateCsv",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
],
|
],
|
||||||
|
@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
],
|
],
|
||||||
"minimal_all": Array [
|
"minimal_all": Array [
|
||||||
|
@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
|
|
@ -1587,6 +1587,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -1714,6 +1726,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard/downloadCsv",
|
"ui:dashboard/downloadCsv",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
],
|
],
|
||||||
|
@ -1721,6 +1745,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard/generateScreenshot",
|
"ui:dashboard/generateScreenshot",
|
||||||
"ui:dashboard_v2/generateScreenshot",
|
"ui:dashboard_v2/generateScreenshot",
|
||||||
],
|
],
|
||||||
|
@ -1885,6 +1921,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:url/delete",
|
"saved_object:url/delete",
|
||||||
"saved_object:url/bulk_delete",
|
"saved_object:url/bulk_delete",
|
||||||
"saved_object:url/share_to_space",
|
"saved_object:url/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:visualize/show",
|
"ui:visualize/show",
|
||||||
"ui:visualize/delete",
|
"ui:visualize/delete",
|
||||||
"ui:visualize/save",
|
"ui:visualize/save",
|
||||||
|
@ -2232,6 +2280,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -2340,12 +2400,36 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
],
|
],
|
||||||
"generate_report": Array [
|
"generate_report": Array [
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/generateScreenshot",
|
"ui:dashboard_v2/generateScreenshot",
|
||||||
],
|
],
|
||||||
"minimal_all": Array [
|
"minimal_all": Array [
|
||||||
|
@ -2489,6 +2573,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:url/delete",
|
"saved_object:url/delete",
|
||||||
"saved_object:url/bulk_delete",
|
"saved_object:url/bulk_delete",
|
||||||
"saved_object:url/share_to_space",
|
"saved_object:url/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:visualize_v2/show",
|
"ui:visualize_v2/show",
|
||||||
"ui:visualize_v2/delete",
|
"ui:visualize_v2/delete",
|
||||||
"ui:visualize_v2/save",
|
"ui:visualize_v2/save",
|
||||||
|
@ -2814,6 +2910,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -2857,6 +2965,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover/generateCsv",
|
"ui:discover/generateCsv",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
],
|
],
|
||||||
|
@ -3165,6 +3285,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -3200,6 +3332,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
],
|
],
|
||||||
"minimal_all": Array [
|
"minimal_all": Array [
|
||||||
|
@ -6514,6 +6658,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
@ -6559,6 +6715,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
|
|
@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard/downloadCsv",
|
"ui:dashboard/downloadCsv",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
],
|
],
|
||||||
|
@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard/generateScreenshot",
|
"ui:dashboard/generateScreenshot",
|
||||||
"ui:dashboard_v2/generateScreenshot",
|
"ui:dashboard_v2/generateScreenshot",
|
||||||
],
|
],
|
||||||
|
@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:url/delete",
|
"saved_object:url/delete",
|
||||||
"saved_object:url/bulk_delete",
|
"saved_object:url/bulk_delete",
|
||||||
"saved_object:url/share_to_space",
|
"saved_object:url/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:visualize/show",
|
"ui:visualize/show",
|
||||||
"ui:visualize/delete",
|
"ui:visualize/delete",
|
||||||
"ui:visualize/save",
|
"ui:visualize/save",
|
||||||
|
@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
],
|
],
|
||||||
"generate_report": Array [
|
"generate_report": Array [
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/generateScreenshot",
|
"ui:dashboard_v2/generateScreenshot",
|
||||||
],
|
],
|
||||||
"minimal_all": Array [
|
"minimal_all": Array [
|
||||||
|
@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:url/delete",
|
"saved_object:url/delete",
|
||||||
"saved_object:url/bulk_delete",
|
"saved_object:url/bulk_delete",
|
||||||
"saved_object:url/share_to_space",
|
"saved_object:url/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:visualize_v2/show",
|
"ui:visualize_v2/show",
|
||||||
"ui:visualize_v2/delete",
|
"ui:visualize_v2/delete",
|
||||||
"ui:visualize_v2/save",
|
"ui:visualize_v2/save",
|
||||||
|
@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover/generateCsv",
|
"ui:discover/generateCsv",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
],
|
],
|
||||||
|
@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"saved_object:index-pattern/bulk_get",
|
"saved_object:index-pattern/bulk_get",
|
||||||
"saved_object:index-pattern/get",
|
"saved_object:index-pattern/get",
|
||||||
"saved_object:index-pattern/find",
|
"saved_object:index-pattern/find",
|
||||||
|
@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"login:",
|
"login:",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
],
|
],
|
||||||
"minimal_all": Array [
|
"minimal_all": Array [
|
||||||
|
@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
|
|
@ -89,6 +89,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
@ -134,6 +146,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:cloud/close_point_in_time",
|
"saved_object:cloud/close_point_in_time",
|
||||||
"api:downloadCsv",
|
"api:downloadCsv",
|
||||||
"ui:management/insightsAndAlerting/reporting",
|
"ui:management/insightsAndAlerting/reporting",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:dashboard_v2/downloadCsv",
|
"ui:dashboard_v2/downloadCsv",
|
||||||
"api:generateReport",
|
"api:generateReport",
|
||||||
"ui:discover_v2/generateCsv",
|
"ui:discover_v2/generateCsv",
|
||||||
|
@ -879,6 +903,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover_v2/show",
|
"ui:discover_v2/show",
|
||||||
"ui:discover_v2/save",
|
"ui:discover_v2/save",
|
||||||
"ui:discover_v2/createShortUrl",
|
"ui:discover_v2/createShortUrl",
|
||||||
|
@ -1766,6 +1802,18 @@ export default function ({ getService }: FtrProviderContext) {
|
||||||
"saved_object:search-session/delete",
|
"saved_object:search-session/delete",
|
||||||
"saved_object:search-session/bulk_delete",
|
"saved_object:search-session/bulk_delete",
|
||||||
"saved_object:search-session/share_to_space",
|
"saved_object:search-session/share_to_space",
|
||||||
|
"saved_object:scheduled_report/bulk_get",
|
||||||
|
"saved_object:scheduled_report/get",
|
||||||
|
"saved_object:scheduled_report/find",
|
||||||
|
"saved_object:scheduled_report/open_point_in_time",
|
||||||
|
"saved_object:scheduled_report/close_point_in_time",
|
||||||
|
"saved_object:scheduled_report/create",
|
||||||
|
"saved_object:scheduled_report/bulk_create",
|
||||||
|
"saved_object:scheduled_report/update",
|
||||||
|
"saved_object:scheduled_report/bulk_update",
|
||||||
|
"saved_object:scheduled_report/delete",
|
||||||
|
"saved_object:scheduled_report/bulk_delete",
|
||||||
|
"saved_object:scheduled_report/share_to_space",
|
||||||
"ui:discover_v2/show",
|
"ui:discover_v2/show",
|
||||||
"ui:discover_v2/save",
|
"ui:discover_v2/save",
|
||||||
"ui:discover_v2/createShortUrl",
|
"ui:discover_v2/createShortUrl",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue