mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -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",
|
||||
"unInstallCount"
|
||||
],
|
||||
"scheduled_report": [
|
||||
"createdBy"
|
||||
],
|
||||
"search": [
|
||||
"description",
|
||||
"title"
|
||||
|
|
|
@ -3119,6 +3119,14 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"scheduled_report": {
|
||||
"dynamic": false,
|
||||
"properties": {
|
||||
"createdBy": {
|
||||
"type": "keyword"
|
||||
}
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"dynamic": false,
|
||||
"properties": {
|
||||
|
|
|
@ -126,6 +126,7 @@ const previouslyRegisteredTypes = [
|
|||
'query',
|
||||
'rules-settings',
|
||||
'sample-data-telemetry',
|
||||
'scheduled_report',
|
||||
'search',
|
||||
'search-session',
|
||||
'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 {
|
||||
static code = 'queue_timeout_error' as const;
|
||||
public get code(): string {
|
||||
|
|
|
@ -24,8 +24,13 @@ export const INTERNAL_ROUTES = {
|
|||
DELETE_PREFIX: prefixInternalPath + '/jobs/delete', // 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',
|
||||
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';
|
||||
|
|
|
@ -66,6 +66,7 @@ export interface BaseParams {
|
|||
objectType: string;
|
||||
title: string;
|
||||
version: string; // to handle any state migrations
|
||||
forceNow?: string;
|
||||
layout?: LayoutParams; // png & pdf only
|
||||
pagingStrategy?: CsvPagingStrategy; // csv only
|
||||
}
|
||||
|
@ -152,6 +153,7 @@ export interface ReportSource {
|
|||
created_at: string; // timestamp in UTC
|
||||
'@timestamp'?: string; // creation timestamp, only used for data streams compatibility
|
||||
status: JOB_STATUS;
|
||||
scheduled_report_id?: string;
|
||||
|
||||
/*
|
||||
* `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', () => {
|
||||
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', () => {
|
||||
|
@ -82,6 +92,16 @@ describe('check_license', () => {
|
|||
it('should set management.jobTypes to 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', () => {
|
||||
|
@ -121,6 +141,18 @@ describe('check_license', () => {
|
|||
'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', () => {
|
||||
|
@ -147,6 +179,18 @@ describe('check_license', () => {
|
|||
it('should set management.jobTypes to 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).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should set scheduledReports.showLinks to false', () => {
|
||||
expect(checkLicense(exportTypesRegistry, license).scheduledReports.showLinks).toEqual(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('& license is expired', () => {
|
||||
|
@ -193,6 +243,12 @@ describe('check_license', () => {
|
|||
it('should set management.jobTypes to 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".
|
||||
*/
|
||||
|
||||
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 { ExportTypesRegistry } from './export_types_registry';
|
||||
|
||||
|
@ -18,6 +25,14 @@ export interface LicenseCheckResult {
|
|||
jobTypes?: string[];
|
||||
}
|
||||
|
||||
const scheduledReportValidLicenses: LicenseType[] = [
|
||||
LICENSE_TYPE_TRIAL,
|
||||
LICENSE_TYPE_CLOUD_STANDARD,
|
||||
LICENSE_TYPE_GOLD,
|
||||
LICENSE_TYPE_PLATINUM,
|
||||
LICENSE_TYPE_ENTERPRISE,
|
||||
];
|
||||
|
||||
const messages = {
|
||||
getUnavailable: () => {
|
||||
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) => {
|
||||
return {
|
||||
id: exportType.id,
|
||||
|
@ -104,6 +155,7 @@ export function checkLicense(
|
|||
const reportingFeatures = [
|
||||
...exportTypes.map(makeExportTypeFeature),
|
||||
makeManagementFeature(exportTypes),
|
||||
makeScheduledReportsFeature(),
|
||||
];
|
||||
|
||||
return reportingFeatures.reduce((result, feature) => {
|
||||
|
|
|
@ -107,7 +107,9 @@ it('Provides a feature declaration ', () => {
|
|||
"minimumLicense": "gold",
|
||||
"name": "Generate PDF reports",
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -216,7 +218,9 @@ it(`Calls on Reporting whether to include Generate PDF as a sub-feature`, () =>
|
|||
"minimumLicense": "gold",
|
||||
"name": "Generate PDF reports",
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
|
|
@ -68,7 +68,7 @@ export function getCanvasFeature(plugins: { reporting?: ReportingStart }): Kiban
|
|||
includeIn: 'all',
|
||||
management: { insightsAndAlerting: ['reporting'] },
|
||||
minimumLicense: 'gold',
|
||||
savedObject: { all: [], read: [] },
|
||||
savedObject: { all: ['scheduled_report'], read: [] },
|
||||
api: ['generateReport'],
|
||||
ui: ['generatePdf'],
|
||||
},
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"reporting"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"actions",
|
||||
"data",
|
||||
"discover",
|
||||
"encryptedSavedObjects",
|
||||
|
|
|
@ -23,6 +23,8 @@ import type {
|
|||
StatusServiceSetup,
|
||||
UiSettingsServiceStart,
|
||||
} 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 { DiscoverServerPluginStart } from '@kbn/discover-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 { ExportType } from '@kbn/reporting-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 type { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
|
||||
import type {
|
||||
|
@ -52,11 +54,20 @@ import type { ReportingSetup } from '.';
|
|||
import { createConfig } from './config';
|
||||
import { reportingEventLoggerFactory } from './lib/event_logger/logger';
|
||||
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 { 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 {
|
||||
actions: ActionsPluginSetupContract;
|
||||
basePath: Pick<IBasePath, 'set'>;
|
||||
docLinks: DocLinksServiceSetup;
|
||||
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
|
||||
|
@ -83,6 +94,7 @@ export interface ReportingInternalStart {
|
|||
logger: Logger;
|
||||
notifications: NotificationsPluginStart;
|
||||
screenshotting?: ScreenshottingStart;
|
||||
security?: SecurityPluginStart;
|
||||
securityService: SecurityServiceStart;
|
||||
taskManager: TaskManagerStartContract;
|
||||
}
|
||||
|
@ -96,7 +108,8 @@ export class ReportingCore {
|
|||
private pluginStartDeps?: ReportingInternalStart;
|
||||
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 executeTask: ExecuteReportTask;
|
||||
private runSingleReportTask: RunSingleReportTask;
|
||||
private runScheduledReportTask: RunScheduledReportTask;
|
||||
private config: ReportingConfigType;
|
||||
private executing: Set<string>;
|
||||
private exportTypesRegistry = new ExportTypesRegistry();
|
||||
|
@ -117,7 +130,16 @@ export class ReportingCore {
|
|||
this.getExportTypes().forEach((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 = () => ({
|
||||
registerExportTypes: (id) => id,
|
||||
|
@ -142,9 +164,10 @@ export class ReportingCore {
|
|||
et.setup(setupDeps);
|
||||
});
|
||||
|
||||
const { executeTask } = this;
|
||||
const { runSingleReportTask, runScheduledReportTask } = this;
|
||||
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 });
|
||||
});
|
||||
|
||||
const { taskManager } = startDeps;
|
||||
const { executeTask } = this;
|
||||
const { taskManager, notifications } = startDeps;
|
||||
const emailNotificationService = new EmailNotificationService({
|
||||
notifications,
|
||||
});
|
||||
|
||||
const { runSingleReportTask, runScheduledReportTask } = this;
|
||||
// enable this instance to generate reports
|
||||
await Promise.all([executeTask.init(taskManager)]);
|
||||
await Promise.all([
|
||||
runSingleReportTask.init(taskManager),
|
||||
runScheduledReportTask.init(taskManager, emailNotificationService),
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
@ -322,13 +364,25 @@ export class ReportingCore {
|
|||
}
|
||||
|
||||
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() {
|
||||
return (await this.getPluginStartDeps()).store;
|
||||
}
|
||||
|
||||
public async getAuditLogger(request: KibanaRequest) {
|
||||
const startDeps = await this.getPluginStartDeps();
|
||||
return startDeps.securityService.audit.asScoped(request);
|
||||
}
|
||||
|
||||
public async getLicenseInfo() {
|
||||
const { license$ } = (await this.getPluginStartDeps()).licensing;
|
||||
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
|
||||
*/
|
||||
|
@ -374,6 +435,24 @@ export class ReportingCore {
|
|||
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() {
|
||||
const startDeps = await this.getPluginStartDeps();
|
||||
return startDeps.data;
|
||||
|
|
|
@ -9,6 +9,11 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import type { FeaturesPluginSetup } from '@kbn/features-plugin/server';
|
||||
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 {
|
||||
features: FeaturesPluginSetup;
|
||||
|
@ -37,4 +42,29 @@ export function registerFeatures({ isServerless, features }: FeatureRegistration
|
|||
}
|
||||
|
||||
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 { SavedReport } from './saved_report';
|
||||
export { ScheduledReport } from './scheduled_report';
|
||||
export { ReportingStore } from './store';
|
||||
export { IlmPolicyManager } from './ilm_policy_manager';
|
||||
|
||||
|
|
|
@ -63,6 +63,61 @@ describe('Class Report', () => {
|
|||
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', () => {
|
||||
const report = new Report({
|
||||
_index: '.reporting-test-index-12345',
|
||||
|
|
|
@ -41,6 +41,7 @@ export class Report implements Partial<ReportSource & ReportDocumentHead> {
|
|||
|
||||
public readonly status: ReportSource['status'];
|
||||
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
|
||||
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.output = opts.output || null;
|
||||
this.error = opts.error;
|
||||
this.scheduled_report_id = opts.scheduled_report_id;
|
||||
|
||||
this.queue_time_ms = fields?.queue_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,
|
||||
output: this.output || null,
|
||||
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'),
|
||||
output: omit(this.output, 'content'),
|
||||
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 () => {
|
||||
const reportingConfig = {
|
||||
index: '.reporting-test',
|
||||
|
@ -181,6 +228,7 @@ describe('ReportingStore', () => {
|
|||
},
|
||||
"process_expiration": undefined,
|
||||
"queue_time_ms": undefined,
|
||||
"scheduled_report_id": undefined,
|
||||
"space_id": undefined,
|
||||
"started_at": undefined,
|
||||
"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', () => {
|
||||
class TestReportingStore extends ReportingStore {
|
||||
constructor(...args: ConstructorParameters<typeof ReportingStore>) {
|
||||
|
|
|
@ -53,6 +53,11 @@ export type ReportCompletedFields = Required<{
|
|||
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
|
||||
*/
|
||||
|
@ -145,8 +150,8 @@ export class ReportingStore {
|
|||
...report.toReportSource(),
|
||||
...sourceDoc({
|
||||
process_expiration: new Date(0).toISOString(),
|
||||
attempts: 0,
|
||||
status: JOB_STATUS.PENDING,
|
||||
attempts: report.attempts || 0,
|
||||
status: report.status || JOB_STATUS.PENDING,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
@ -337,4 +342,31 @@ export class ReportingStore {
|
|||
|
||||
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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
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 { ExecuteReportTask } from './execute_report';
|
||||
export { RunSingleReportTask } from './run_single_report';
|
||||
export { RunScheduledReportTask } from './run_scheduled_report';
|
||||
|
||||
export interface ReportTaskParams<JobPayloadType = BasePayload> {
|
||||
id: string;
|
||||
|
@ -25,18 +27,21 @@ export interface ReportTaskParams<JobPayloadType = BasePayload> {
|
|||
meta: ReportSource['meta'];
|
||||
}
|
||||
|
||||
export interface ScheduledReportTaskParams {
|
||||
id: string;
|
||||
jobtype: ReportSource['jobtype'];
|
||||
spaceId: string;
|
||||
schedule: RruleSchedule;
|
||||
}
|
||||
|
||||
export type ScheduledReportTaskParamsWithoutSpaceId = Omit<ScheduledReportTaskParams, 'spaceId'>;
|
||||
|
||||
export enum ReportingTaskStatus {
|
||||
UNINITIALIZED = 'uninitialized',
|
||||
INITIALIZED = 'initialized',
|
||||
}
|
||||
|
||||
export interface ReportingTask {
|
||||
getTaskDefinition: () => {
|
||||
type: string;
|
||||
title: string;
|
||||
createTaskRunner: TaskRunCreatorFunction;
|
||||
maxAttempts: number;
|
||||
timeout: string;
|
||||
};
|
||||
getTaskDefinition: () => TaskRegisterDefinition;
|
||||
getStatus: () => ReportingTaskStatus;
|
||||
}
|
||||
|
|
|
@ -11,11 +11,11 @@ import { timeout } from 'rxjs';
|
|||
import { Writable } from 'stream';
|
||||
import type { FakeRawRequest, Headers } from '@kbn/core-http-server';
|
||||
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 {
|
||||
CancellationToken,
|
||||
KibanaShuttingDownError,
|
||||
QueueTimeoutError,
|
||||
MissingAuthenticationError,
|
||||
ReportingError,
|
||||
durationToNumber,
|
||||
numberToDuration,
|
||||
|
@ -28,34 +28,28 @@ import type {
|
|||
TaskRunResult,
|
||||
} from '@kbn/reporting-common/types';
|
||||
import { decryptJobHeaders, type ReportingConfigType } from '@kbn/reporting-server';
|
||||
import type {
|
||||
RunContext,
|
||||
TaskManagerStartContract,
|
||||
TaskRunCreatorFunction,
|
||||
import {
|
||||
throwRetryableError,
|
||||
type ConcreteTaskInstance,
|
||||
type RunContext,
|
||||
type TaskManagerStartContract,
|
||||
type TaskRegisterDefinition,
|
||||
type TaskRunCreatorFunction,
|
||||
} from '@kbn/task-manager-plugin/server';
|
||||
import { throwRetryableError } from '@kbn/task-manager-plugin/server';
|
||||
|
||||
import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry';
|
||||
import { kibanaRequestFactory } from '@kbn/core-http-server-utils';
|
||||
import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common';
|
||||
import {
|
||||
REPORTING_EXECUTE_TYPE,
|
||||
ReportTaskParams,
|
||||
ReportingTask,
|
||||
ReportingTaskStatus,
|
||||
TIME_BETWEEN_ATTEMPTS,
|
||||
} from '.';
|
||||
import { getContentStream, finishedWithNoPendingCallbacks } from '../content_stream';
|
||||
import { mapToReportingError } from '../../../common/errors/map_to_reporting_error';
|
||||
import { ReportTaskParams, ReportingTask, ReportingTaskStatus, TIME_BETWEEN_ATTEMPTS } from '.';
|
||||
import type { ReportingCore } from '../..';
|
||||
import {
|
||||
isExecutionError,
|
||||
mapToReportingError,
|
||||
} from '../../../common/errors/map_to_reporting_error';
|
||||
import { EventTracker } from '../../usage';
|
||||
import type { ReportingStore } 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 { finishedWithNoPendingCallbacks, getContentStream } from '../content_stream';
|
||||
import { EmailNotificationService } from '../../services/notifications/email_notification_service';
|
||||
import { ScheduledReportType } from '../../types';
|
||||
|
||||
type CompletedReportOutput = Omit<ReportOutput, 'content'>;
|
||||
|
||||
|
@ -72,12 +66,6 @@ interface GetHeadersOpts {
|
|||
requestFromTask?: KibanaRequest;
|
||||
spaceId: string | undefined;
|
||||
}
|
||||
interface ReportingExecuteTaskInstance {
|
||||
state: object;
|
||||
taskType: string;
|
||||
params: ReportTaskParams;
|
||||
runAt?: Date;
|
||||
}
|
||||
|
||||
function isOutput(output: CompletedReportOutput | Error): output is CompletedReportOutput {
|
||||
return (output as CompletedReportOutput).size != null;
|
||||
|
@ -95,63 +83,100 @@ function parseError(error: unknown): ExecutionError | unknown {
|
|||
return error;
|
||||
}
|
||||
|
||||
export class ExecuteReportTask implements ReportingTask {
|
||||
public TYPE = REPORTING_EXECUTE_TYPE;
|
||||
export interface ConstructorOpts {
|
||||
config: ReportingConfigType;
|
||||
logger: Logger;
|
||||
reporting: ReportingCore;
|
||||
}
|
||||
|
||||
private logger: Logger;
|
||||
private taskManagerStart?: TaskManagerStartContract;
|
||||
private kibanaId?: string;
|
||||
private kibanaName?: string;
|
||||
private exportTypesRegistry: ExportTypesRegistry;
|
||||
private store?: ReportingStore;
|
||||
private eventTracker?: EventTracker;
|
||||
export interface PrepareJobResults {
|
||||
isLastAttempt: boolean;
|
||||
jobId: string;
|
||||
report?: SavedReport;
|
||||
task?: ReportTaskParams;
|
||||
scheduledReport?: SavedObject<ScheduledReportType>;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private reporting: ReportingCore,
|
||||
private config: ReportingConfigType,
|
||||
logger: Logger
|
||||
) {
|
||||
this.logger = logger.get('runTask');
|
||||
this.exportTypesRegistry = this.reporting.getExportTypesRegistry();
|
||||
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);
|
||||
}
|
||||
|
||||
/*
|
||||
* To be called from plugin start
|
||||
*/
|
||||
public async init(taskManager: TaskManagerStartContract) {
|
||||
// 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;
|
||||
|
||||
const { reporting } = this;
|
||||
const { uuid, name } = reporting.getServerInfo();
|
||||
const { uuid, name } = this.opts.reporting.getServerInfo();
|
||||
this.kibanaId = uuid;
|
||||
this.kibanaName = name;
|
||||
|
||||
this.emailNotificationService = emailNotificationService;
|
||||
}
|
||||
|
||||
/*
|
||||
* Async get the ReportingStore: it is only available after PluginStart
|
||||
*/
|
||||
private async getStore(): Promise<ReportingStore> {
|
||||
if (this.store) {
|
||||
return this.store;
|
||||
public getStatus() {
|
||||
if (this.taskManagerStart) {
|
||||
return ReportingTaskStatus.INITIALIZED;
|
||||
}
|
||||
const { store } = await this.reporting.getPluginStartDeps();
|
||||
this.store = store;
|
||||
return store;
|
||||
|
||||
return ReportingTaskStatus.UNINITIALIZED;
|
||||
}
|
||||
|
||||
private getTaskManagerStart() {
|
||||
// Protected methods
|
||||
protected getTaskManagerStart() {
|
||||
if (!this.taskManagerStart) {
|
||||
throw new Error('Reporting task runner has not been initialized!');
|
||||
}
|
||||
return this.taskManagerStart;
|
||||
}
|
||||
|
||||
private getEventTracker(report: Report) {
|
||||
protected getEventTracker(report: Report) {
|
||||
if (this.eventTracker) {
|
||||
return this.eventTracker;
|
||||
}
|
||||
|
||||
const eventTracker = this.reporting.getEventTracker(
|
||||
const eventTracker = this.opts.reporting.getEventTracker(
|
||||
report._id,
|
||||
report.jobtype,
|
||||
report.payload.objectType
|
||||
|
@ -160,91 +185,26 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
return this.eventTracker;
|
||||
}
|
||||
|
||||
private getJobContentEncoding(jobType: string) {
|
||||
protected getJobContentEncoding(jobType: string) {
|
||||
const exportType = this.exportTypesRegistry.getByJobType(jobType);
|
||||
return exportType.jobContentEncoding;
|
||||
}
|
||||
|
||||
private async _claimJob(task: ReportTaskParams): Promise<SavedReport> {
|
||||
if (this.kibanaId == null) {
|
||||
throw new Error(`Kibana instance ID is undefined!`);
|
||||
}
|
||||
if (this.kibanaName == null) {
|
||||
throw new Error(`Kibana instance name is undefined!`);
|
||||
}
|
||||
|
||||
const store = await this.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 queueTimeout = durationToNumber(this.config.queue.timeout);
|
||||
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;
|
||||
protected getJobContentExtension(jobType: string) {
|
||||
const exportType = this.exportTypesRegistry.getByJobType(jobType);
|
||||
return exportType.jobContentExtension;
|
||||
}
|
||||
|
||||
private async _failJob(
|
||||
protected getMaxConcurrency() {
|
||||
return this.opts.config.queue.pollEnabled ? 1 : 0;
|
||||
}
|
||||
|
||||
protected getQueueTimeout() {
|
||||
// round up from ms to the nearest second
|
||||
return Math.ceil(numberToDuration(this.opts.config.queue.timeout).asSeconds()) + 's';
|
||||
}
|
||||
|
||||
protected async failJob(
|
||||
report: SavedReport,
|
||||
error?: ReportingError
|
||||
): Promise<UpdateResponse<ReportDocument>> {
|
||||
|
@ -255,13 +215,13 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
let docOutput;
|
||||
if (error) {
|
||||
errorLogger(logger, message, error);
|
||||
docOutput = this._formatOutput(error);
|
||||
docOutput = this.formatOutput(error);
|
||||
} else {
|
||||
errorLogger(logger, message);
|
||||
}
|
||||
|
||||
// update the report in the store
|
||||
const store = await this.getStore();
|
||||
const store = await this.opts.reporting.getStore();
|
||||
const completedTime = moment();
|
||||
const doc: ReportFailedFields = {
|
||||
completed_at: completedTime.toISOString(),
|
||||
|
@ -280,7 +240,7 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
return await store.setReportFailed(report, doc);
|
||||
}
|
||||
|
||||
private async _saveExecutionError(
|
||||
protected async saveExecutionError(
|
||||
report: SavedReport,
|
||||
failedToExecuteErr: any
|
||||
): Promise<UpdateResponse<ReportDocument>> {
|
||||
|
@ -291,7 +251,7 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
errorLogger(logger, message, failedToExecuteErr);
|
||||
|
||||
// update the report in the store
|
||||
const store = await this.getStore();
|
||||
const store = await this.opts.reporting.getStore();
|
||||
const doc: ReportFailedFields = {
|
||||
output: null,
|
||||
error: errorParsed,
|
||||
|
@ -300,7 +260,25 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
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 unknownMime = null;
|
||||
|
||||
|
@ -324,7 +302,7 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
return docOutput;
|
||||
}
|
||||
|
||||
private async _getRequestToUse({
|
||||
protected async getRequestToUse({
|
||||
requestFromTask,
|
||||
spaceId,
|
||||
encryptedHeaders,
|
||||
|
@ -339,17 +317,17 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
}
|
||||
|
||||
let decryptedHeaders;
|
||||
if (this.config.encryptionKey && encryptedHeaders) {
|
||||
if (this.opts.config.encryptionKey && encryptedHeaders) {
|
||||
// get decrypted headers
|
||||
decryptedHeaders = await decryptJobHeaders(
|
||||
this.config.encryptionKey,
|
||||
this.opts.config.encryptionKey,
|
||||
encryptedHeaders,
|
||||
this.logger
|
||||
);
|
||||
}
|
||||
|
||||
if (!decryptedHeaders && !apiKeyAuthHeaders) {
|
||||
throw new Error('No headers found to execute report');
|
||||
throw new MissingAuthenticationError();
|
||||
}
|
||||
|
||||
let headersToUse: Headers = {};
|
||||
|
@ -367,10 +345,10 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
headersToUse = decryptedHeaders || {};
|
||||
}
|
||||
|
||||
return this._getFakeRequest(headersToUse, spaceId, this.logger);
|
||||
return this.getFakeRequest(headersToUse, spaceId, this.logger);
|
||||
}
|
||||
|
||||
private _getFakeRequest(
|
||||
protected getFakeRequest(
|
||||
headers: Headers,
|
||||
spaceId: string | undefined,
|
||||
logger = this.logger
|
||||
|
@ -381,7 +359,7 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
};
|
||||
const fakeRequest = kibanaRequestFactory(rawRequest);
|
||||
|
||||
const setupDeps = this.reporting.getPluginSetupDeps();
|
||||
const setupDeps = this.opts.reporting.getPluginSetupDeps();
|
||||
const spacesService = setupDeps.spaces?.spacesService;
|
||||
if (spacesService) {
|
||||
if (spaceId && spaceId !== DEFAULT_SPACE_ID) {
|
||||
|
@ -392,7 +370,7 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
return fakeRequest;
|
||||
}
|
||||
|
||||
private async _performJob({
|
||||
protected async performJob({
|
||||
task,
|
||||
fakeRequest,
|
||||
taskInstanceFields,
|
||||
|
@ -405,8 +383,7 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
}
|
||||
// run the report
|
||||
// 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,
|
||||
spaceId: task.payload.spaceId,
|
||||
encryptedHeaders: task.payload.headers,
|
||||
|
@ -422,11 +399,11 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
cancellationToken,
|
||||
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,
|
||||
output: CompletedReportOutput
|
||||
): Promise<SavedReport> {
|
||||
|
@ -436,8 +413,8 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
logger.debug(`Saving ${report.jobtype} to ${docId}.`);
|
||||
|
||||
const completedTime = moment();
|
||||
const docOutput = this._formatOutput(output);
|
||||
const store = await this.getStore();
|
||||
const docOutput = this.formatOutput(output);
|
||||
const store = await this.opts.reporting.getStore();
|
||||
const doc = {
|
||||
completed_at: completedTime.toISOString(),
|
||||
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.
|
||||
private async throwIfKibanaShutsDown<T>(): Promise<T> {
|
||||
await Rx.firstValueFrom(this.reporting.getKibanaShutdown$());
|
||||
protected async throwIfKibanaShutsDown<T>(): Promise<T> {
|
||||
await Rx.firstValueFrom(this.opts.reporting.getKibanaShutdown$());
|
||||
throw new KibanaShuttingDownError();
|
||||
}
|
||||
|
||||
/*
|
||||
* Provides a TaskRunner for Task Manager
|
||||
*/
|
||||
private getTaskRunner(): TaskRunCreatorFunction {
|
||||
protected getTaskRunner(): TaskRunCreatorFunction {
|
||||
// Keep a separate local stack for each task run
|
||||
return ({ taskInstance, fakeRequest }: RunContext) => {
|
||||
let jobId: string;
|
||||
const cancellationToken = new CancellationToken();
|
||||
const {
|
||||
attempts: taskAttempts,
|
||||
params: reportTaskParams,
|
||||
retryAt: taskRetryAt,
|
||||
startedAt: taskStartedAt,
|
||||
} = taskInstance;
|
||||
const { retryAt: taskRetryAt, startedAt: taskStartedAt } = taskInstance;
|
||||
|
||||
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
|
||||
*/
|
||||
run: async () => {
|
||||
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;
|
||||
jobId = task?.id;
|
||||
|
||||
try {
|
||||
if (!jobId) {
|
||||
throw new Error('Invalid report data provided in scheduled task!');
|
||||
}
|
||||
if (!isLastAttempt) {
|
||||
this.reporting.trackReport(jobId);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (this.kibanaId == null) {
|
||||
throw new Error(`Kibana instance ID is undefined!`);
|
||||
}
|
||||
if (this.kibanaName == null) {
|
||||
throw new Error(`Kibana instance name is undefined!`);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
this.reporting.untrackReport(jobId);
|
||||
let report: SavedReport | undefined;
|
||||
const {
|
||||
isLastAttempt,
|
||||
jobId: jId,
|
||||
report: preparedReport,
|
||||
task,
|
||||
scheduledReport,
|
||||
} = await this.prepareJob(taskInstance);
|
||||
jobId = jId;
|
||||
report = preparedReport;
|
||||
|
||||
if (!isLastAttempt) {
|
||||
this.opts.reporting.trackReport(jobId);
|
||||
}
|
||||
|
||||
if (!report || !task) {
|
||||
this.opts.reporting.untrackReport(jobId);
|
||||
|
||||
if (isLastAttempt) {
|
||||
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 maxAttempts = this.getMaxAttempts();
|
||||
const logger = this.logger.get(jobId);
|
||||
|
||||
logger.debug(
|
||||
`Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.`
|
||||
);
|
||||
logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`);
|
||||
const maxAttempts = this.getMaxAttempts();
|
||||
if (maxAttempts) {
|
||||
logger.debug(
|
||||
`Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.`
|
||||
);
|
||||
} 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 })
|
||||
);
|
||||
|
||||
try {
|
||||
const jobContentEncoding = this.getJobContentEncoding(jobType);
|
||||
const stream = await getContentStream(
|
||||
this.reporting,
|
||||
this.opts.reporting,
|
||||
{
|
||||
id: report._id,
|
||||
index: report._index,
|
||||
|
@ -574,7 +550,7 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
eventLog.logExecutionStart();
|
||||
|
||||
const output = await Promise.race<TaskRunResult>([
|
||||
this._performJob({
|
||||
this.performJob({
|
||||
task,
|
||||
fakeRequest,
|
||||
taskInstanceFields: { retryAt: taskRetryAt, startedAt: taskStartedAt },
|
||||
|
@ -593,18 +569,28 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
report._seq_no = stream.getSeqNo()!;
|
||||
report._primary_term = stream.getPrimaryTerm()!;
|
||||
|
||||
const byteSize = stream.bytesWritten;
|
||||
eventLog.logExecutionComplete({
|
||||
...(output.metrics ?? {}),
|
||||
byteSize: stream.bytesWritten,
|
||||
byteSize,
|
||||
});
|
||||
|
||||
if (output) {
|
||||
logger.debug(`Job output size: ${stream.bytesWritten} bytes.`);
|
||||
logger.debug(`Job output size: ${byteSize} bytes.`);
|
||||
// Update the job status to "completed"
|
||||
report = await this._completeJob(report, {
|
||||
report = await this.completeJob(report, {
|
||||
...output,
|
||||
size: stream.bytesWritten,
|
||||
size: byteSize,
|
||||
});
|
||||
|
||||
await this.notify(
|
||||
report,
|
||||
taskInstance,
|
||||
output,
|
||||
byteSize,
|
||||
scheduledReport,
|
||||
task.payload.spaceId
|
||||
);
|
||||
}
|
||||
|
||||
// untrack the report for concurrency awareness
|
||||
|
@ -612,11 +598,9 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
} catch (failedToExecuteErr) {
|
||||
eventLog.logError(failedToExecuteErr);
|
||||
|
||||
await this._saveExecutionError(report, failedToExecuteErr).catch(
|
||||
(failedToSaveError) => {
|
||||
errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError);
|
||||
}
|
||||
);
|
||||
await this.saveExecutionError(report, failedToExecuteErr).catch((failedToSaveError) => {
|
||||
errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError);
|
||||
});
|
||||
|
||||
cancellationToken.cancel();
|
||||
|
||||
|
@ -624,8 +608,8 @@ export class ExecuteReportTask implements ReportingTask {
|
|||
|
||||
throwRetryableError(error, new Date(Date.now() + TIME_BETWEEN_ATTEMPTS));
|
||||
} finally {
|
||||
this.reporting.untrackReport(jobId);
|
||||
logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`);
|
||||
this.opts.reporting.untrackReport(jobId);
|
||||
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 { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
|
||||
|
||||
import { ExecuteReportTask, REPORTING_EXECUTE_TYPE } from '.';
|
||||
import { RunSingleReportTask, REPORTING_EXECUTE_TYPE } from '.';
|
||||
import { ReportingCore } from '../..';
|
||||
import { createMockReportingCore } from '../../test_helpers';
|
||||
import { FakeRawRequest, KibanaRequest } from '@kbn/core/server';
|
||||
|
@ -104,7 +104,7 @@ const fakeRawRequest: FakeRawRequest = {
|
|||
path: '/',
|
||||
};
|
||||
|
||||
describe('Execute Report Task', () => {
|
||||
describe('Run Single Report Task', () => {
|
||||
let mockReporting: ReportingCore;
|
||||
let configType: ReportingConfigType;
|
||||
beforeAll(async () => {
|
||||
|
@ -113,7 +113,7 @@ describe('Execute Report Task', () => {
|
|||
});
|
||||
|
||||
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.getTaskDefinition()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -129,7 +129,7 @@ describe('Execute Report Task', () => {
|
|||
|
||||
it('Instance start', () => {
|
||||
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.getStatus()).toBe('initialized');
|
||||
});
|
||||
|
@ -138,7 +138,7 @@ describe('Execute Report Task', () => {
|
|||
logger.info = 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 taskRunner = taskDef.createTaskRunner({
|
||||
taskInstance: {
|
||||
|
@ -155,7 +155,11 @@ describe('Execute Report Task', () => {
|
|||
queue: { pollEnabled: false, timeout: 55000 },
|
||||
} 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.getTaskDefinition()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -175,7 +179,7 @@ describe('Execute Report Task', () => {
|
|||
hasPermanentEncryptionKey: true,
|
||||
areNotificationsEnabled: true,
|
||||
});
|
||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
||||
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||
const mockTaskManager = taskManagerMock.createStart();
|
||||
await task.init(mockTaskManager);
|
||||
|
||||
|
@ -208,7 +212,7 @@ describe('Execute Report Task', () => {
|
|||
hasPermanentEncryptionKey: true,
|
||||
areNotificationsEnabled: false,
|
||||
});
|
||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
||||
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||
const mockTaskManager = taskManagerMock.createStart();
|
||||
await task.init(mockTaskManager);
|
||||
|
||||
|
@ -238,7 +242,7 @@ describe('Execute Report Task', () => {
|
|||
hasPermanentEncryptionKey: false,
|
||||
areNotificationsEnabled: true,
|
||||
});
|
||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
||||
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||
const mockTaskManager = taskManagerMock.createStart();
|
||||
await task.init(mockTaskManager);
|
||||
|
||||
|
@ -275,14 +279,14 @@ describe('Execute Report Task', () => {
|
|||
jobType: 'test1',
|
||||
validLicenses: [],
|
||||
} as unknown as ExportType);
|
||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
||||
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||
jest
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
||||
.spyOn(task, '_claimJob')
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||
.spyOn(task, 'claimJob')
|
||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
|
||||
jest
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
||||
.spyOn(task, '_completeJob')
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||
.spyOn(task, 'completeJob')
|
||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never);
|
||||
const mockTaskManager = taskManagerMock.createStart();
|
||||
await task.init(mockTaskManager);
|
||||
|
@ -320,14 +324,14 @@ describe('Execute Report Task', () => {
|
|||
jobType: 'test2',
|
||||
validLicenses: [],
|
||||
} as unknown as ExportType);
|
||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
||||
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||
jest
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
||||
.spyOn(task, '_claimJob')
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||
.spyOn(task, 'claimJob')
|
||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
|
||||
jest
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
||||
.spyOn(task, '_completeJob')
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||
.spyOn(task, 'completeJob')
|
||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never);
|
||||
const mockTaskManager = taskManagerMock.createStart();
|
||||
await task.init(mockTaskManager);
|
||||
|
@ -367,14 +371,14 @@ describe('Execute Report Task', () => {
|
|||
jobType: 'test3',
|
||||
validLicenses: [],
|
||||
} as unknown as ExportType);
|
||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
||||
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||
jest
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
||||
.spyOn(task, '_claimJob')
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||
.spyOn(task, 'claimJob')
|
||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
|
||||
jest
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
||||
.spyOn(task, '_completeJob')
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||
.spyOn(task, 'completeJob')
|
||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never);
|
||||
const mockTaskManager = taskManagerMock.createStart();
|
||||
await task.init(mockTaskManager);
|
||||
|
@ -421,10 +425,10 @@ describe('Execute Report Task', () => {
|
|||
status: 'processing',
|
||||
} as unknown as estypes.UpdateUpdateWriteResponseBase<ReportDocument>)
|
||||
);
|
||||
const task = new ExecuteReportTask(mockReporting, configType, logger);
|
||||
const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger });
|
||||
jest
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance
|
||||
.spyOn(task, '_claimJob')
|
||||
// @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance
|
||||
.spyOn(task, 'claimJob')
|
||||
.mockResolvedValueOnce({ _id: 'test', jobtype: 'noop', status: 'pending' } as never);
|
||||
const mockTaskManager = taskManagerMock.createStart();
|
||||
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 () => {
|
||||
// wait for the setup phase background work
|
||||
plugin.setup(coreSetup, pluginSetup);
|
||||
|
@ -168,21 +183,37 @@ describe('Reporting Plugin', () => {
|
|||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
// Force type-cast to convert `ReadOnly<PackageInfo>` to mutable `PackageInfo`.
|
||||
(serverlessInitContext.env.packageInfo as PackageInfo).buildFlavor = 'serverless';
|
||||
plugin = new ReportingPlugin(serverlessInitContext);
|
||||
|
||||
plugin.setup(coreSetup, pluginSetup);
|
||||
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1);
|
||||
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({
|
||||
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(2);
|
||||
expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(1, {
|
||||
id: 'reporting',
|
||||
name: 'Reporting',
|
||||
category: DEFAULT_APP_CATEGORIES.management,
|
||||
|
@ -193,6 +224,22 @@ describe('Reporting Plugin', () => {
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,6 +27,7 @@ import type {
|
|||
import { ReportingRequestHandlerContext } from './types';
|
||||
import { registerReportingEventTypes, registerReportingUsageCollector } from './usage';
|
||||
import { registerFeatures } from './features';
|
||||
import { setupSavedObjects } from './saved_objects';
|
||||
|
||||
/*
|
||||
* @internal
|
||||
|
@ -75,6 +76,9 @@ export class ReportingPlugin
|
|||
registerReportingUsageCollector(reportingCore, plugins.usageCollection);
|
||||
registerReportingEventTypes(core);
|
||||
|
||||
// Saved objects
|
||||
setupSavedObjects(core.savedObjects);
|
||||
|
||||
// Routes
|
||||
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 { getContentStream } from '../../../lib';
|
||||
import { ReportingRequestHandlerContext, ReportingUser } from '../../../types';
|
||||
import { handleUnavailable } from '../generate';
|
||||
import { handleUnavailable } from '../request_handler';
|
||||
import { jobManagementPreRouting } from './job_management_pre_routing';
|
||||
import { jobsQueryFactory } from './jobs_query';
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import {
|
|||
ReportingRequestHandlerContext,
|
||||
ReportingSetup,
|
||||
} from '../../../types';
|
||||
import { RequestHandler } from './request_handler';
|
||||
import { GenerateRequestHandler } from './generate_request_handler';
|
||||
|
||||
jest.mock('@kbn/reporting-server/crypto', () => ({
|
||||
cryptoFactory: () => ({
|
||||
|
@ -68,7 +68,7 @@ describe('Handle request to generate', () => {
|
|||
let mockContext: ReturnType<typeof getMockContext>;
|
||||
let mockRequest: ReturnType<typeof getMockRequest>;
|
||||
let mockResponseFactory: ReturnType<typeof getMockResponseFactory>;
|
||||
let requestHandler: RequestHandler;
|
||||
let requestHandler: GenerateRequestHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
reportingCore = await createMockReportingCore(createMockConfigSchema({}));
|
||||
|
@ -91,20 +91,23 @@ describe('Handle request to generate', () => {
|
|||
mockContext = getMockContext();
|
||||
mockContext.reporting = Promise.resolve({} as ReportingSetup);
|
||||
|
||||
requestHandler = new RequestHandler(
|
||||
reportingCore,
|
||||
{ username: 'testymcgee' },
|
||||
mockContext,
|
||||
'/api/reporting/test/generate/pdf',
|
||||
mockRequest,
|
||||
mockResponseFactory,
|
||||
mockLogger
|
||||
);
|
||||
requestHandler = new GenerateRequestHandler({
|
||||
reporting: reportingCore,
|
||||
user: { username: 'testymcgee' },
|
||||
context: mockContext,
|
||||
path: '/api/reporting/test/generate/pdf',
|
||||
req: mockRequest,
|
||||
res: mockResponseFactory,
|
||||
logger: mockLogger,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enqueue Job', () => {
|
||||
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;
|
||||
expect(snapObj).toMatchInlineSnapshot(`
|
||||
|
@ -131,6 +134,7 @@ describe('Handle request to generate', () => {
|
|||
"output": null,
|
||||
"process_expiration": undefined,
|
||||
"queue_time_ms": undefined,
|
||||
"scheduled_report_id": undefined,
|
||||
"space_id": "default",
|
||||
"started_at": undefined,
|
||||
"status": "pending",
|
||||
|
@ -158,7 +162,10 @@ describe('Handle request to generate', () => {
|
|||
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?
|
||||
(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;
|
||||
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 () => {
|
||||
expect(await requestHandler.handleGenerateRequest('neanderthals', mockJobParams))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
await requestHandler.handleRequest({
|
||||
exportTypeId: 'neanderthals',
|
||||
jobParams: mockJobParams,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": "Invalid export-type of neanderthals",
|
||||
}
|
||||
|
@ -225,8 +236,12 @@ describe('Handle request to generate', () => {
|
|||
},
|
||||
}));
|
||||
|
||||
expect(await requestHandler.handleGenerateRequest('csv_searchsource', mockJobParams))
|
||||
.toMatchInlineSnapshot(`
|
||||
expect(
|
||||
await requestHandler.handleRequest({
|
||||
exportTypeId: 'csv_searchsource',
|
||||
jobParams: mockJobParams,
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": "seeing this means the license isn't supported",
|
||||
}
|
||||
|
@ -234,30 +249,26 @@ describe('Handle request to generate', () => {
|
|||
});
|
||||
|
||||
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(
|
||||
await requestHandler.handleGenerateRequest('csv_searchsource', {
|
||||
...mockJobParams,
|
||||
browserTimezone: 'America/Amsterdam',
|
||||
await requestHandler.handleRequest({
|
||||
exportTypeId: 'csv_searchsource',
|
||||
jobParams: {
|
||||
...mockJobParams,
|
||||
browserTimezone: 'America/Amsterdam',
|
||||
},
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": "seeing this means the license isn't supported",
|
||||
"body": "Invalid timezone \\"America/Amsterdam\\".",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('generates the download path', async () => {
|
||||
const { body } = (await requestHandler.handleGenerateRequest(
|
||||
'csv_searchsource',
|
||||
mockJobParams
|
||||
)) as unknown as { body: ReportingJobResponse };
|
||||
const { body } = (await requestHandler.handleRequest({
|
||||
exportTypeId: 'csv_searchsource',
|
||||
jobParams: mockJobParams,
|
||||
})) as unknown as { body: ReportingJobResponse };
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
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 { registerHealthRoute } from './internal/health';
|
||||
import { registerGenerationRoutesInternal } from './internal/generate/generate_from_jobparams';
|
||||
import { registerScheduleRoutesInternal } from './internal/schedule/schedule_from_jobparams';
|
||||
import { registerJobInfoRoutesInternal } from './internal/management/jobs';
|
||||
import { registerScheduledRoutesInternal } from './internal/management/scheduled';
|
||||
import { registerGenerationRoutesPublic } from './public/generate_from_jobparams';
|
||||
import { registerJobInfoRoutesPublic } from './public/jobs';
|
||||
|
||||
|
@ -20,7 +22,9 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) {
|
|||
registerHealthRoute(reporting, logger);
|
||||
registerDiagnosticRoutes(reporting, logger);
|
||||
registerGenerationRoutesInternal(reporting, logger);
|
||||
registerScheduleRoutesInternal(reporting, logger);
|
||||
registerJobInfoRoutesInternal(reporting);
|
||||
registerScheduledRoutesInternal(reporting, logger);
|
||||
registerGenerationRoutesPublic(reporting, logger);
|
||||
registerJobInfoRoutesPublic(reporting);
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import type { Logger } from '@kbn/core/server';
|
|||
import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
||||
import type { ReportingCore } from '../../..';
|
||||
import { authorizedUserPreRouting } from '../../common';
|
||||
import { RequestHandler } from '../../common/generate';
|
||||
import { GenerateRequestHandler } from '../../common/request_handler';
|
||||
|
||||
const { GENERATE_PREFIX } = INTERNAL_ROUTES;
|
||||
|
||||
|
@ -30,7 +30,7 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge
|
|||
requiredPrivileges: kibanaAccessControlTags,
|
||||
},
|
||||
},
|
||||
validate: RequestHandler.getValidation(),
|
||||
validate: GenerateRequestHandler.getValidation(),
|
||||
options: {
|
||||
tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`),
|
||||
access: 'internal',
|
||||
|
@ -38,17 +38,20 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge
|
|||
},
|
||||
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
||||
try {
|
||||
const requestHandler = new RequestHandler(
|
||||
const requestHandler = new GenerateRequestHandler({
|
||||
reporting,
|
||||
user,
|
||||
context,
|
||||
path,
|
||||
req,
|
||||
res,
|
||||
logger
|
||||
);
|
||||
logger,
|
||||
});
|
||||
const jobParams = requestHandler.getJobParams();
|
||||
return await requestHandler.handleGenerateRequest(req.params.exportType, jobParams);
|
||||
return await requestHandler.handleRequest({
|
||||
exportTypeId: req.params.exportType,
|
||||
jobParams,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof KibanaResponse) {
|
||||
return err;
|
||||
|
|
|
@ -10,7 +10,7 @@ import { INTERNAL_ROUTES } from '@kbn/reporting-common';
|
|||
import { ROUTE_TAG_CAN_REDIRECT } from '@kbn/security-plugin/server';
|
||||
import { ReportingCore } from '../../..';
|
||||
import { authorizedUserPreRouting, getCounters } from '../../common';
|
||||
import { handleUnavailable } from '../../common/generate';
|
||||
import { handleUnavailable } from '../../common/request_handler';
|
||||
import {
|
||||
commonJobsRouteHandlerFactory,
|
||||
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 type { ReportingCore } from '../..';
|
||||
import { authorizedUserPreRouting } from '../common';
|
||||
import { RequestHandler } from '../common/generate';
|
||||
import { GenerateRequestHandler } from '../common/request_handler';
|
||||
|
||||
export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: Logger) {
|
||||
const setupDeps = reporting.getPluginSetupDeps();
|
||||
|
@ -28,7 +28,7 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger:
|
|||
requiredPrivileges: kibanaAccessControlTags,
|
||||
},
|
||||
},
|
||||
validate: RequestHandler.getValidation(),
|
||||
validate: GenerateRequestHandler.getValidation(),
|
||||
options: {
|
||||
tags: kibanaAccessControlTags.map((controlAccessTag) => `access:${controlAccessTag}`),
|
||||
access: 'public',
|
||||
|
@ -36,19 +36,19 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger:
|
|||
},
|
||||
authorizedUserPreRouting(reporting, async (user, context, req, res) => {
|
||||
try {
|
||||
const requestHandler = new RequestHandler(
|
||||
const requestHandler = new GenerateRequestHandler({
|
||||
reporting,
|
||||
user,
|
||||
context,
|
||||
path,
|
||||
req,
|
||||
res,
|
||||
logger
|
||||
);
|
||||
return await requestHandler.handleGenerateRequest(
|
||||
req.params.exportType,
|
||||
requestHandler.getJobParams()
|
||||
);
|
||||
logger,
|
||||
});
|
||||
return await requestHandler.handleRequest({
|
||||
exportTypeId: req.params.exportType,
|
||||
jobParams: requestHandler.getJobParams(),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof KibanaResponse) {
|
||||
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,
|
||||
elasticsearchServiceMock,
|
||||
loggingSystemMock,
|
||||
savedObjectsClientMock,
|
||||
statusServiceMock,
|
||||
} from '@kbn/core/server/mocks';
|
||||
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
|
||||
import { discoverPluginMock } from '@kbn/discover-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>>
|
||||
): ReportingInternalSetup => {
|
||||
return {
|
||||
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
|
||||
actions: {
|
||||
...actionsMock.createSetup(),
|
||||
getActionsConfigurationUtilities: jest.fn().mockReturnValue({
|
||||
validateEmailAddresses: jest.fn(),
|
||||
}),
|
||||
},
|
||||
encryptedSavedObjects: encryptedSavedObjectsMock.createSetup({ canEncrypt: true }),
|
||||
features: featuresPluginMock.createSetup(),
|
||||
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(),
|
||||
taskManager: taskManagerMock.createSetup(),
|
||||
logger: loggingSystemMock.createLogger(),
|
||||
|
@ -56,6 +70,7 @@ export const createMockPluginSetup = (
|
|||
const coreSetupMock = coreMock.createSetup();
|
||||
const coreStartMock = coreMock.createStart();
|
||||
const logger = loggingSystemMock.createLogger();
|
||||
const savedObjectsClient = savedObjectsClientMock.create();
|
||||
|
||||
const createMockReportingStore = async (config: ReportingConfigType) => {
|
||||
const mockConfigSchema = createMockConfigSchema(config);
|
||||
|
@ -71,7 +86,10 @@ export const createMockPluginStart = async (
|
|||
return {
|
||||
analytics: coreSetupMock.analytics,
|
||||
esClient: elasticsearchServiceMock.createClusterClient(),
|
||||
savedObjects: { getScopedClient: jest.fn() },
|
||||
savedObjects: {
|
||||
getScopedClient: jest.fn().mockReturnValue(savedObjectsClient),
|
||||
createInternalRepository: jest.fn().mockReturnValue(savedObjectsClient),
|
||||
},
|
||||
uiSettings: { asScopedToClient: () => ({ get: jest.fn() }) },
|
||||
discover: discoverPluginMock.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 { FieldFormatsStart } from '@kbn/field-formats-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 { ReportingConfigType } from '@kbn/reporting-server';
|
||||
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server';
|
||||
|
@ -21,18 +21,24 @@ import type {
|
|||
PngScreenshotOptions as BasePngScreenshotOptions,
|
||||
ScreenshottingStart,
|
||||
} 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 {
|
||||
RruleSchedule,
|
||||
TaskManagerSetupContract,
|
||||
TaskManagerStartContract,
|
||||
} from '@kbn/task-manager-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 type { AuthenticatedUser } from '@kbn/core-security-common';
|
||||
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
|
||||
|
@ -50,6 +56,7 @@ export type ReportingUser = { username: AuthenticatedUser['username'] } | false;
|
|||
export type ScrollConfig = ReportingConfigType['csv']['scroll'];
|
||||
|
||||
export interface ReportingSetupDeps {
|
||||
actions: ActionsPluginSetupContract;
|
||||
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
|
||||
features: FeaturesPluginSetup;
|
||||
screenshotMode: ScreenshotModePluginSetup;
|
||||
|
@ -66,6 +73,7 @@ export interface ReportingStartDeps {
|
|||
licensing: LicensingPluginStart;
|
||||
notifications: NotificationsPluginStart;
|
||||
taskManager: TaskManagerStartContract;
|
||||
security?: SecurityPluginStart;
|
||||
screenshotting?: ScreenshottingStart;
|
||||
}
|
||||
|
||||
|
@ -92,6 +100,44 @@ export interface ReportingJobResponse {
|
|||
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'> {
|
||||
urls: UrlOrUrlLocatorTuple[];
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
},
|
||||
"include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"],
|
||||
"kbn_references": [
|
||||
"@kbn/actions-plugin",
|
||||
"@kbn/core",
|
||||
"@kbn/data-plugin",
|
||||
"@kbn/discover-plugin",
|
||||
|
@ -52,7 +53,11 @@
|
|||
"@kbn/react-kibana-mount",
|
||||
"@kbn/core-security-common",
|
||||
"@kbn/core-http-server-utils",
|
||||
"@kbn/core-saved-objects-server",
|
||||
"@kbn/rrule",
|
||||
"@kbn/notifications-plugin",
|
||||
"@kbn/spaces-utils",
|
||||
"@kbn/logging-mocks",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -479,7 +479,9 @@ Array [
|
|||
},
|
||||
],
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -570,7 +572,9 @@ Array [
|
|||
},
|
||||
"name": "Generate CSV reports",
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -646,7 +650,9 @@ Array [
|
|||
},
|
||||
],
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -706,7 +712,9 @@ Array [
|
|||
"minimumLicense": "gold",
|
||||
"name": "Generate PDF or PNG reports",
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -822,7 +830,9 @@ Array [
|
|||
},
|
||||
],
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -850,7 +860,9 @@ Array [
|
|||
},
|
||||
],
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -942,7 +954,9 @@ Array [
|
|||
"minimumLicense": "gold",
|
||||
"name": "Generate PDF or PNG reports",
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
@ -962,7 +976,9 @@ Array [
|
|||
},
|
||||
"name": "Generate CSV reports from Discover session panels",
|
||||
"savedObject": Object {
|
||||
"all": Array [],
|
||||
"all": Array [
|
||||
"scheduled_report",
|
||||
],
|
||||
"read": Array [],
|
||||
},
|
||||
"ui": Array [
|
||||
|
|
|
@ -802,7 +802,7 @@ const reportingFeatures: {
|
|||
defaultMessage: 'Generate CSV reports',
|
||||
}),
|
||||
includeIn: 'all',
|
||||
savedObject: { all: [], read: [] },
|
||||
savedObject: { all: ['scheduled_report'], read: [] },
|
||||
management: { insightsAndAlerting: ['reporting'] },
|
||||
api: ['generateReport'],
|
||||
ui: ['generateCsv'],
|
||||
|
@ -830,7 +830,7 @@ const reportingFeatures: {
|
|||
),
|
||||
includeIn: 'all',
|
||||
minimumLicense: 'gold',
|
||||
savedObject: { all: [], read: [] },
|
||||
savedObject: { all: ['scheduled_report'], read: [] },
|
||||
management: { insightsAndAlerting: ['reporting'] },
|
||||
api: ['generateReport'],
|
||||
ui: ['generateScreenshot'],
|
||||
|
@ -844,7 +844,7 @@ const reportingFeatures: {
|
|||
defaultMessage: 'Generate CSV reports from Discover session panels',
|
||||
}),
|
||||
includeIn: 'all',
|
||||
savedObject: { all: [], read: [] },
|
||||
savedObject: { all: ['scheduled_report'], read: [] },
|
||||
management: { insightsAndAlerting: ['reporting'] },
|
||||
api: ['downloadCsv'],
|
||||
ui: ['downloadCsv'],
|
||||
|
@ -872,7 +872,7 @@ const reportingFeatures: {
|
|||
),
|
||||
includeIn: 'all',
|
||||
minimumLicense: 'gold',
|
||||
savedObject: { all: [], read: [] },
|
||||
savedObject: { all: ['scheduled_report'], read: [] },
|
||||
management: { insightsAndAlerting: ['reporting'] },
|
||||
api: ['generateReport'],
|
||||
ui: ['generateScreenshot'],
|
||||
|
|
|
@ -43,8 +43,9 @@ export interface PlainTextEmail {
|
|||
};
|
||||
}
|
||||
|
||||
export interface AttachmentEmail extends PlainTextEmail {
|
||||
export interface AttachmentEmail extends Omit<PlainTextEmail, 'to'> {
|
||||
attachments: Attachment[];
|
||||
to?: string[];
|
||||
bcc?: string[];
|
||||
cc?: 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 () => {
|
||||
const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock;
|
||||
const getOAuthClientCredentialsAccessTokenMock =
|
||||
|
@ -197,6 +261,7 @@ describe('send_email module', () => {
|
|||
expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"attachments": Array [],
|
||||
"headers": Object {
|
||||
"Authorization": "Bearer dfjsdfgdjhfgsjdf",
|
||||
"Content-Type": "application/json",
|
||||
|
|
|
@ -78,6 +78,7 @@ export async function sendEmail(
|
|||
): Promise<unknown> {
|
||||
const { transport, content } = options;
|
||||
const { message, messageHTML } = content;
|
||||
const attachments = options.attachments ?? [];
|
||||
|
||||
const renderedMessage = messageHTML ?? htmlFromMarkdown(logger, message);
|
||||
|
||||
|
@ -87,10 +88,17 @@ export async function sendEmail(
|
|||
options,
|
||||
renderedMessage,
|
||||
connectorTokenClient,
|
||||
connectorUsageCollector
|
||||
connectorUsageCollector,
|
||||
attachments
|
||||
);
|
||||
} 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,
|
||||
messageHTML: string,
|
||||
connectorTokenClient: ConnectorTokenClientContract,
|
||||
connectorUsageCollector: ConnectorUsageCollector
|
||||
connectorUsageCollector: ConnectorUsageCollector,
|
||||
attachments: Attachment[]
|
||||
): Promise<unknown> {
|
||||
const { transport, configurationUtilities, connectorId } = options;
|
||||
const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport;
|
||||
|
@ -167,6 +176,7 @@ export async function sendEmailWithExchange(
|
|||
options,
|
||||
headers,
|
||||
messageHTML,
|
||||
attachments,
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
|
@ -180,7 +190,8 @@ async function sendEmailWithNodemailer(
|
|||
logger: Logger,
|
||||
options: SendEmailOptions,
|
||||
messageHTML: string,
|
||||
connectorUsageCollector: ConnectorUsageCollector
|
||||
connectorUsageCollector: ConnectorUsageCollector,
|
||||
attachments: Attachment[]
|
||||
): Promise<unknown> {
|
||||
const { transport, routing, content, configurationUtilities, hasAuth } = options;
|
||||
const { service } = transport;
|
||||
|
@ -197,6 +208,7 @@ async function sendEmailWithNodemailer(
|
|||
subject,
|
||||
html: messageHTML,
|
||||
text: message,
|
||||
...(attachments.length > 0 && { attachments }),
|
||||
};
|
||||
|
||||
// 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 { ProxySettings } 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 axiosInstanceMock = jest.fn();
|
||||
|
@ -24,6 +24,7 @@ const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
|||
|
||||
describe('sendEmailGraphApi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
createAxiosInstanceMock.mockReturnValue(axiosInstanceMock);
|
||||
});
|
||||
const configurationUtilities = actionsConfigMock.create();
|
||||
|
@ -42,6 +43,7 @@ describe('sendEmailGraphApi', () => {
|
|||
options: getSendEmailOptions(),
|
||||
messageHTML: 'test1',
|
||||
headers: {},
|
||||
attachments: [],
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
|
@ -137,12 +139,13 @@ describe('sendEmailGraphApi', () => {
|
|||
options: getSendEmailOptions(),
|
||||
messageHTML: 'test2',
|
||||
headers: { Authorization: 'Bearer 1234567' },
|
||||
attachments: [],
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
connectorUsageCollector
|
||||
);
|
||||
expect(axiosInstanceMock.mock.calls[1]).toMatchInlineSnapshot(`
|
||||
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"https://graph.microsoft.com/v1.0/users/fred@example.com/sendMail",
|
||||
Object {
|
||||
|
@ -235,12 +238,13 @@ describe('sendEmailGraphApi', () => {
|
|||
options: getSendEmailOptions(),
|
||||
messageHTML: 'test3',
|
||||
headers: {},
|
||||
attachments: [],
|
||||
},
|
||||
logger,
|
||||
configurationUtilities,
|
||||
connectorUsageCollector
|
||||
);
|
||||
expect(axiosInstanceMock.mock.calls[2]).toMatchInlineSnapshot(`
|
||||
expect(axiosInstanceMock.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"https://test/users/fred@example.com/sendMail",
|
||||
Object {
|
||||
|
@ -334,7 +338,7 @@ describe('sendEmailGraphApi', () => {
|
|||
|
||||
await expect(
|
||||
sendEmailGraphApi(
|
||||
{ options: getSendEmailOptions(), messageHTML: 'test1', headers: {} },
|
||||
{ options: getSendEmailOptions(), messageHTML: 'test1', headers: {}, attachments: [] },
|
||||
logger,
|
||||
configurationUtilities,
|
||||
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(
|
||||
|
|
|
@ -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 { ConnectorUsageCollector } from '@kbn/actions-plugin/server/types';
|
||||
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(
|
||||
sendEmailOptions: SendEmailGraphApiOptions,
|
||||
|
@ -22,11 +26,54 @@ export async function sendEmailGraphApi(
|
|||
connectorUsageCollector: ConnectorUsageCollector,
|
||||
axiosInstance?: AxiosInstance
|
||||
): Promise<AxiosResponse> {
|
||||
const { options, headers, messageHTML } = sendEmailOptions;
|
||||
|
||||
// Create a new axios instance if one is not provided
|
||||
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
|
||||
const res = await request({
|
||||
axios: axiosInstance,
|
||||
|
@ -46,15 +93,58 @@ export async function sendEmailGraphApi(
|
|||
}
|
||||
const errString = stringify(res.data);
|
||||
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);
|
||||
}
|
||||
|
||||
interface SendEmailGraphApiOptions {
|
||||
options: SendEmailOptions;
|
||||
headers: Record<string, string>;
|
||||
messageHTML: string;
|
||||
export async function sendEmailWithAttachments(
|
||||
params: SendEmailParams,
|
||||
smallAttachmentLimit: number = SMALL_ATTACHMENT_LIMIT,
|
||||
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) {
|
||||
|
@ -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
|
||||
'report:execute',
|
||||
'report:execute-scheduled',
|
||||
];
|
||||
|
|
|
@ -24,7 +24,9 @@ export type {
|
|||
} from './task';
|
||||
|
||||
export { Frequency, Weekday } from '@kbn/rrule';
|
||||
export { scheduleRruleSchema } from './saved_objects';
|
||||
|
||||
export type { RruleSchedule } from './task';
|
||||
export { TaskStatus, TaskPriority, TaskCost } from './task';
|
||||
|
||||
export type { TaskRegisterDefinition, TaskDefinitionRegistry } from './task_type_dictionary';
|
||||
|
|
|
@ -159,7 +159,7 @@ describe('getFirstRunAt', () => {
|
|||
freq: 2, // Weekly
|
||||
interval: 1,
|
||||
tzid: 'UTC',
|
||||
byweekday: [1], // Monday
|
||||
byweekday: ['1'], // Monday
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -182,7 +182,7 @@ describe('getFirstRunAt', () => {
|
|||
freq: 2, // Weekly
|
||||
interval: 1,
|
||||
tzid: 'UTC',
|
||||
byweekday: [1], // Monday
|
||||
byweekday: ['MO'], // Monday
|
||||
byhour: [12],
|
||||
byminute: [15],
|
||||
},
|
||||
|
@ -257,7 +257,7 @@ describe('getFirstRunAt', () => {
|
|||
freq: 1, // Monthly
|
||||
interval: 1,
|
||||
tzid: 'UTC',
|
||||
byweekday: [3], // Wednesday
|
||||
byweekday: ['3'], // Wednesday
|
||||
byhour: [12],
|
||||
byminute: [17],
|
||||
},
|
||||
|
|
|
@ -14,6 +14,8 @@ import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task';
|
|||
import { TASK_MANAGER_INDEX } from '../constants';
|
||||
import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_versions';
|
||||
|
||||
export { scheduleRruleSchema } from './schemas/task';
|
||||
|
||||
export const TASK_SO_NAME = 'task';
|
||||
export const BACKGROUND_TASK_NODE_SO_NAME = 'background-task-node';
|
||||
|
||||
|
|
|
@ -55,6 +55,14 @@ export const taskSchemaV3 = taskSchemaV2.extends({
|
|||
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({
|
||||
apiKey: schema.maybe(schema.string()),
|
||||
userScope: schema.maybe(
|
||||
|
@ -67,14 +75,5 @@ export const taskSchemaV4 = taskSchemaV3.extends({
|
|||
});
|
||||
|
||||
export const taskSchemaV5 = taskSchemaV4.extends({
|
||||
schedule: schema.maybe(
|
||||
schema.oneOf([
|
||||
schema.object({
|
||||
interval: schema.string({ validate: validateDuration }),
|
||||
}),
|
||||
schema.object({
|
||||
rrule: rruleSchedule,
|
||||
}),
|
||||
])
|
||||
),
|
||||
schedule: schema.maybe(schema.oneOf([scheduleIntervalSchema, scheduleRruleSchema])),
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { ObjectType, TypeOf } from '@kbn/config-schema';
|
|||
import { schema } from '@kbn/config-schema';
|
||||
import { isNumber } from 'lodash';
|
||||
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 type { Interval } from './lib/intervals';
|
||||
import { isInterval, parseIntervalAsMillisecond } from './lib/intervals';
|
||||
|
@ -259,8 +259,9 @@ export interface IntervalSchedule {
|
|||
rrule?: never;
|
||||
}
|
||||
|
||||
export type Rrule = RruleMonthly | RruleWeekly | RruleDaily;
|
||||
export interface RruleSchedule {
|
||||
rrule: RruleMonthly | RruleWeekly | RruleDaily;
|
||||
rrule: Rrule;
|
||||
interval?: never;
|
||||
}
|
||||
|
||||
|
@ -269,17 +270,16 @@ interface RruleCommon {
|
|||
interval: number;
|
||||
tzid: string;
|
||||
}
|
||||
|
||||
interface RruleMonthly extends RruleCommon {
|
||||
freq: Frequency.MONTHLY;
|
||||
bymonthday?: number[];
|
||||
byhour?: number[];
|
||||
byminute?: number[];
|
||||
byweekday?: Weekday[];
|
||||
byweekday?: string[];
|
||||
}
|
||||
interface RruleWeekly extends RruleCommon {
|
||||
freq: Frequency.WEEKLY;
|
||||
byweekday?: Weekday[];
|
||||
byweekday?: string[];
|
||||
byhour?: number[];
|
||||
byminute?: number[];
|
||||
bymonthday?: never;
|
||||
|
@ -288,7 +288,7 @@ interface RruleDaily extends RruleCommon {
|
|||
freq: Frequency.DAILY;
|
||||
byhour?: number[];
|
||||
byminute?: number[];
|
||||
byweekday?: Weekday[];
|
||||
byweekday?: string[];
|
||||
bymonthday?: never;
|
||||
}
|
||||
|
||||
|
|
|
@ -35,6 +35,9 @@ export const REMOVED_TYPES: string[] = [
|
|||
export const SHARED_CONCURRENCY_TASKS: string[][] = [
|
||||
// for testing
|
||||
['sampleTaskSharedConcurrencyType1', 'sampleTaskSharedConcurrencyType2'],
|
||||
|
||||
// reporting
|
||||
['report:execute', 'report:execute-scheduled'],
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
@ -127,6 +127,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'inventory',
|
||||
'logs',
|
||||
'maintenanceWindow',
|
||||
'manageReporting',
|
||||
'maps_v2',
|
||||
'osquery',
|
||||
'rulesSettings',
|
||||
|
@ -210,6 +211,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'fleet',
|
||||
'fleetv2',
|
||||
'entityManager',
|
||||
'manageReporting',
|
||||
];
|
||||
|
||||
const features = body.filter(
|
||||
|
|
|
@ -208,6 +208,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
logs: ['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'],
|
||||
discover: [
|
||||
'all',
|
||||
|
|
|
@ -81,6 +81,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
inventory: ['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'],
|
||||
},
|
||||
global: ['all', 'read'],
|
||||
|
@ -312,6 +313,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
logs: ['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'],
|
||||
discover: [
|
||||
'all',
|
||||
|
|
|
@ -171,6 +171,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'osquery:telemetry-packs',
|
||||
'osquery:telemetry-saved-queries',
|
||||
'report:execute',
|
||||
'report:execute-scheduled',
|
||||
'risk_engine:risk_scoring',
|
||||
'search:agentless-connectors-manager',
|
||||
'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.createDataAnalyst();
|
||||
await reportingAPI.createTestReportingUser();
|
||||
await reportingAPI.createManageReportingUserRole();
|
||||
await reportingAPI.createManageReportingUser();
|
||||
});
|
||||
|
||||
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('./security_roles_privileges'));
|
||||
loadTestFile(require.resolve('./spaces'));
|
||||
loadTestFile(require.resolve('./list_scheduled_reports'));
|
||||
loadTestFile(require.resolve('./disable_scheduled_reports'));
|
||||
loadTestFile(require.resolve('./list_jobs'));
|
||||
|
||||
// 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 { 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';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
|
@ -14,13 +16,33 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
const reportingAPI = getService('reportingAPI');
|
||||
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', () => {
|
||||
const scheduledReportIds: string[] = [];
|
||||
const scheduledReportTaskIds: string[] = [];
|
||||
before(async () => {
|
||||
await reportingAPI.initEcommerce();
|
||||
});
|
||||
after(async () => {
|
||||
await reportingAPI.teardownEcommerce();
|
||||
await reportingAPI.deleteAllReports();
|
||||
await reportingAPI.deleteScheduledReports(scheduledReportIds);
|
||||
await reportingAPI.deleteTasks(scheduledReportTaskIds);
|
||||
});
|
||||
|
||||
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
|
||||
it('should register reporting privileges with the security privileges API', async () => {
|
||||
await supertest
|
||||
|
|
|
@ -17,5 +17,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
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,
|
||||
} from '@kbn/reporting-server';
|
||||
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';
|
||||
|
||||
function removeWhitespace(str: string) {
|
||||
|
@ -39,6 +41,9 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
const REPORTING_USER_USERNAME = 'reporting_user';
|
||||
const REPORTING_USER_PASSWORD = 'reporting_user-password';
|
||||
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 () => {
|
||||
// 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 () => {
|
||||
await security.user.create('data_analyst', {
|
||||
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 () => {
|
||||
await security.user.create(REPORTING_USER_USERNAME, {
|
||||
password: REPORTING_USER_PASSWORD,
|
||||
|
@ -156,6 +199,19 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
.set('kbn-xsrf', 'xxx')
|
||||
.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 (
|
||||
username: string,
|
||||
password: string,
|
||||
|
@ -170,6 +226,19 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
.set('kbn-xsrf', 'xxx')
|
||||
.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 (
|
||||
job: JobParamsCSV,
|
||||
username = 'elastic',
|
||||
|
@ -184,6 +253,46 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
.set('kbn-xsrf', 'xxx')
|
||||
.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 (
|
||||
apiPath: string,
|
||||
|
@ -221,7 +330,6 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
.get(`${INTERNAL_ROUTES.JOBS.LIST}?page=0&ids=${id}`)
|
||||
.auth(username, password)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send()
|
||||
.expect(200);
|
||||
return job?.output?.error_code;
|
||||
};
|
||||
|
@ -290,6 +398,30 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
.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 {
|
||||
logTaskManagerHealth,
|
||||
initEcommerce,
|
||||
|
@ -301,13 +433,21 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
REPORTING_USER_USERNAME,
|
||||
REPORTING_USER_PASSWORD,
|
||||
REPORTING_ROLE,
|
||||
MANAGE_REPORTING_USER_USERNAME,
|
||||
MANAGE_REPORTING_USER_PASSWORD,
|
||||
MANAGE_REPORTING_ROLE,
|
||||
createDataAnalystRole,
|
||||
createDataAnalyst,
|
||||
createTestReportingUserRole,
|
||||
createTestReportingUser,
|
||||
createManageReportingUserRole,
|
||||
createManageReportingUser,
|
||||
generatePdf,
|
||||
generatePng,
|
||||
generateCsv,
|
||||
schedulePdf,
|
||||
schedulePng,
|
||||
scheduleCsv,
|
||||
listReports,
|
||||
postJob,
|
||||
postJobJSON,
|
||||
|
@ -317,5 +457,11 @@ export function createScenarios({ getService }: Pick<FtrProviderContext, 'getSer
|
|||
migrateReportingIndices,
|
||||
makeAllReportingIndicesUnmanaged,
|
||||
getJobErrorCode,
|
||||
getScheduledReports,
|
||||
deleteScheduledReports,
|
||||
getTask,
|
||||
deleteTasks,
|
||||
listScheduledReports,
|
||||
disableScheduledReports,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -69,6 +69,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
generalCases: 0,
|
||||
generalCasesV2: 0,
|
||||
generalCasesV3: 0,
|
||||
manageReporting: 0,
|
||||
maps: 2,
|
||||
maps_v2: 2,
|
||||
canvas: 2,
|
||||
|
|
|
@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:downloadCsv",
|
||||
"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_v2/downloadCsv",
|
||||
],
|
||||
|
@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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_v2/generateScreenshot",
|
||||
],
|
||||
|
@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:url/delete",
|
||||
"saved_object:url/bulk_delete",
|
||||
"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/delete",
|
||||
"ui:visualize/save",
|
||||
|
@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
],
|
||||
"generate_report": Array [
|
||||
"login:",
|
||||
"api:generateReport",
|
||||
"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",
|
||||
],
|
||||
"minimal_all": Array [
|
||||
|
@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:url/delete",
|
||||
"saved_object:url/bulk_delete",
|
||||
"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/delete",
|
||||
"ui:visualize_v2/save",
|
||||
|
@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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_v2/generateCsv",
|
||||
],
|
||||
|
@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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",
|
||||
],
|
||||
"minimal_all": Array [
|
||||
|
@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
|
|
@ -1587,6 +1587,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -1714,6 +1726,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:downloadCsv",
|
||||
"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_v2/downloadCsv",
|
||||
],
|
||||
|
@ -1721,6 +1745,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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_v2/generateScreenshot",
|
||||
],
|
||||
|
@ -1885,6 +1921,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:url/delete",
|
||||
"saved_object:url/bulk_delete",
|
||||
"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/delete",
|
||||
"ui:visualize/save",
|
||||
|
@ -2232,6 +2280,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -2340,12 +2400,36 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
],
|
||||
"generate_report": Array [
|
||||
"login:",
|
||||
"api:generateReport",
|
||||
"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",
|
||||
],
|
||||
"minimal_all": Array [
|
||||
|
@ -2489,6 +2573,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:url/delete",
|
||||
"saved_object:url/bulk_delete",
|
||||
"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/delete",
|
||||
"ui:visualize_v2/save",
|
||||
|
@ -2814,6 +2910,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -2857,6 +2965,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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_v2/generateCsv",
|
||||
],
|
||||
|
@ -3165,6 +3285,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -3200,6 +3332,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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",
|
||||
],
|
||||
"minimal_all": Array [
|
||||
|
@ -6514,6 +6658,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
@ -6559,6 +6715,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
|
|
@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:downloadCsv",
|
||||
"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_v2/downloadCsv",
|
||||
],
|
||||
|
@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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_v2/generateScreenshot",
|
||||
],
|
||||
|
@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:url/delete",
|
||||
"saved_object:url/bulk_delete",
|
||||
"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/delete",
|
||||
"ui:visualize/save",
|
||||
|
@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
],
|
||||
"generate_report": Array [
|
||||
"login:",
|
||||
"api:generateReport",
|
||||
"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",
|
||||
],
|
||||
"minimal_all": Array [
|
||||
|
@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:url/delete",
|
||||
"saved_object:url/bulk_delete",
|
||||
"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/delete",
|
||||
"ui:visualize_v2/save",
|
||||
|
@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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_v2/generateCsv",
|
||||
],
|
||||
|
@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/get",
|
||||
"saved_object:index-pattern/find",
|
||||
|
@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"login:",
|
||||
"api:generateReport",
|
||||
"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",
|
||||
],
|
||||
"minimal_all": Array [
|
||||
|
@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
|
|
@ -89,6 +89,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
@ -134,6 +146,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:cloud/close_point_in_time",
|
||||
"api:downloadCsv",
|
||||
"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",
|
||||
"api:generateReport",
|
||||
"ui:discover_v2/generateCsv",
|
||||
|
@ -879,6 +903,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/save",
|
||||
"ui:discover_v2/createShortUrl",
|
||||
|
@ -1766,6 +1802,18 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"saved_object:search-session/delete",
|
||||
"saved_object:search-session/bulk_delete",
|
||||
"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/save",
|
||||
"ui:discover_v2/createShortUrl",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue