diff --git a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts index 578d823aafae2..9e38a3d7b9182 100644 --- a/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/observability_solution/synthetics/common/runtime_types/monitor_management/monitor_types.ts @@ -259,6 +259,8 @@ export const BrowserSensitiveSimpleFieldsCodec = t.intersection([ CommonFieldsCodec, ]); +export type BrowserSensitiveSimpleFields = t.TypeOf; + export const ThrottlingConfigValueCodec = t.interface({ download: t.string, upload: t.string, diff --git a/x-pack/plugins/observability_solution/synthetics/server/common/inline_to_zip.test.ts b/x-pack/plugins/observability_solution/synthetics/server/common/inline_to_zip.test.ts new file mode 100644 index 0000000000000..9f02f129ea077 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/common/inline_to_zip.test.ts @@ -0,0 +1,31 @@ +/* + * 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 { inlineToProjectZip } from './inline_to_zip'; +import { unzipFile } from './unzip_project_code'; + +describe('inlineToProjectZip', () => { + it('should return base64 encoded zip data', async () => { + const inlineJourney = ` +step('goto', () => page.goto('https://elastic.co')); +step('throw error', () => { throw new Error('error'); }); +`; + const result = await inlineToProjectZip(inlineJourney, 'testMonitorId', jest.fn() as any); + + expect(result.length).toBeGreaterThan(0); + expect(await unzipFile(result)).toEqual( + `import { journey, step, expect, mfa } from '@elastic/synthetics'; + +journey('inline', ({ page, context, browser, params, request }) => { + +step('goto', () => page.goto('https://elastic.co')); +step('throw error', () => { throw new Error('error'); }); + +});` + ); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/common/inline_to_zip.ts b/x-pack/plugins/observability_solution/synthetics/server/common/inline_to_zip.ts new file mode 100644 index 0000000000000..56825776b7c93 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/common/inline_to_zip.ts @@ -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 { Logger } from '@kbn/logging'; +import archiver from 'archiver'; +import { MemWritable } from './mem_writable'; + +function wrapInlineInProject(inlineJourney: string) { + return `import { journey, step, expect, mfa } from '@elastic/synthetics'; + +journey('inline', ({ page, context, browser, params, request }) => { +${inlineJourney} +});`; +} + +export async function inlineToProjectZip( + inlineJourney: string, + monitorId: string, + logger: Logger +): Promise { + const mWriter = new MemWritable(); + try { + await new Promise((resolve, reject) => { + const archive = archiver('zip', { + zlib: { level: 9 }, + }); + archive.on('error', reject); + mWriter.on('close', resolve); + archive.pipe(mWriter); + archive.append(wrapInlineInProject(inlineJourney), { + name: 'inline.journey.ts', + // Date is fixed to Unix epoch so the file metadata is + // not modified everytime when files are bundled + date: new Date('1970-01-01'), + }); + return archive.finalize(); + }); + } catch (e) { + logger.error(`Failed to create zip for inline monitor ${monitorId}`); + throw e; + } + return mWriter.buffer.toString('base64'); +} diff --git a/x-pack/plugins/observability_solution/synthetics/server/common/mem_writable.test.ts b/x-pack/plugins/observability_solution/synthetics/server/common/mem_writable.test.ts new file mode 100644 index 0000000000000..fac4f3834dafa --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/common/mem_writable.test.ts @@ -0,0 +1,37 @@ +/* + * 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 { MemWritable } from './mem_writable'; + +describe('MemWritable', () => { + it('should write chunks to the buffer', async () => { + const memWritable = new MemWritable(); + + const chunk1 = `step('goto', () => page.goto('https://elastic.co'));`; + const chunk2 = `step('throw error', () => { throw new Error('error'); });`; + const expectedBuffer = Buffer.from(chunk1 + chunk2); + + await new Promise((resolve, reject) => { + memWritable.write(Buffer.from(chunk1), (err) => { + if (err) { + reject(err); + } + expect(memWritable.buffer).toEqual(Buffer.from(chunk1)); + resolve(); + }); + }); + await new Promise((resolve, reject) => { + memWritable.write(Buffer.from(chunk2), (err) => { + if (err) { + reject(err); + } + resolve(); + }); + }); + expect(memWritable.buffer).toEqual(expectedBuffer); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/common/mem_writable.ts b/x-pack/plugins/observability_solution/synthetics/server/common/mem_writable.ts new file mode 100644 index 0000000000000..3e1091b77ac4a --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/common/mem_writable.ts @@ -0,0 +1,29 @@ +/* + * 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 { Writable, WritableOptions } from 'node:stream'; + +export class MemWritable extends Writable { + private _queue: Buffer[]; + constructor(opts?: WritableOptions) { + super(opts); + this._queue = []; + } + + public get buffer(): Buffer { + return Buffer.concat(this._queue); + } + + _write( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null | undefined) => void + ): void { + this._queue.push(chunk); + callback(); + } +} diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor.test.ts new file mode 100644 index 0000000000000..1e9db8b183201 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor.test.ts @@ -0,0 +1,143 @@ +/* + * 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 { ConfigKey, SyntheticsMonitor } from '../../../common/runtime_types'; +import { UpsertMonitorAPI } from './add_monitor/upsert_monitor_api'; + +describe('hydrateMonitorFields', () => { + it('does not add project field value for inline browser monitor', () => { + const normalizedMonitor: SyntheticsMonitor = { + // @ts-expect-error extra field + type: 'browser', + // @ts-expect-error extra field + form_monitor_type: 'multistep', + enabled: true, + alert: { status: { enabled: true }, tls: { enabled: true } }, + // @ts-expect-error extra field + schedule: { unit: 'm', number: '10' }, + 'service.name': '', + config_id: '', + tags: [], + timeout: null, + name: 'test-once-more', + locations: [{ id: 'dev', label: 'Dev Service', isServiceManaged: true }], + namespace: 'default', + // @ts-expect-error extra field + origin: 'ui', + journey_id: '', + hash: '', + id: '', + params: '', + max_attempts: 2, + project_id: '', + playwright_options: '', + __ui: { script_source: { is_generated_script: false, file_name: '' } }, + 'url.port': null, + 'source.inline.script': `step('goto', () => page.goto('https://elastic.co')) +step('fail', () => { + throw Error('fail'); +})`, + playwright_text_assertion: '', + urls: '', + screenshots: 'on', + synthetics_args: [], + 'filter_journeys.match': '', + 'filter_journeys.tags': [], + ignore_https_errors: false, + throttling: { + value: { download: '5', upload: '3', latency: '20' }, + id: 'default', + label: 'Default', + }, + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.key': '', + 'ssl.key_passphrase': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + }; + + const api = new UpsertMonitorAPI({ + request: { + query: { + preserve_namespace: true, + }, + }, + server: { + logger: jest.fn(), + }, + } as any); + const hydratedMonitor = api.hydrateMonitorFields({ + normalizedMonitor, + newMonitorId: 'testMonitorId', + }); + + expect((hydratedMonitor as any)[ConfigKey.SOURCE_PROJECT_CONTENT]).toBeUndefined(); + }); + + it('does not add b64 zip data to lightweight monitors', () => { + const newMonitorId = 'testMonitorId'; + const routeContext = { + request: { + query: { + preserve_namespace: true, + }, + }, + server: { + logger: jest.fn(), + }, + }; + const normalizedMonitor: SyntheticsMonitor = { + // @ts-expect-error extra field + type: 'tcp', + // @ts-expect-error extra field + form_monitor_type: 'tcp', + enabled: true, + alert: { status: { enabled: true }, tls: { enabled: true } }, + // @ts-expect-error extra field + schedule: { number: '3', unit: 'm' }, + 'service.name': '', + config_id: '', + tags: [], + timeout: '16', + name: 'tcp://google.com:80', + locations: [{ id: 'dev', label: 'Dev Service', isServiceManaged: true }], + namespace: 'default', + // @ts-expect-error extra field + origin: 'ui', + journey_id: '', + hash: '', + id: '', + params: '', + max_attempts: 2, + __ui: { is_tls_enabled: false }, + hosts: 'tcp://google.com:80', + urls: '', + 'url.port': null, + proxy_url: '', + proxy_use_local_resolver: false, + 'check.receive': '', + 'check.send': '', + mode: 'any', + ipv4: true, + ipv6: true, + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.key': '', + 'ssl.key_passphrase': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + }; + const api = new UpsertMonitorAPI(routeContext as any); + const hydratedMonitor = api.hydrateMonitorFields({ + normalizedMonitor, + newMonitorId, + }); + + expect((hydratedMonitor as any)[ConfigKey.SOURCE_PROJECT_CONTENT]).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor.ts index f2c8f0974a6b4..2989bf1d8f36b 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor.ts @@ -9,11 +9,12 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; import { validatePermissions } from './edit_monitor'; import { InvalidLocationError } from '../../synthetics_service/project_monitor/normalizers/common_fields'; -import { AddEditMonitorAPI, CreateMonitorPayLoad } from './add_monitor/add_monitor_api'; +import { UpsertMonitorAPI, CreateMonitorPayLoad } from './add_monitor/upsert_monitor_api'; import { SyntheticsRestApiRouteFactory } from '../types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; import { normalizeAPIConfig, validateMonitor } from './monitor_validation'; import { mapSavedObjectToMonitor } from './formatters/saved_object_to_monitor'; +import { mapInlineToProjectFields } from '../../synthetics_service/utils/map_inline_to_project_fields'; export const addSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'POST', @@ -39,7 +40,7 @@ export const addSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ // usually id is auto generated, but this is useful for testing const { id, internal } = request.query; - const addMonitorAPI = new AddEditMonitorAPI(routeContext); + const addMonitorAPI = new UpsertMonitorAPI(routeContext); const { locations, @@ -86,7 +87,15 @@ export const addSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ }); } - const normalizedMonitor = validationResult.decodedMonitor; + const normalizedMonitor = Object.assign( + {}, + validationResult.decodedMonitor, + await mapInlineToProjectFields({ + monitorType: validationResult.decodedMonitor?.type, + monitor: validationResult?.decodedMonitor, + logger: server.logger, + }) + ); const err = await validatePermissions(routeContext, normalizedMonitor.locations); if (err) { diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/upsert_monitor_api.test.ts similarity index 98% rename from x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.test.ts rename to x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/upsert_monitor_api.test.ts index 47429f7f037ef..497fd5c1cc1bc 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/upsert_monitor_api.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { AddEditMonitorAPI } from './add_monitor_api'; +import { UpsertMonitorAPI } from './upsert_monitor_api'; import { SyntheticsMonitorClient } from '../../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; import { SyntheticsService } from '../../../synthetics_service/synthetics_service'; @@ -16,7 +16,7 @@ describe('AddNewMonitorsPublicAPI', () => { enabled: true, }, } as any); - const api = new AddEditMonitorAPI({ + const api = new UpsertMonitorAPI({ syntheticsMonitorClient: new SyntheticsMonitorClient(syntheticsService, {} as any), request: { body: {}, @@ -55,7 +55,7 @@ describe('AddNewMonitorsPublicAPI', () => { const syntheticsService = new SyntheticsService({ config: {}, } as any); - const api = new AddEditMonitorAPI({ + const api = new UpsertMonitorAPI({ syntheticsMonitorClient: new SyntheticsMonitorClient(syntheticsService, {} as any), request: { body: {}, diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/upsert_monitor_api.ts similarity index 98% rename from x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts rename to x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/upsert_monitor_api.ts index f8c7fa9ed9b23..cf6a6431bb439 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/add_monitor_api.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/upsert_monitor_api.ts @@ -49,12 +49,9 @@ export type CreateMonitorPayLoad = MonitorFields & { schedule?: number | MonitorFields['schedule']; }; -export class AddEditMonitorAPI { - routeContext: RouteContext; +export class UpsertMonitorAPI { allPrivateLocations?: PrivateLocationAttributes[]; - constructor(routeContext: RouteContext) { - this.routeContext = routeContext; - } + constructor(private readonly routeContext: RouteContext) {} async syncNewMonitor({ id, diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts index 2c58e868d827f..16f034e0d1a16 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/add_monitor/utils.ts @@ -6,7 +6,7 @@ */ import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; -import { CreateMonitorPayLoad } from './add_monitor_api'; +import { CreateMonitorPayLoad } from './upsert_monitor_api'; import { MonitorFields, SyntheticsMonitor } from '../../../../common/runtime_types'; import { getPrivateLocations } from '../../../synthetics_service/get_private_locations'; diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts index cb50708c04eca..bbb8ff8e6ce3c 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.test.ts @@ -6,9 +6,11 @@ */ import { loggerMock } from '@kbn/logging-mocks'; -import { syncEditedMonitor } from './edit_monitor'; +import { refreshInlineZip, syncEditedMonitor } from './edit_monitor'; import { SavedObject, SavedObjectsClientContract, KibanaRequest } from '@kbn/core/server'; import { + BrowserSensitiveSimpleFields, + ConfigKey, EncryptedSyntheticsMonitorAttributes, SyntheticsMonitor, SyntheticsMonitorWithSecretsAttributes, @@ -17,6 +19,7 @@ import { SyntheticsService } from '../../synthetics_service/synthetics_service'; import { SyntheticsMonitorClient } from '../../synthetics_service/synthetics_monitor/synthetics_monitor_client'; import { mockEncryptedSO } from '../../synthetics_service/utils/mocks'; import { SyntheticsServerSetup } from '../../types'; +import { unzipFile } from '../../common/unzip_project_code'; jest.mock('../telemetry/monitor_upgrade_sender', () => ({ sendTelemetryEvents: jest.fn(), @@ -129,3 +132,250 @@ describe('syncEditedMonitor', () => { ); }); }); + +describe('refreshInlineZip', () => { + it('refreshes the inline zip', async () => { + const normalized: SyntheticsMonitor = { + // @ts-expect-error extra field + type: 'browser', + // @ts-expect-error extra field + form_monitor_type: 'multistep', + enabled: true, + alert: { status: { enabled: true }, tls: { enabled: true } }, + // @ts-expect-error extra field + schedule: { unit: 'm', number: '10' }, + 'service.name': '', + config_id: '7be61e81-a29a-439d-877b-9ff694c60d1b', + tags: [], + timeout: null, + name: 'test-again', + locations: [{ id: 'dev', label: 'Dev Service', isServiceManaged: true }], + namespace: 'default', + // @ts-expect-error extra field + origin: 'ui', + journey_id: '', + hash: '', + id: '7be61e81-a29a-439d-877b-9ff694c60d1b', + params: '', + max_attempts: 2, + project_id: '', + playwright_options: '', + __ui: { script_source: { is_generated_script: false, file_name: '' } }, + 'url.port': null, + 'source.inline.script': `step('goto', ()=> page.goto('https://elastic.co')) + step('fail', () => { + // add a comment + throw Error('fail') + })`, + 'source.project.content': `UEsDBBQACAAIAAiNSVgAAAAAAAAAAAAAAAARAAAAaW5saW5lLmpvdXJuZXkudHM1jsEKwjAQRO/5irmlgWDviuLFD4lhtZE2GzdbVEr/XVor7GmG92bTUFgUEx48SqaPR1UqHvQuFBUzbsID7Jn6UDXFtn6ydqQpVnswZoMam3KfMlmPZkIJd/KInJXe6nEVflUSjxIkDNVD6DlSVcwOxxMmg3WysXdWXgzueFoduyVobKda6r5ttw92ka1z5ofcQupX5G8CtBN+4SLCsvXOzMsdzBdQSwcIvqw1HaUAAADsAAAAUEsBAi0DFAAIAAgACI1JWL6sNR2lAAAA7AAAABEAAAAAAAAAAAAgAKSBAAAAAGlubGluZS5qb3VybmV5LnRzUEsFBgAAAAABAAEAPwAAAOQAAAAAAA==`, + playwright_text_assertion: '', + urls: '', + screenshots: 'on', + synthetics_args: [], + 'filter_journeys.match': '', + 'filter_journeys.tags': [], + ignore_https_errors: false, + throttling: { + value: { download: '5', upload: '3', latency: '20' }, + id: 'default', + label: 'Default', + }, + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.key': '', + 'ssl.key_passphrase': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + revision: 2, + }; + const previous: SavedObject = { + id: '7be61e81-a29a-439d-877b-9ff694c60d1b', + type: 'synthetics-monitor', + namespaces: ['default'], + migrationVersion: undefined, + updated_at: '2024-02-09T17:40:17.708Z', + created_at: '2024-02-09T17:40:17.708Z', + version: 'WzQzMTAsMV0=', + attributes: { + // @ts-expect-error extra field + type: 'browser', + // @ts-expect-error extra field + form_monitor_type: 'multistep', + enabled: true, + // @ts-expect-error extra field + alert: { status: [Object], tls: [Object] }, + // @ts-expect-error extra field + schedule: { unit: 'm', number: '10' }, + 'service.name': '', + config_id: '7be61e81-a29a-439d-877b-9ff694c60d1b', + tags: [], + timeout: null, + name: 'test-again', + namespace: 'default', + // @ts-expect-error extra field + origin: 'ui', + journey_id: '', + hash: '', + id: '7be61e81-a29a-439d-877b-9ff694c60d1b', + max_attempts: 2, + project_id: '', + playwright_options: '', + __ui: { script_source: [Object] }, + 'url.port': null, + playwright_text_assertion: '', + urls: '', + screenshots: 'on', + 'filter_journeys.match': '', + 'filter_journeys.tags': [], + ignore_https_errors: false, + throttling: { value: [Object], id: 'default', label: 'Default' }, + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + revision: 1, + }, + references: [], + managed: false, + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '8.9.0', + }; + + // @ts-expect-error not testing logger functionality + const result = await refreshInlineZip(normalized, previous, jest.fn()); + expect(typeof (result as BrowserSensitiveSimpleFields)[ConfigKey.SOURCE_PROJECT_CONTENT]).toBe( + 'string' + ); + expect( + await unzipFile((result as BrowserSensitiveSimpleFields)[ConfigKey.SOURCE_PROJECT_CONTENT]) + ).toMatchInlineSnapshot(` + "import { journey, step, expect, mfa } from '@elastic/synthetics'; + + journey('inline', ({ page, context, browser, params, request }) => { + step('goto', ()=> page.goto('https://elastic.co')) + step('fail', () => { + // add a comment + throw Error('fail') + }) + });" + `); + // the inline script was edited, and thus the new zip should be different + expect((result as BrowserSensitiveSimpleFields)[ConfigKey.SOURCE_PROJECT_CONTENT]).not.toEqual( + (normalized as BrowserSensitiveSimpleFields)[ConfigKey.SOURCE_PROJECT_CONTENT] + ); + expect((result as BrowserSensitiveSimpleFields)[ConfigKey.SOURCE_INLINE]) + .toMatchInlineSnapshot(` + "step('goto', ()=> page.goto('https://elastic.co')) + step('fail', () => { + // add a comment + throw Error('fail') + })" + `); + }); + + it('does nothing for lightweight monitors', async () => { + const normalized = { + type: 'http', + form_monitor_type: 'http', + enabled: true, + alert: { status: { enabled: true }, tls: { enabled: true } }, + schedule: { number: '1', unit: 'm' }, + 'service.name': '', + config_id: '4b14793b-5916-42b4-946f-0eca7e09cf72', + tags: [], + timeout: '16', + name: 'http', + locations: [{ id: 'dev', label: 'Dev Service', isServiceManaged: true }], + namespace: 'default', + origin: 'ui', + journey_id: '', + hash: '', + id: '4b14793b-5916-42b4-946f-0eca7e09cf72', + params: '', + max_attempts: 2, + __ui: { is_tls_enabled: false }, + urls: 'https://www.elastic.co', + max_redirects: '0', + 'url.port': null, + password: '', + proxy_url: '', + proxy_headers: {}, + 'check.response.body.negative': [], + 'check.response.body.positive': [], + 'check.response.json': [], + 'response.include_body': 'on_error', + 'check.response.headers': {}, + 'response.include_headers': true, + 'check.response.status': [], + 'check.request.body': { type: 'text', value: '' }, + 'check.request.headers': { 'Content-Type': 'text/plain' }, + 'check.request.method': 'GET', + username: '', + mode: 'any', + 'response.include_body_max_bytes': '1024', + ipv4: true, + ipv6: true, + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.key': '', + 'ssl.key_passphrase': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + revision: 4, + }; + const previous = { + id: '4b14793b-5916-42b4-946f-0eca7e09cf72', + type: 'synthetics-monitor', + namespaces: ['default'], + migrationVersion: undefined, + updated_at: '2024-02-09T20:02:56.660Z', + created_at: '2024-02-09T16:43:19.030Z', + version: 'WzUzNzcsMV0=', + attributes: { + type: 'http', + form_monitor_type: 'http', + enabled: true, + alert: { status: [Object], tls: [Object] }, + schedule: { number: '1', unit: 'm' }, + 'service.name': '', + config_id: '4b14793b-5916-42b4-946f-0eca7e09cf72', + tags: [], + timeout: '16', + name: 'http', + locations: [[Object]], + namespace: 'default', + origin: 'ui', + journey_id: '', + hash: '', + id: '4b14793b-5916-42b4-946f-0eca7e09cf72', + max_attempts: 2, + __ui: { is_tls_enabled: false }, + urls: 'https://www.elastic.co/', + max_redirects: '0', + 'url.port': null, + proxy_url: '', + 'response.include_body': 'on_error', + 'response.include_headers': true, + 'check.response.status': [], + 'check.request.method': 'GET', + mode: 'any', + 'response.include_body_max_bytes': '1024', + ipv4: true, + ipv6: true, + 'ssl.certificate_authorities': '', + 'ssl.certificate': '', + 'ssl.verification_mode': 'full', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + revision: 3, + }, + references: [], + managed: false, + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '8.9.0', + }; + + // @ts-expect-error not testing logger functionality + const result = await refreshInlineZip(normalized, previous, jest.fn()); + expect((result as any)[ConfigKey.SOURCE_PROJECT_CONTENT]).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.ts index d460b71037950..af26a816f730c 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/edit_monitor.ts @@ -10,7 +10,7 @@ import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { isEmpty } from 'lodash'; import { invalidOriginError } from './add_monitor'; import { InvalidLocationError } from '../../synthetics_service/project_monitor/normalizers/common_fields'; -import { AddEditMonitorAPI, CreateMonitorPayLoad } from './add_monitor/add_monitor_api'; +import { UpsertMonitorAPI, CreateMonitorPayLoad } from './add_monitor/upsert_monitor_api'; import { ELASTIC_MANAGED_LOCATIONS_DISABLED } from './add_monitor_project'; import { getDecryptedMonitor } from '../../saved_objects/synthetics_monitor'; import { getPrivateLocations } from '../../synthetics_service/get_private_locations'; @@ -33,9 +33,10 @@ import { formatTelemetryUpdateEvent, } from '../telemetry/monitor_upgrade_sender'; import { formatSecrets, normalizeSecrets } from '../../synthetics_service/utils/secrets'; +import { SyntheticsServerSetup } from '../../types'; import { mapSavedObjectToMonitor } from './formatters/saved_object_to_monitor'; +import { mapInlineToProjectFields } from '../../synthetics_service/utils/map_inline_to_project_fields'; -// Simplify return promise type and type it with runtime_types export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'PUT', path: SYNTHETICS_API_URLS.SYNTHETICS_MONITORS + '/{monitorId}', @@ -73,7 +74,7 @@ export const editSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ( return response.badRequest(getInvalidOriginError(monitor)); } - const editMonitorAPI = new AddEditMonitorAPI(routeContext); + const editMonitorAPI = new UpsertMonitorAPI(routeContext); if (monitor.name) { const nameError = await editMonitorAPI.validateUniqueMonitorName(monitor.name, monitorId); if (nameError) { @@ -219,6 +220,28 @@ const rollbackUpdate = async ({ } }; +export const refreshInlineZip = async ( + normalizedMonitor: SyntheticsMonitor, + previousMonitor: SavedObject, + server: SyntheticsServerSetup +) => { + const projectFields = await mapInlineToProjectFields({ + monitorType: normalizedMonitor[ConfigKey.MONITOR_TYPE], + monitor: normalizedMonitor, + logger: server.logger, + }); + return Object.assign( + {}, + normalizedMonitor, + { + [ConfigKey.MONITOR_QUERY_ID]: + normalizedMonitor[ConfigKey.CUSTOM_HEARTBEAT_ID] || previousMonitor.id, + [ConfigKey.CONFIG_ID]: previousMonitor.id, + }, + projectFields + ); +}; + export const syncEditedMonitor = async ({ normalizedMonitor, decryptedPreviousMonitor, @@ -232,13 +255,12 @@ export const syncEditedMonitor = async ({ }) => { const { server, savedObjectsClient, syntheticsMonitorClient } = routeContext; try { - const monitorWithId = { - ...normalizedMonitor, - [ConfigKey.MONITOR_QUERY_ID]: - normalizedMonitor[ConfigKey.CUSTOM_HEARTBEAT_ID] || decryptedPreviousMonitor.id, - [ConfigKey.CONFIG_ID]: decryptedPreviousMonitor.id, - }; - const formattedMonitor = formatSecrets(monitorWithId); + const monitorWithId = await refreshInlineZip( + normalizedMonitor, + decryptedPreviousMonitor, + server + ); + const formattedMonitor = formatSecrets(monitorWithId as MonitorFields); const editedSOPromise = savedObjectsClient.update( syntheticsMonitorType, diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/inspect_monitor.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/inspect_monitor.ts index 6b4687e2bea81..774ec2481a56c 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/inspect_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/inspect_monitor.ts @@ -14,7 +14,7 @@ import { SYNTHETICS_API_URLS } from '../../../common/constants'; import { DEFAULT_FIELDS } from '../../../common/constants/monitor_defaults'; import { validateMonitor } from './monitor_validation'; import { getPrivateLocationsForMonitor } from './add_monitor/utils'; -import { AddEditMonitorAPI } from './add_monitor/add_monitor_api'; +import { UpsertMonitorAPI } from './add_monitor/upsert_monitor_api'; export const inspectSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'POST', @@ -65,7 +65,7 @@ export const inspectSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () = try { const newMonitorId = id ?? uuidV4(); - const addMonitorAPI = new AddEditMonitorAPI(routeContext); + const addMonitorAPI = new UpsertMonitorAPI(routeContext); const monitorWithNamespace = addMonitorAPI.hydrateMonitorFields({ normalizedMonitor, diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts index ee4ccb7b7bf3e..e4550b1ba2eb3 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/monitor_validation.ts @@ -12,7 +12,7 @@ import { formatErrors } from '@kbn/securitysolution-io-ts-utils'; import { omit } from 'lodash'; import { schema } from '@kbn/config-schema'; import { AlertConfigSchema } from '../../../common/runtime_types/monitor_management/alert_config_schema'; -import { CreateMonitorPayLoad } from './add_monitor/add_monitor_api'; +import { CreateMonitorPayLoad } from './add_monitor/upsert_monitor_api'; import { flattenAndFormatObject } from '../../synthetics_service/project_monitor/normalizers/common_fields'; import { BrowserFieldsCodec, diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts index 4fc527f930832..d323b1b5bc773 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/monitor_cruds/services/delete_monitor_api.ts @@ -139,7 +139,6 @@ export class DeleteMonitorAPI { ...normalizedMonitor.attributes, id: normalizedMonitor.attributes[ConfigKey.MONITOR_QUERY_ID], })) as SyntheticsMonitorWithId[], - savedObjectsClient, spaceId ); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/run_once_monitor.test.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/run_once_monitor.test.ts new file mode 100644 index 0000000000000..221cc4d093f59 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/run_once_monitor.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConfigKey } from '../../../common/runtime_types'; +import { unzipFile } from '../../common/unzip_project_code'; +import * as validator from '../monitor_cruds/monitor_validation'; +import { runOnceSyntheticsMonitorRoute } from './run_once_monitor'; + +// Mocking the necessary modules +jest.mock('../monitor_cruds/monitor_validation', () => ({ + validateMonitor: jest.fn(), +})); +jest.mock('../monitor_cruds/add_monitor/utils', () => ({ + getPrivateLocationsForMonitor: jest.fn(), +})); + +describe('runOnceSyntheticsMonitorRoute', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('will ship zipped project content for inline script monitors', async () => { + const monitorData = { type: 'browser', name: 'test monitor' }; + jest.spyOn(validator, 'validateMonitor').mockReturnValue({ + decodedMonitor: { + // @ts-ignore just testing zip + type: 'browser', + [ConfigKey.SOURCE_INLINE]: 'step("goto", () => page.goto("http://example.com"))', + urls: 'http://example.com', + 'url.port': null, + }, + valid: true, + }); + + const testNowConfigsMock = jest.fn() as any; + testNowConfigsMock.mockResolvedValue([null, undefined]); + await runOnceSyntheticsMonitorRoute().handler({ + // @ts-ignore just testing zip + request: { + params: { monitorId: 'test-monitor-id' }, + body: monitorData, + }, + response: {} as any, + server: {} as any, + syntheticsMonitorClient: { + testNowConfigs: testNowConfigsMock, + } as any, + savedObjectsClient: {} as any, + }); + + expect(testNowConfigsMock).toHaveBeenCalledTimes(1); + const monitor = testNowConfigsMock.mock.calls[0][0].monitor; + expect(monitor).toBeDefined(); + expect(monitor[ConfigKey.SOURCE_INLINE]).toBeUndefined(); + expect(monitor[ConfigKey.SOURCE_PROJECT_CONTENT]).toBeDefined(); + expect(await unzipFile(monitor[ConfigKey.SOURCE_PROJECT_CONTENT])).toMatchInlineSnapshot(` + "import { journey, step, expect, mfa } from '@elastic/synthetics'; + + journey('inline', ({ page, context, browser, params, request }) => { + step(\\"goto\\", () => page.goto(\\"http://example.com\\")) + });" + `); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/run_once_monitor.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/run_once_monitor.ts index 2af3a10f39750..d98810f14c62a 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/run_once_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/run_once_monitor.ts @@ -6,13 +6,14 @@ */ import { schema } from '@kbn/config-schema'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; -import { isEmpty } from 'lodash'; +import { isEmpty, omit } from 'lodash'; import { PrivateLocationAttributes } from '../../runtime_types/private_locations'; import { getPrivateLocationsForMonitor } from '../monitor_cruds/add_monitor/utils'; import { SyntheticsRestApiRouteFactory } from '../types'; import { ConfigKey, MonitorFields } from '../../../common/runtime_types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; import { validateMonitor } from '../monitor_cruds/monitor_validation'; +import { mapInlineToProjectFields } from '../../synthetics_service/utils/map_inline_to_project_fields'; export const runOnceSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ method: 'POST', @@ -30,13 +31,13 @@ export const runOnceSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () = syntheticsMonitorClient, savedObjectsClient, }): Promise => { - const monitor = request.body as MonitorFields; + const requestFields = request.body as MonitorFields; const { monitorId } = request.params; - if (isEmpty(monitor)) { + if (isEmpty(requestFields)) { return response.badRequest({ body: { message: 'Monitor data is empty.' } }); } - const validationResult = validateMonitor(monitor); + const validationResult = validateMonitor(requestFields); const spaceId = server.spaces?.spacesService.getSpaceId(request) ?? DEFAULT_SPACE_ID; @@ -51,17 +52,26 @@ export const runOnceSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () = decodedMonitor ); + const monitorFields = { + ...decodedMonitor, + [ConfigKey.CONFIG_ID]: monitorId, + [ConfigKey.MONITOR_QUERY_ID]: monitorId, + } as MonitorFields; + const zippedProjectFields = await mapInlineToProjectFields({ + monitorType: decodedMonitor.type, + monitor: monitorFields, + logger: server.logger, + }); + const monitor = omit( + Object.assign(monitorFields, zippedProjectFields), + ConfigKey.SOURCE_INLINE + ) as MonitorFields; const [, errors] = await syntheticsMonitorClient.testNowConfigs( { - monitor: { - ...decodedMonitor, - [ConfigKey.CONFIG_ID]: monitorId, - [ConfigKey.MONITOR_QUERY_ID]: monitorId, - } as MonitorFields, + monitor, id: monitorId, testRunId: monitorId, }, - savedObjectsClient, privateLocations, spaceId, true @@ -71,6 +81,6 @@ export const runOnceSyntheticsMonitorRoute: SyntheticsRestApiRouteFactory = () = return { errors }; } - return monitor; + return requestFields; }, }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/test_now_monitor.ts b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/test_now_monitor.ts index d1a1513ae85c8..48494c54b638f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/test_now_monitor.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/routes/synthetics_service/test_now_monitor.ts @@ -16,6 +16,7 @@ import { ConfigKey, MonitorFields } from '../../../common/runtime_types'; import { SYNTHETICS_API_URLS } from '../../../common/constants'; import { normalizeSecrets } from '../../synthetics_service/utils/secrets'; import { getPrivateLocationsForMonitor } from '../monitor_cruds/add_monitor/utils'; +import { dropInlineScriptForTransmission } from '../../synthetics_service/utils/map_inline_to_project_fields'; import { getMonitorNotFoundResponse } from './service_errors'; export const testNowMonitorRoute: SyntheticsRestApiRouteFactory = () => ({ @@ -54,11 +55,10 @@ export const triggerTestNow = async ( const [, errors] = await syntheticsMonitorClient.testNowConfigs( { - monitor: normalizedMonitor.attributes as MonitorFields, + monitor: dropInlineScriptForTransmission(normalizedMonitor.attributes as MonitorFields), id: monitorId, testRunId, }, - savedObjectsClient, privateLocations, spaceId ); diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts index 78cbc2d4a0790..4ddfd32d68ab3 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/private_formatters/format_synthetics_policy.ts @@ -6,7 +6,7 @@ */ import { NewPackagePolicy } from '@kbn/fleet-plugin/common'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, omit } from 'lodash'; import { processorsFormatter } from './processors_formatter'; import { LegacyConfigKey } from '../../../../common/constants/monitor_management'; import { ConfigKey, MonitorTypeEnum, MonitorFields } from '../../../../common/runtime_types'; @@ -80,5 +80,9 @@ export const formatSyntheticsPolicy = ( throttling.value = throttlingFormatter?.(config, ConfigKey.THROTTLING_CONFIG); } - return { formattedPolicy, hasDataStream: Boolean(dataStream), hasInput: Boolean(currentInput) }; + return { + formattedPolicy: omit(formattedPolicy, [ConfigKey.SOURCE_INLINE]) as NewPackagePolicy, + hasDataStream: Boolean(dataStream), + hasInput: Boolean(currentInput), + }; }; diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.test.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.test.ts index 979db50dee6d4..262e577c48e5c 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.test.ts @@ -22,7 +22,9 @@ import { ScheduleUnit, SyntheticsMonitor, VerificationMode, + BrowserSensitiveSimpleFields, } from '../../../../common/runtime_types'; +import { inlineToProjectZip } from '../../../common/inline_to_zip'; const testHTTPConfig: Partial = { type: 'http' as MonitorTypeEnum, @@ -355,66 +357,13 @@ describe('formatHeartbeatRequest', () => { }); }); - it('sets project fields as null when project id is not defined', () => { - const monitorId = 'test-monitor-id'; - const monitor = { ...testBrowserConfig, project_id: undefined } as SyntheticsMonitor; - const actual = formatHeartbeatRequest({ - monitor, - configId: monitorId, - heartbeatId: monitorId, - spaceId: 'test-space-id', - }); - - expect(actual).toEqual({ - ...monitor, - id: monitorId, - fields: { - config_id: monitorId, - 'monitor.project.name': undefined, - 'monitor.project.id': undefined, - run_once: undefined, - test_run_id: undefined, - meta: { - space_id: 'test-space-id', - }, - }, - fields_under_root: true, - }); - }); - - it('sets project fields as null when project id is empty', () => { - const monitorId = 'test-monitor-id'; - const monitor = { ...testBrowserConfig, project_id: '' } as SyntheticsMonitor; - const actual = formatHeartbeatRequest({ - monitor, - configId: monitorId, - heartbeatId: monitorId, - spaceId: 'test-space-id', - }); - - expect(actual).toEqual({ - ...monitor, - id: monitorId, - fields: { - config_id: monitorId, - 'monitor.project.name': undefined, - 'monitor.project.id': undefined, - run_once: undefined, - test_run_id: undefined, - meta: { - space_id: 'test-space-id', - }, - }, - fields_under_root: true, - }); - }); - - it('supports run once', () => { + it('supports test_run_id', () => { const monitorId = 'test-monitor-id'; + const testRunId = 'beep'; const actual = formatHeartbeatRequest({ monitor: testBrowserConfig as SyntheticsMonitor, configId: monitorId, - runOnce: true, + testRunId, heartbeatId: monitorId, spaceId: 'test-space-id', }); @@ -426,8 +375,8 @@ describe('formatHeartbeatRequest', () => { config_id: monitorId, 'monitor.project.name': testBrowserConfig.project_id, 'monitor.project.id': testBrowserConfig.project_id, - run_once: true, - test_run_id: undefined, + run_once: undefined, + test_run_id: testRunId, meta: { space_id: 'test-space-id', }, @@ -436,11 +385,11 @@ describe('formatHeartbeatRequest', () => { }); }); - it('supports test_run_id', () => { + it('does not append project data', () => { const monitorId = 'test-monitor-id'; const testRunId = 'beep'; const actual = formatHeartbeatRequest({ - monitor: testBrowserConfig as SyntheticsMonitor, + monitor: { ...testBrowserConfig, params: '' } as SyntheticsMonitor, configId: monitorId, testRunId, heartbeatId: monitorId, @@ -449,6 +398,7 @@ describe('formatHeartbeatRequest', () => { expect(actual).toEqual({ ...testBrowserConfig, + params: '', id: monitorId, fields: { config_id: monitorId, @@ -462,33 +412,50 @@ describe('formatHeartbeatRequest', () => { }, fields_under_root: true, }); + expect( + (actual as BrowserSensitiveSimpleFields)[ConfigKey.SOURCE_PROJECT_CONTENT] + ).toBeUndefined(); + expect((actual as BrowserSensitiveSimpleFields)[ConfigKey.SOURCE_INLINE]) + .toMatchInlineSnapshot(` + "step('Go to https://www.google.com/', async () => { + await page.goto('https://www.google.com/'); + });" + `); }); - it('supports empty params', () => { + it('retains project string if there is no inline content', async () => { const monitorId = 'test-monitor-id'; - const testRunId = 'beep'; + const projectZipInput = await inlineToProjectZip( + "step('Go to https://www.google.com/', async () => {\n await page.goto('https://www.google.com/');\n});", + monitorId, + jest.fn() as any + ); const actual = formatHeartbeatRequest({ - monitor: { ...testBrowserConfig, params: '' } as SyntheticsMonitor, + monitor: { + ...testBrowserConfig, + [ConfigKey.SOURCE_INLINE]: '', + [ConfigKey.SOURCE_PROJECT_CONTENT]: projectZipInput, + } as SyntheticsMonitor, configId: monitorId, - testRunId, heartbeatId: monitorId, spaceId: 'test-space-id', }); expect(actual).toEqual({ ...testBrowserConfig, - params: '', id: monitorId, fields: { config_id: monitorId, 'monitor.project.name': testBrowserConfig.project_id, 'monitor.project.id': testBrowserConfig.project_id, run_once: undefined, - test_run_id: testRunId, + test_run_id: undefined, meta: { space_id: 'test-space-id', }, }, + [ConfigKey.SOURCE_INLINE]: '', + [ConfigKey.SOURCE_PROJECT_CONTENT]: projectZipInput, fields_under_root: true, }); }); diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.ts index d9c0821154990..b4abe1c8bd7a1 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/formatters/public_formatters/format_configs.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { isEmpty, isNil, omitBy } from 'lodash'; +import { isEmpty, isNil, omit, omitBy } from 'lodash'; import { Logger } from '@kbn/logging'; import { replaceStringWithParams } from '../formatting_utils'; import { PARAMS_KEYS_TO_SKIP } from '../common'; @@ -89,6 +89,13 @@ export interface ConfigData { spaceId: string; } +function stripInlineScript(config: BrowserFields): BrowserFields { + if (config?.[ConfigKey.SOURCE_PROJECT_CONTENT] && config?.[ConfigKey.SOURCE_INLINE]) { + return omit(config, ConfigKey.SOURCE_INLINE) as BrowserFields; + } + return config; +} + export const formatHeartbeatRequest = ( { monitor, configId, heartbeatId, runOnce, testRunId, spaceId }: Omit, params?: string @@ -101,7 +108,7 @@ export const formatHeartbeatRequest = ( const { labels } = monitor; return { - ...monitor, + ...stripInlineScript(monitor as BrowserFields), id: heartbeatIdT, fields: { config_id: configId, diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts index 9ed34399e74f2..09ba90695ea7f 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/private_location/synthetics_private_location.ts @@ -158,7 +158,6 @@ export class SyntheticsPrivateLocation { `Unable to find Synthetics private location for agentId ${privateLocation.id}` ); } - const newPolicy = await this.generateNewPolicy( config, location, diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.test.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.test.ts index 594621b3b1ab4..bdd5848900fef 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.test.ts @@ -5,7 +5,7 @@ * 2.0. */ import { loggerMock } from '@kbn/logging-mocks'; -import { SavedObjectsClientContract, CoreStart } from '@kbn/core/server'; +import { CoreStart } from '@kbn/core/server'; import { coreMock } from '@kbn/core/server/mocks'; import { SyntheticsMonitorClient } from './synthetics_monitor_client'; import { SyntheticsService } from '../synthetics_service'; @@ -42,10 +42,6 @@ describe('SyntheticsMonitorClient', () => { const mockEsClient = { search: jest.fn(), }; - const savedObjectsClientMock = { - bulkUpdate: jest.fn(), - get: jest.fn(), - } as unknown as SavedObjectsClientContract; const logger = loggerMock.create(); @@ -204,11 +200,7 @@ describe('SyntheticsMonitorClient', () => { client.privateLocationAPI.deleteMonitors = jest.fn(); syntheticsService.deleteConfigs = jest.fn(); - await client.deleteMonitors( - [monitor as unknown as SyntheticsMonitorWithId], - savedObjectsClientMock, - 'test-space' - ); + await client.deleteMonitors([monitor as unknown as SyntheticsMonitorWithId], 'test-space'); expect(syntheticsService.deleteConfigs).toHaveBeenCalledTimes(1); expect(client.privateLocationAPI.deleteMonitors).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts index b0f9cd20211b3..566f43ac71fb3 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_monitor/synthetics_monitor_client.ts @@ -4,7 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { SavedObject, SavedObjectsClientContract, SavedObjectsFindResult } from '@kbn/core/server'; + +import { SavedObject, SavedObjectsFindResult } from '@kbn/core/server'; import { EncryptedSavedObjectsPluginStart } from '@kbn/encrypted-saved-objects-plugin/server'; import { SyntheticsServerSetup } from '../../types'; import { syntheticsMonitorType } from '../../../common/types/saved_objects'; @@ -162,11 +163,7 @@ export class SyntheticsMonitorClient { return { failedPolicyUpdates, publicSyncErrors }; } - async deleteMonitors( - monitors: SyntheticsMonitorWithId[], - savedObjectsClient: SavedObjectsClientContract, - spaceId: string - ) { + async deleteMonitors(monitors: SyntheticsMonitorWithId[], spaceId: string) { const privateDeletePromise = this.privateLocationAPI.deleteMonitors(monitors, spaceId); const publicDeletePromise = this.syntheticsService.deleteConfigs( @@ -179,7 +176,6 @@ export class SyntheticsMonitorClient { async testNowConfigs( monitor: { monitor: MonitorFields; id: string; testRunId: string }, - savedObjectsClient: SavedObjectsClientContract, allPrivateLocations: PrivateLocationAttributes[], spaceId: string, runOnce?: true @@ -220,7 +216,7 @@ export class SyntheticsMonitorClient { } } - const newPolicies = this.privateLocationAPI.createPackagePolicies( + const newPoliciesPromise = this.privateLocationAPI.createPackagePolicies( privateConfig ? [privateConfig] : [], allPrivateLocations, spaceId, @@ -228,9 +224,9 @@ export class SyntheticsMonitorClient { runOnce ); - const syncErrors = this.syntheticsService.runOnceConfigs(publicConfig); + const syncErrorsPromise = this.syntheticsService.runOnceConfigs(publicConfig); - return await Promise.all([newPolicies, syncErrors]); + return await Promise.all([newPoliciesPromise, syncErrorsPromise]); } hasPrivateLocations(previousMonitor: SavedObject) { diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts index fdc41831e8afd..d8fa277606402 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.test.ts @@ -10,9 +10,9 @@ import { coreMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { CoreStart } from '@kbn/core/server'; import { SyntheticsService } from './synthetics_service'; import { loggerMock } from '@kbn/logging-mocks'; -import axios, { AxiosResponse } from 'axios'; +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; import times from 'lodash/times'; -import { LocationStatus, HeartbeatConfig } from '../../common/runtime_types'; +import { LocationStatus, HeartbeatConfig, ConfigKey } from '../../common/runtime_types'; import { mockEncryptedSO } from './utils/mocks'; import * as apiKeys from './get_api_key'; import { SyntheticsServerSetup } from '../types'; @@ -356,6 +356,60 @@ describe('SyntheticsService', () => { ); }); + it('does not add zip to inline source', async () => { + const { service, locations } = getMockedService(); + + serverMock.encryptedSavedObjects = mockEncryptedSO({ + monitors: [ + { + attributes: { + ...getFakePayload([locations[0]]), + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.SOURCE_INLINE]: `step('goto', () => page.goto('https://elastic.co'))`, + }, + }, + ], + }); + + (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + + await service.pushConfigs(); + + expect(axios).toHaveBeenCalledTimes(1); + const mockArg = (axios as jest.MockedFunction).mock.calls[0][0]; + const projectContent = (mockArg as AxiosRequestConfig).data.monitors[0].streams[0][ + ConfigKey.SOURCE_PROJECT_CONTENT + ]; + expect(projectContent).not.toBeDefined(); + }); + + it('does not push a zip if inline content is missing', async () => { + const { service, locations } = getMockedService(); + + serverMock.encryptedSavedObjects = mockEncryptedSO({ + monitors: [ + { + attributes: { + ...getFakePayload([locations[0]]), + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.SOURCE_INLINE]: undefined, + }, + }, + ], + }); + + (axios as jest.MockedFunction).mockResolvedValue({} as AxiosResponse); + + await service.pushConfigs(); + + expect(axios).toHaveBeenCalledTimes(1); + const mockArg = (axios as jest.MockedFunction).mock.calls[0][0]; + const projectContent = (mockArg as AxiosRequestConfig).data.monitors[0].streams[0][ + ConfigKey.SOURCE_PROJECT_CONTENT + ]; + expect(projectContent).toBeUndefined(); + }); + it.each([ [true, 'Cannot sync monitors with the Synthetics service. License is expired.'], [ diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.ts index be72ca4d9a496..89a2fd00c1249 100644 --- a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/synthetics_service.ts @@ -16,8 +16,8 @@ import { } from '@kbn/task-manager-plugin/server'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; -import pMap from 'p-map'; import moment from 'moment'; +import pMap from 'p-map'; import { registerCleanUpTask } from './private_location/clean_up_task'; import { SyntheticsServerSetup } from '../types'; import { syntheticsMonitorType, syntheticsParamType } from '../../common/types/saved_objects'; @@ -46,6 +46,7 @@ import { formatMonitorConfigFields, mixParamsWithGlobalParams, } from './formatters/public_formatters/format_configs'; +import { dropInlineScriptForTransmission } from './utils/map_inline_to_project_fields'; const SYNTHETICS_SERVICE_SYNC_MONITORS_TASK_TYPE = 'UPTIME:SyntheticsService:Sync-Saved-Monitor-Objects'; @@ -420,7 +421,7 @@ export class SyntheticsService { ); const syncErrors = await this.apiClient.syncMonitors({ - monitors: locMonitors, + monitors: locMonitors.map(dropInlineScriptForTransmission), output, license, location, diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/utils/map_inline_to_project_fields.test.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/utils/map_inline_to_project_fields.test.ts new file mode 100644 index 0000000000000..f25668606e47d --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/utils/map_inline_to_project_fields.test.ts @@ -0,0 +1,147 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import { ConfigKey, MonitorFields } from '../../../common/runtime_types'; +import { unzipFile } from '../../common/unzip_project_code'; +import { + dropInlineScriptForTransmission, + mapInlineToProjectFields, +} from './map_inline_to_project_fields'; +import * as inlineToZip from '../../common/inline_to_zip'; + +describe('dropInlineScriptForTransmission', () => { + it('omits the inline script if there is project content available', () => { + expect( + dropInlineScriptForTransmission({ + [ConfigKey.SOURCE_INLINE]: 'this monitor has project content', + [ConfigKey.SOURCE_PROJECT_CONTENT]: 'so we should remove the inline script', + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', + } as MonitorFields) + ).toEqual({ + [ConfigKey.SOURCE_PROJECT_CONTENT]: 'so we should remove the inline script', + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', + }); + }); + + it('returns the original monitor if there is no project content', () => { + expect( + dropInlineScriptForTransmission({ + [ConfigKey.SOURCE_INLINE]: 'this is an old monitor that has no zip content', + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', + } as MonitorFields) + ).toEqual({ + [ConfigKey.SOURCE_INLINE]: 'this is an old monitor that has no zip content', + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', + }); + }); + + it('returns the original monitor if it is not of type `browser`', () => { + expect( + dropInlineScriptForTransmission({ + [ConfigKey.MONITOR_TYPE]: 'http', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', + } as MonitorFields) + ).toEqual({ + [ConfigKey.MONITOR_TYPE]: 'http', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'ui', + }); + }); + + it('returns the original monitor if it is not a UI monitor', () => { + expect( + dropInlineScriptForTransmission({ + [ConfigKey.SOURCE_PROJECT_CONTENT]: 'so we should remove the inline script', + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'project', + } as MonitorFields) + ).toEqual({ + [ConfigKey.SOURCE_PROJECT_CONTENT]: 'so we should remove the inline script', + [ConfigKey.MONITOR_TYPE]: 'browser', + [ConfigKey.MONITOR_SOURCE_TYPE]: 'project', + }); + }); +}); + +describe('mapInlineToProjectFields', () => { + let logger: Logger; + + beforeEach(() => { + logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as Logger; + }); + + it.each(['http', 'tcp', 'icmp'])( + 'should return an empty object if the monitor type is not browser', + async (monitorType) => { + const result = await mapInlineToProjectFields({ + monitorType, + monitor: {}, + logger: logger as any, + }); + expect(result).toEqual({}); + } + ); + + it('should return an empty object if the inline script is empty', async () => { + const result = await mapInlineToProjectFields({ + monitorType: 'browser', + monitor: { [ConfigKey.SOURCE_PROJECT_CONTENT]: 'foo' }, + logger: logger as any, + }); + expect(result).toEqual({}); + }); + + it.each([true, false])( + 'should zip the source inline and return it as project content', + async (includeInlineScript) => { + const expectedInlineScript = `step('goto', () => page.goto('https://elastic.co'))`; + const result = await mapInlineToProjectFields({ + monitorType: 'browser', + monitor: { + [ConfigKey.SOURCE_INLINE]: expectedInlineScript, + }, + logger: logger as any, + includeInlineScript, + }); + expect(result[ConfigKey.SOURCE_INLINE]).toEqual( + includeInlineScript ? expectedInlineScript : undefined + ); + expect(await unzipFile(result[ConfigKey.SOURCE_PROJECT_CONTENT] ?? '')) + .toMatchInlineSnapshot(` + "import { journey, step, expect, mfa } from '@elastic/synthetics'; + + journey('inline', ({ page, context, browser, params, request }) => { + step('goto', () => page.goto('https://elastic.co')) + });" + `); + } + ); + + it('should return the inline script if the zipping fails', async () => { + jest.spyOn(inlineToZip, 'inlineToProjectZip').mockImplementationOnce(async () => { + throw new Error('Failed to zip'); + }); + + const result = await mapInlineToProjectFields({ + monitorType: 'browser', + monitor: { + [ConfigKey.SOURCE_INLINE]: 'foo', + }, + logger: logger as any, + }); + expect(result[ConfigKey.SOURCE_INLINE]).toEqual('foo'); + }); +}); diff --git a/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/utils/map_inline_to_project_fields.ts b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/utils/map_inline_to_project_fields.ts new file mode 100644 index 0000000000000..6cee9b137b7b0 --- /dev/null +++ b/x-pack/plugins/observability_solution/synthetics/server/synthetics_service/utils/map_inline_to_project_fields.ts @@ -0,0 +1,65 @@ +/* + * 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 { Logger } from '@kbn/logging'; +import { omit } from 'lodash'; +import { BrowserSimpleFields, ConfigKey, MonitorFields } from '../../../common/runtime_types'; +import { inlineToProjectZip } from '../../common/inline_to_zip'; + +interface MapInlineToProjectFieldsArgs { + monitorType: string; + monitor: unknown; + logger: Logger; + includeInlineScript?: boolean; +} + +export async function mapInlineToProjectFields({ + monitorType, + monitor, + logger, + includeInlineScript, +}: MapInlineToProjectFieldsArgs) { + if (monitorType !== 'browser' || !monitor) return {}; + const asBrowserMonitor = monitor as BrowserSimpleFields; + const inlineScript = asBrowserMonitor?.[ConfigKey.SOURCE_INLINE]; + if (!inlineScript) return {}; + try { + const projectZip = await inlineToProjectZip( + inlineScript, + asBrowserMonitor?.[ConfigKey.CONFIG_ID], + logger + ); + if (includeInlineScript) + return { + [ConfigKey.SOURCE_INLINE]: inlineScript, + [ConfigKey.SOURCE_PROJECT_CONTENT]: projectZip, + }; + return { + [ConfigKey.SOURCE_PROJECT_CONTENT]: projectZip, + }; + } catch (e) { + logger.error(e); + } + + return { + [ConfigKey.SOURCE_INLINE]: inlineScript, + }; +} + +/** + * We don't transmit the inline script in `source.inline.script` field anymore, because JS parsing on the backend + * can break on certain characters. The procedure for handling project content (the default behavior in the absence + * of the inline script data) is not susceptible to this issue. + * + * See https://github.com/elastic/kibana/issues/169963 for more information. + */ +export const dropInlineScriptForTransmission = (monitor: MonitorFields): MonitorFields => { + if ((monitor as MonitorFields)[ConfigKey.SOURCE_PROJECT_CONTENT]) { + return omit(monitor, ConfigKey.SOURCE_INLINE) as MonitorFields; + } + return monitor; +}; diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts index 082d1aebd6d76..a33ad2ff07473 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_public_api.ts @@ -9,8 +9,11 @@ import { v4 as uuidv4 } from 'uuid'; import { DEFAULT_FIELDS } from '@kbn/synthetics-plugin/common/constants/monitor_defaults'; import { LOCATION_REQUIRED_ERROR } from '@kbn/synthetics-plugin/server/routes/monitor_cruds/monitor_validation'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { ConfigKey } from '@kbn/synthetics-plugin/common/runtime_types'; +import { omit } from 'lodash'; +import { unzipFile } from '@kbn/synthetics-plugin/server/common/unzip_project_code'; import { addMonitorAPIHelper, omitMonitorKeys } from './add_monitor'; +import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { describe('AddNewMonitorsPublicAPI', function () { @@ -253,13 +256,17 @@ export default function ({ getService }: FtrProviderContext) { }; const { body: result } = await addMonitorAPI(monitor); - expect(result).eql( + expect(omit(result, ConfigKey.SOURCE_PROJECT_CONTENT)).eql( omitMonitorKeys({ ...defaultFields, ...monitor, locations: [localLoc], }) ); + expect(result[ConfigKey.SOURCE_PROJECT_CONTENT]).to.be.a('string'); + expect(await unzipFile(result[ConfigKey.SOURCE_PROJECT_CONTENT])).to.contain( + monitor[ConfigKey.SOURCE_INLINE] + ); }); it('base browser monitor with inline_script', async () => { @@ -271,13 +278,17 @@ export default function ({ getService }: FtrProviderContext) { }; const { body: result } = await addMonitorAPI(monitor); - expect(result).eql( + expect(omit(result, ConfigKey.SOURCE_PROJECT_CONTENT)).eql( omitMonitorKeys({ ...defaultFields, ...monitor, locations: [localLoc], }) ); + expect(result[ConfigKey.SOURCE_PROJECT_CONTENT]).to.be.a('string'); + expect(await unzipFile(result[ConfigKey.SOURCE_PROJECT_CONTENT])).to.contain( + monitor.inline_script + ); }); }); });