diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts index aac36f3e4f573..45b26226def8f 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/advanced_job_creator.ts @@ -32,6 +32,7 @@ export interface RichDetector { excludeFrequent: estypes.ExcludeFrequent | null; description: string | null; customRules: CustomRule[] | null; + useNull: boolean | null; } export class AdvancedJobCreator extends JobCreator { @@ -58,7 +59,8 @@ export class AdvancedJobCreator extends JobCreator { overField: SplitField, partitionField: SplitField, excludeFrequent: estypes.ExcludeFrequent | null, - description: string | null + description: string | null, + useNull: boolean | null ) { // addDetector doesn't support adding new custom rules. // this will be added in the future once it's supported in the UI @@ -71,7 +73,8 @@ export class AdvancedJobCreator extends JobCreator { partitionField, excludeFrequent, description, - customRules + customRules, + useNull ); this._addDetector(detector, agg, field); @@ -86,7 +89,8 @@ export class AdvancedJobCreator extends JobCreator { partitionField: SplitField, excludeFrequent: estypes.ExcludeFrequent | null, description: string | null, - index: number + index: number, + useNull: boolean | null ) { const customRules = this._detectors[index] !== undefined ? this._detectors[index].custom_rules || null : null; @@ -99,7 +103,8 @@ export class AdvancedJobCreator extends JobCreator { partitionField, excludeFrequent, description, - customRules + customRules, + useNull ); this._editDetector(detector, agg, field, index); @@ -117,7 +122,8 @@ export class AdvancedJobCreator extends JobCreator { partitionField: SplitField, excludeFrequent: estypes.ExcludeFrequent | null, description: string | null, - customRules: CustomRule[] | null + customRules: CustomRule[] | null, + useNull: boolean | null ): { detector: Detector; richDetector: RichDetector } { const detector: Detector = createBasicDetector(agg, field); @@ -139,6 +145,9 @@ export class AdvancedJobCreator extends JobCreator { if (customRules !== null) { detector.custom_rules = customRules; } + if (useNull !== null) { + detector.use_null = useNull; + } const richDetector: RichDetector = { agg, @@ -149,6 +158,7 @@ export class AdvancedJobCreator extends JobCreator { excludeFrequent, description, customRules, + useNull, }; return { detector, richDetector }; @@ -209,7 +219,8 @@ export class AdvancedJobCreator extends JobCreator { dtr.overField, dtr.partitionField, dtr.excludeFrequent, - dtr.description + dtr.description, + dtr.useNull ); } }); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts index 3f306f9bcc996..bab6800c08335 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/util/general.ts @@ -79,8 +79,9 @@ export function getRichDetectors( byField, overField, partitionField, - excludeFrequent: d.exclude_frequent || null, - description: d.detector_description || null, + excludeFrequent: d.exclude_frequent ?? null, + description: d.detector_description ?? null, + useNull: d.use_null ?? null, }; }); } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx index d3108eef04983..2b1a35bcb8c46 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_detector_modal/advanced_detector_modal.tsx @@ -177,6 +177,7 @@ export const AdvancedDetectorModal: FC = ({ : null, description: descriptionOption !== '' ? descriptionOption : null, customRules: null, + useNull: null, }; setDetector(dtr); setDescriptionPlaceholder(dtr); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_view/metric_selection.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_view/metric_selection.tsx index b4508af7803dd..8f53e1283faa0 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_view/metric_selection.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/advanced_view/metric_selection.tsx @@ -29,6 +29,7 @@ const emptyRichDetector: RichDetector = { excludeFrequent: null, description: null, customRules: null, + useNull: null, }; export const AdvancedDetectors: FC = ({ setIsValid }) => { @@ -51,7 +52,8 @@ export const AdvancedDetectors: FC = ({ setIsValid }) => { dtr.overField, dtr.partitionField, dtr.excludeFrequent, - dtr.description + dtr.description, + dtr.useNull ); } else { jobCreator.editDetector( @@ -62,7 +64,8 @@ export const AdvancedDetectors: FC = ({ setIsValid }) => { dtr.partitionField, dtr.excludeFrequent, dtr.description, - index + index, + dtr.useNull ); } jobCreatorUpdate(); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/combo_box.tsx b/x-pack/plugins/uptime/public/components/fleet_package/combo_box.tsx index 12ee154dbcac4..7a1df79b0a59a 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/combo_box.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/combo_box.tsx @@ -13,7 +13,7 @@ export interface Props { selectedOptions: string[]; } -export const ComboBox = ({ onChange, selectedOptions }: Props) => { +export const ComboBox = ({ onChange, selectedOptions, ...props }: Props) => { const [formattedSelectedOptions, setSelectedOptions] = useState< Array> >(selectedOptions.map((option) => ({ label: option, key: option }))); @@ -66,6 +66,7 @@ export const ComboBox = ({ onChange, selectedOptions }: Props) => { onChange={onOptionsChange} onSearchChange={onSearchChange} isInvalid={isInvalid} + {...props} /> ); }; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx index 0bc2fc8823cec..e6703a6eaa97c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx @@ -76,6 +76,7 @@ export const CustomFields = memo( defaultMessage="Configure your monitor with the following options." /> } + data-test-subj="monitorSettingsSection" > @@ -104,6 +105,7 @@ export const CustomFields = memo( configKey: ConfigKeys.MONITOR_TYPE, }) } + data-test-subj="syntheticsMonitorTypeField" /> )} @@ -128,6 +130,7 @@ export const CustomFields = memo( onChange={(event) => handleInputChange({ value: event.target.value, configKey: ConfigKeys.URLS }) } + data-test-subj="syntheticsUrlField" /> )} @@ -155,6 +158,7 @@ export const CustomFields = memo( configKey: ConfigKeys.HOSTS, }) } + data-test-subj="syntheticsTCPHostField" /> )} @@ -182,6 +186,7 @@ export const CustomFields = memo( configKey: ConfigKeys.HOSTS, }) } + data-test-subj="syntheticsICMPHostField" /> )} @@ -268,6 +273,7 @@ export const CustomFields = memo( configKey: ConfigKeys.APM_SERVICE_NAME, }) } + data-test-subj="syntheticsAPMServiceName" /> {isHTTP && ( @@ -364,6 +370,7 @@ export const CustomFields = memo( handleInputChange({ value, configKey: ConfigKeys.TAGS })} + data-test-subj="syntheticsTags" /> @@ -385,6 +392,7 @@ export const CustomFields = memo( defaultMessage="Configure TLS options, including verification mode, certificate authorities, and client certificates." /> } + data-test-subj="syntheticsIsTLSEnabled" > (({ validate }) => { defaultMessage="Advanced HTTP options" /> } + data-test-subj="syntheticsHTTPAdvancedFieldsAccordion" > (({ validate }) => { defaultMessage="Configure an optional request to send to the remote host including method, body, and headers." /> } + data-test-subj="httpAdvancedFieldsSection" > (({ validate }) => { configKey: ConfigKeys.USERNAME, }) } + data-test-subj="syntheticsUsername" /> (({ validate }) => { configKey: ConfigKeys.PASSWORD, }) } + data-test-subj="syntheticsPassword" /> (({ validate }) => { configKey: ConfigKeys.PROXY_URL, }) } + data-test-subj="syntheticsProxyUrl" /> (({ validate }) => { configKey: ConfigKeys.REQUEST_METHOD_CHECK, }) } + data-test-subj="syntheticsRequestMethod" /> (({ validate }) => { defaultMessage="A dictionary of additional HTTP headers to send. By default the client will set the User-Agent header to identify itself." /> } + data-test-subj="syntheticsRequestHeaders" > (({ validate }) => { http.response.body.headers } + data-test-subj="syntheticsIndexResponseHeaders" > (({ validate }) => { configKey: ConfigKeys.RESPONSE_STATUS_CHECK, }) } + data-test-subj="syntheticsResponseStatusCheck" /> (({ validate }) => { defaultMessage="A list of expected response headers." /> } + data-test-subj="syntheticsResponseHeaders" > (({ validate }) => { }), [handleInputChange] )} + data-test-subj="syntheticsResponseBodyCheckPositive" /> (({ validate }) => { }), [handleInputChange] )} + data-test-subj="syntheticsResponseBodyCheckNegative" /> diff --git a/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.tsx index a82e7a0938078..fc53b275f0828 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/index_response_body_field.tsx @@ -38,7 +38,7 @@ export const ResponseBodyIndexField = ({ defaultValue, onChange }: Props) => { return ( - + { { id: Mode.TEXT, name: modeLabels[Mode.TEXT], + 'data-test-subj': `syntheticsRequestBodyTab__${Mode.TEXT}`, content: ( { { id: Mode.JSON, name: modeLabels[Mode.JSON], + 'data-test-subj': `syntheticsRequestBodyTab__${Mode.JSON}`, content: ( { { id: Mode.XML, name: modeLabels[Mode.XML], + 'data-test-subj': `syntheticsRequestBodyTab__${Mode.XML}`, content: ( { { id: Mode.FORM, name: modeLabels[Mode.FORM], + 'data-test-subj': `syntheticsRequestBodyTab__${Mode.FORM}`, content: ( { ); return ( - + { configKey: ConfigKeys.PROXY_URL, }) } + data-test-subj="syntheticsProxyUrl" /> {!!fields[ConfigKeys.PROXY_URL] && ( - + { }), [handleInputChange] )} + data-test-subj="syntheticsTCPRequestSendCheck" /> @@ -166,6 +172,7 @@ export const TCPAdvancedFields = () => { }), [handleInputChange] )} + data-test-subj="syntheticsTCPResponseReceiveCheck" /> diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.tsx index de8879ec3a819..a2db0d99088f7 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/tls_fields.tsx @@ -139,6 +139,7 @@ export const TLSFields: React.FunctionComponent<{ }, })); }} + data-test-subj="syntheticsTLSVerificationMode" /> {fields[ConfigKeys.TLS_VERIFICATION_MODE].value === VerificationMode.NONE && ( @@ -229,6 +230,7 @@ export const TLSFields: React.FunctionComponent<{ }, })); }} + data-test-subj="syntheticsTLSCA" /> diff --git a/x-pack/test/functional/apps/uptime/index.ts b/x-pack/test/functional/apps/uptime/index.ts index f96d2d0255d2a..0b02fd2bf322b 100644 --- a/x-pack/test/functional/apps/uptime/index.ts +++ b/x-pack/test/functional/apps/uptime/index.ts @@ -59,6 +59,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => { loadTestFile(require.resolve('./locations')); loadTestFile(require.resolve('./settings')); loadTestFile(require.resolve('./certificates')); + loadTestFile(require.resolve('./synthetics_integration')); }); describe('with generated data but no data reset', () => { diff --git a/x-pack/test/functional/apps/uptime/synthetics_integration.ts b/x-pack/test/functional/apps/uptime/synthetics_integration.ts new file mode 100644 index 0000000000000..52ec81b8bf7db --- /dev/null +++ b/x-pack/test/functional/apps/uptime/synthetics_integration.ts @@ -0,0 +1,442 @@ +/* + * 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'; +import { FullAgentPolicy } from '../../../../plugins/fleet/common'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const monitorName = 'Sample Synthetics integration'; + + const uptimePage = getPageObjects(['syntheticsIntegration']); + const testSubjects = getService('testSubjects'); + const uptimeService = getService('uptime'); + + const generatePolicy = ({ + agentFullPolicy, + version, + monitorType, + name, + config, + }: { + agentFullPolicy: FullAgentPolicy; + version: string; + monitorType: string; + name: string; + config: Record; + }) => ({ + data_stream: { + namespace: 'default', + }, + id: agentFullPolicy.inputs[0].id, + meta: { + package: { + name: 'synthetics', + version, + }, + }, + name, + revision: 1, + streams: [ + { + data_stream: { + dataset: monitorType, + type: 'synthetics', + }, + id: `${agentFullPolicy.inputs[0]?.streams?.[0]?.id}`, + name, + type: monitorType, + processors: [ + { + add_observer_metadata: { + geo: { + name: 'Fleet managed', + }, + }, + }, + { + add_fields: { + fields: { + 'monitor.fleet_managed': true, + }, + target: '', + }, + }, + ], + ...config, + }, + ], + type: `synthetics/${monitorType}`, + use_output: 'default', + }); + + describe('When on the Synthetics Integration Policy Create Page', function () { + this.tags(['ciGroup6']); + const basicConfig = { + name: monitorName, + apmServiceName: 'Sample APM Service', + tags: 'sample tag', + }; + + const generateHTTPConfig = (url: string) => ({ + ...basicConfig, + url, + }); + + const generateTCPorICMPConfig = (host: string) => ({ + ...basicConfig, + host, + }); + + describe('displays custom UI', () => { + before(async () => { + const version = await uptimeService.syntheticsPackage.getSyntheticsPackageVersion(); + await uptimePage.syntheticsIntegration.navigateToPackagePage(version!); + }); + + it('should display policy view', async () => { + await uptimePage.syntheticsIntegration.ensureIsOnPackagePage(); + }); + + it('prevent saving when integration name, url/host, or schedule is missing', async () => { + const saveButton = await uptimePage.syntheticsIntegration.findSaveButton(); + await saveButton.click(); + + await testSubjects.missingOrFail('packagePolicyCreateSuccessToast'); + }); + }); + + describe('create new policy', () => { + let version: string; + before(async () => { + await uptimeService.syntheticsPackage.deletePolicyByName('system-1'); + }); + + beforeEach(async () => { + version = (await uptimeService.syntheticsPackage.getSyntheticsPackageVersion())!; + await uptimePage.syntheticsIntegration.navigateToPackagePage(version!); + await uptimeService.syntheticsPackage.deletePolicyByName(monitorName); + }); + + afterEach(async () => { + await uptimeService.syntheticsPackage.deletePolicyByName(monitorName); + }); + + it('allows saving when user enters a valid integration name and url/host', async () => { + // This test ensures that updates made to the Synthetics Policy are carried all the way through + // to the generated Agent Policy that is dispatch down to the Elastic Agent. + const config = generateHTTPConfig('http://elastic.co'); + await uptimePage.syntheticsIntegration.createBasicHTTPMonitorDetails(config); + await uptimePage.syntheticsIntegration.confirmAndSave(); + + await uptimePage.syntheticsIntegration.isPolicyCreatedSuccessfully(); + + const [agentPolicy] = await uptimeService.syntheticsPackage.getAgentPolicyList(); + const agentPolicyId = agentPolicy.id; + const agentFullPolicy = await uptimeService.syntheticsPackage.getFullAgentPolicy( + agentPolicyId + ); + + expect(agentFullPolicy.inputs).to.eql([ + generatePolicy({ + agentFullPolicy, + version, + name: monitorName, + monitorType: 'http', + config: { + max_redirects: 0, + 'response.include_body': 'on_error', + 'response.include_headers': true, + schedule: '@every 3m', + timeout: '16s', + urls: config.url, + 'service.name': config.apmServiceName, + tags: [config.tags], + 'check.request.method': 'GET', + }, + }), + ]); + }); + + it('allows enabling tls with defaults', async () => { + // This test ensures that updates made to the Synthetics Policy are carried all the way through + // to the generated Agent Policy that is dispatch down to the Elastic Agent. + const config = generateHTTPConfig('http://elastic.co'); + + await uptimePage.syntheticsIntegration.createBasicHTTPMonitorDetails(config); + await uptimePage.syntheticsIntegration.enableTLS(); + await uptimePage.syntheticsIntegration.confirmAndSave(); + + await uptimePage.syntheticsIntegration.isPolicyCreatedSuccessfully(); + + const [agentPolicy] = await uptimeService.syntheticsPackage.getAgentPolicyList(); + const agentPolicyId = agentPolicy.id; + const agentFullPolicy = await uptimeService.syntheticsPackage.getFullAgentPolicy( + agentPolicyId + ); + + expect(agentFullPolicy.inputs).to.eql([ + generatePolicy({ + agentFullPolicy, + version, + name: monitorName, + monitorType: 'http', + config: { + max_redirects: 0, + 'check.request.method': 'GET', + 'response.include_body': 'on_error', + 'response.include_headers': true, + schedule: '@every 3m', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + 'ssl.verification_mode': 'full', + timeout: '16s', + urls: config.url, + 'service.name': config.apmServiceName, + tags: [config.tags], + }, + }), + ]); + }); + + it('allows configuring tls', async () => { + // This test ensures that updates made to the Synthetics Policy are carried all the way through + // to the generated Agent Policy that is dispatch down to the Elastic Agent. + const config = generateHTTPConfig('http://elastic.co'); + + const tlsConfig = { + verificationMode: 'strict', + ca: 'ca', + cert: 'cert', + certKey: 'certKey', + certKeyPassphrase: 'certKeyPassphrase', + }; + await uptimePage.syntheticsIntegration.createBasicHTTPMonitorDetails(config); + await uptimePage.syntheticsIntegration.configureTLSOptions(tlsConfig); + await uptimePage.syntheticsIntegration.confirmAndSave(); + + await uptimePage.syntheticsIntegration.isPolicyCreatedSuccessfully(); + + const [agentPolicy] = await uptimeService.syntheticsPackage.getAgentPolicyList(); + const agentPolicyId = agentPolicy.id; + const agentFullPolicy = await uptimeService.syntheticsPackage.getFullAgentPolicy( + agentPolicyId + ); + + expect(agentFullPolicy.inputs).to.eql([ + generatePolicy({ + agentFullPolicy, + version, + name: monitorName, + monitorType: 'http', + config: { + max_redirects: 0, + 'check.request.method': 'GET', + 'response.include_body': 'on_error', + 'response.include_headers': true, + schedule: '@every 3m', + 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], + 'ssl.verification_mode': tlsConfig.verificationMode, + 'ssl.certificate': tlsConfig.cert, + 'ssl.certificate_authorities': tlsConfig.ca, + 'ssl.key': tlsConfig.certKey, + 'ssl.key_passphrase': tlsConfig.certKeyPassphrase, + timeout: '16s', + urls: config.url, + 'service.name': config.apmServiceName, + tags: [config.tags], + }, + }), + ]); + }); + + it('allows configuring http advanced options', async () => { + // This test ensures that updates made to the Synthetics Policy are carried all the way through + // to the generated Agent Policy that is dispatch down to the Elastic Agent. + const config = generateHTTPConfig('http://elastic.co'); + + await uptimePage.syntheticsIntegration.createBasicHTTPMonitorDetails(config); + const advancedConfig = { + username: 'username', + password: 'password', + proxyUrl: 'proxyUrl', + requestMethod: 'POST', + responseStatusCheck: '204', + responseBodyCheckPositive: 'success', + responseBodyCheckNegative: 'failure', + requestHeaders: { + sampleRequestHeader1: 'sampleRequestKey1', + sampleRequestHeader2: 'sampleRequestKey2', + }, + responseHeaders: { + sampleResponseHeader1: 'sampleResponseKey1', + sampleResponseHeader2: 'sampleResponseKey2', + }, + requestBody: { + type: 'xml', + value: 'samplexml', + }, + indexResponseBody: false, + indexResponseHeaders: false, + }; + await uptimePage.syntheticsIntegration.configureHTTPAdvancedOptions(advancedConfig); + await uptimePage.syntheticsIntegration.confirmAndSave(); + + await uptimePage.syntheticsIntegration.isPolicyCreatedSuccessfully(); + + const [agentPolicy] = await uptimeService.syntheticsPackage.getAgentPolicyList(); + const agentPolicyId = agentPolicy.id; + const agentFullPolicy = await uptimeService.syntheticsPackage.getFullAgentPolicy( + agentPolicyId + ); + + expect(agentFullPolicy.inputs).to.eql([ + generatePolicy({ + agentFullPolicy, + version, + name: monitorName, + monitorType: 'http', + config: { + max_redirects: 0, + 'check.request.method': advancedConfig.requestMethod, + 'check.request.headers': { + 'Content-Type': 'application/xml', + ...advancedConfig.requestHeaders, + }, + 'check.response.headers': advancedConfig.responseHeaders, + 'check.response.status': [advancedConfig.responseStatusCheck], + 'check.request.body': `${advancedConfig.requestBody.value}`, // code editor adds closing tag + 'check.response.body.positive': [advancedConfig.responseBodyCheckPositive], + 'check.response.body.negative': [advancedConfig.responseBodyCheckNegative], + 'response.include_body': advancedConfig.indexResponseBody ? 'on_error' : 'never', + 'response.include_headers': advancedConfig.indexResponseHeaders, + schedule: '@every 3m', + timeout: '16s', + urls: config.url, + proxy_url: advancedConfig.proxyUrl, + username: advancedConfig.username, + password: advancedConfig.password, + 'service.name': config.apmServiceName, + tags: [config.tags], + }, + }), + ]); + }); + + it('allows saving tcp monitor when user enters a valid integration name and host+port', async () => { + // This test ensures that updates made to the Synthetics Policy are carried all the way through + // to the generated Agent Policy that is dispatch down to the Elastic Agent. + const config = generateTCPorICMPConfig('smtp.gmail.com:587'); + + await uptimePage.syntheticsIntegration.createBasicTCPMonitorDetails(config); + await uptimePage.syntheticsIntegration.confirmAndSave(); + + await uptimePage.syntheticsIntegration.isPolicyCreatedSuccessfully(); + + const [agentPolicy] = await uptimeService.syntheticsPackage.getAgentPolicyList(); + const agentPolicyId = agentPolicy.id; + const agentFullPolicy = await uptimeService.syntheticsPackage.getFullAgentPolicy( + agentPolicyId + ); + + expect(agentFullPolicy.inputs).to.eql([ + generatePolicy({ + agentFullPolicy, + version, + name: monitorName, + monitorType: 'tcp', + config: { + proxy_use_local_resolver: false, + schedule: '@every 3m', + timeout: '16s', + hosts: config.host, + tags: [config.tags], + 'service.name': config.apmServiceName, + }, + }), + ]); + }); + + it('allows configuring tcp advanced options', async () => { + // This test ensures that updates made to the Synthetics Policy are carried all the way through + // to the generated Agent Policy that is dispatch down to the Elastic Agent. + const config = generateTCPorICMPConfig('smtp.gmail.com:587'); + + await uptimePage.syntheticsIntegration.createBasicTCPMonitorDetails(config); + const advancedConfig = { + proxyUrl: 'proxyUrl', + requestSendCheck: 'body', + responseReceiveCheck: 'success', + proxyUseLocalResolver: true, + }; + await uptimePage.syntheticsIntegration.configureTCPAdvancedOptions(advancedConfig); + await uptimePage.syntheticsIntegration.confirmAndSave(); + + await uptimePage.syntheticsIntegration.isPolicyCreatedSuccessfully(); + + const [agentPolicy] = await uptimeService.syntheticsPackage.getAgentPolicyList(); + const agentPolicyId = agentPolicy.id; + const agentFullPolicy = await uptimeService.syntheticsPackage.getFullAgentPolicy( + agentPolicyId + ); + + expect(agentFullPolicy.inputs).to.eql([ + generatePolicy({ + agentFullPolicy, + version, + name: monitorName, + monitorType: 'tcp', + config: { + schedule: '@every 3m', + timeout: '16s', + hosts: config.host, + proxy_url: advancedConfig.proxyUrl, + proxy_use_local_resolver: advancedConfig.proxyUseLocalResolver, + 'check.receive': advancedConfig.responseReceiveCheck, + 'check.send': advancedConfig.requestSendCheck, + 'service.name': config.apmServiceName, + tags: [config.tags], + }, + }), + ]); + }); + + it('allows saving icmp monitor when user enters a valid integration name and host', async () => { + // This test ensures that updates made to the Synthetics Policy are carried all the way through + // to the generated Agent Policy that is dispatch down to the Elastic Agent. + const config = generateTCPorICMPConfig('1.1.1.1'); + + await uptimePage.syntheticsIntegration.createBasicICMPMonitorDetails(config); + await uptimePage.syntheticsIntegration.confirmAndSave(); + + await uptimePage.syntheticsIntegration.isPolicyCreatedSuccessfully(); + + const [agentPolicy] = await uptimeService.syntheticsPackage.getAgentPolicyList(); + const agentPolicyId = agentPolicy.id; + const agentFullPolicy = await uptimeService.syntheticsPackage.getFullAgentPolicy( + agentPolicyId + ); + + expect(agentFullPolicy.inputs).to.eql([ + generatePolicy({ + agentFullPolicy, + version, + name: monitorName, + monitorType: 'icmp', + config: { + schedule: '@every 3m', + timeout: '16s', + wait: '1s', + hosts: config.host, + 'service.name': config.apmServiceName, + tags: [config.tags], + }, + }), + ]); + }); + }); + }); +} diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index f171e247472f1..573350dad24d0 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -154,6 +154,9 @@ export default async function ({ readConfigFile }) { uptime: { pathname: '/app/uptime', }, + fleet: { + pathname: '/app/fleet', + }, ml: { pathname: '/app/ml', }, diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 81c0328e76342..e83420a9cea1d 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -24,6 +24,7 @@ import { StatusPagePageProvider } from './status_page'; import { UpgradeAssistantPageProvider } from './upgrade_assistant_page'; import { RollupPageProvider } from './rollup_page'; import { UptimePageProvider } from './uptime_page'; +import { SyntheticsIntegrationPageProvider } from './synthetics_integration_page'; import { ApiKeysPageProvider } from './api_keys_page'; import { LicenseManagementPageProvider } from './license_management_page'; import { IndexManagementPageProvider } from './index_management_page'; @@ -64,6 +65,7 @@ export const pageObjects = { statusPage: StatusPagePageProvider, upgradeAssistant: UpgradeAssistantPageProvider, uptime: UptimePageProvider, + syntheticsIntegration: SyntheticsIntegrationPageProvider, rollup: RollupPageProvider, apiKeys: ApiKeysPageProvider, licenseManagement: LicenseManagementPageProvider, diff --git a/x-pack/test/functional/page_objects/synthetics_integration_page.ts b/x-pack/test/functional/page_objects/synthetics_integration_page.ts new file mode 100644 index 0000000000000..69ae3f43d26f2 --- /dev/null +++ b/x-pack/test/functional/page_objects/synthetics_integration_page.ts @@ -0,0 +1,384 @@ +/* + * 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 { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export function SyntheticsIntegrationPageProvider({ + getService, + getPageObjects, +}: FtrProviderContext) { + const pageObjects = getPageObjects(['common', 'header']); + const testSubjects = getService('testSubjects'); + const comboBox = getService('comboBox'); + + return { + /** + * Navigates to the Synthetics Integration page + * + */ + async navigateToPackagePage(packageVersion: string) { + await pageObjects.common.navigateToUrl( + 'fleet', + `/integrations/synthetics-${packageVersion}/add-integration`, + { + shouldUseHashForSubUrl: true, + useActualUrl: true, + } + ); + await pageObjects.header.waitUntilLoadingHasFinished(); + }, + + async navigateToPackageEditPage(packageId: string, agentId: string) { + await pageObjects.common.navigateToUrl( + 'fleet', + `/policies/${agentId}/edit-integration/${packageId}`, + { + shouldUseHashForSubUrl: true, + useActualUrl: true, + } + ); + await pageObjects.header.waitUntilLoadingHasFinished(); + }, + + /** + * Finds and returns the Policy Details Page Save button + */ + async findSaveButton(isEditPage?: boolean) { + await this.ensureIsOnPackagePage(); + return await testSubjects.find( + isEditPage ? 'saveIntegration' : 'createPackagePolicySaveButton' + ); + }, + + /** + * Finds and returns the Policy Details Page Cancel Button + */ + async findCancelButton() { + await this.ensureIsOnPackagePage(); + return await testSubjects.find('policyDetailsCancelButton'); + }, + + /** + * Determines if the policy was created successfully by looking for the creation success toast + */ + async isPolicyCreatedSuccessfully() { + await testSubjects.existOrFail('packagePolicyCreateSuccessToast'); + }, + + /** + * Selects the monitor type + * @params {monitorType} the type of monitor, tcp, http, or icmp + */ + async selectMonitorType(monitorType: string) { + await testSubjects.selectValue('syntheticsMonitorTypeField', monitorType); + }, + + /** + * Fills a text input + * @params {testSubj} the testSubj of the input to fill + * @params {value} the value of the input + */ + async fillTextInputByTestSubj(testSubj: string, value: string) { + const field = await testSubjects.find(testSubj, 5000); + await field.click(); + await field.clearValue(); + await field.type(value); + }, + + /** + * Fills a text input + * @params {testSubj} the testSubj of the input to fill + * @params {value} the value of the input + */ + async fillTextInput(field: WebElementWrapper, value: string) { + await field.click(); + await field.clearValue(); + await field.type(value); + }, + + /** + * Fills a text input + * @params {testSubj} the testSubj of the comboBox + */ + async setComboBox(testSubj: string, value: string) { + await comboBox.setCustom(`${testSubj} > comboBoxInput`, value); + }, + + /** + * Finds and returns the HTTP advanced options accordion trigger + */ + async findHTTPAdvancedOptionsAccordion() { + await this.ensureIsOnPackagePage(); + const accordion = await testSubjects.find('syntheticsHTTPAdvancedFieldsAccordion', 5000); + return accordion; + }, + + /** + * Finds and returns the enable TLS checkbox + */ + async findEnableTLSCheckbox() { + await this.ensureIsOnPackagePage(); + const tlsCheckboxContainer = await testSubjects.find('syntheticsIsTLSEnabled'); + return await tlsCheckboxContainer.findByCssSelector('label'); + }, + + /** + * ensures that the package page is the currently display view + */ + async ensureIsOnPackagePage() { + await testSubjects.existOrFail('monitorSettingsSection'); + }, + + /** + * Clicks save button and confirms update on the Policy Details page + */ + async confirmAndSave(isEditPage?: boolean) { + await this.ensureIsOnPackagePage(); + const saveButton = await this.findSaveButton(isEditPage); + saveButton.click(); + }, + + /** + * Fills in the username and password field + * @params username {string} the value of the username + * @params password {string} the value of the password + */ + async configureUsernameAndPassword({ username, password }: Record) { + await this.fillTextInputByTestSubj('syntheticsUsername', username); + await this.fillTextInputByTestSubj('syntheticsPassword', password); + }, + + /** + * + * Configures request headers + * @params headers {string} an object containing desired headers + * + */ + async configureRequestHeaders(headers: Record) { + await this.configureHeaders('syntheticsRequestHeaders', headers); + }, + + /** + * + * Configures response headers + * @params headers {string} an object containing desired headers + * + */ + async configureResponseHeaders(headers: Record) { + await this.configureHeaders('syntheticsResponseHeaders', headers); + }, + + /** + * + * Configures headers + * @params testSubj {string} test subj + * @params headers {string} an object containing desired headers + * + */ + async configureHeaders(testSubj: string, headers: Record) { + const headersContainer = await testSubjects.find(testSubj); + const addHeaderButton = await headersContainer.findByCssSelector('button'); + const keys = Object.keys(headers); + + await Promise.all( + keys.map(async (key, index) => { + await addHeaderButton.click(); + const keyField = await headersContainer.findByCssSelector( + `[data-test-subj="keyValuePairsKey${index}"]` + ); + const valueField = await headersContainer.findByCssSelector( + `[data-test-subj="keyValuePairsValue${index}"]` + ); + await this.fillTextInput(keyField, key); + await this.fillTextInput(valueField, headers[key]); + }) + ); + }, + + /** + * + * Configures request body + * @params contentType {string} contentType of the request body + * @params value {string} value of the request body + * + */ + async configureRequestBody(testSubj: string, value: string) { + await testSubjects.click(`syntheticsRequestBodyTab__${testSubj}`); + const codeEditorContainer = await testSubjects.find('codeEditorContainer'); + const textArea = await codeEditorContainer.findByCssSelector('textarea'); + await textArea.clearValue(); + await textArea.type(value); + }, + + /** + * Creates basic common monitor details + * @params name {string} the name of the monitor + * @params url {string} the url of the monitor + * + */ + async createBasicMonitorDetails({ name, apmServiceName, tags }: Record) { + await this.fillTextInputByTestSubj('packagePolicyNameInput', name); + await this.fillTextInputByTestSubj('syntheticsAPMServiceName', apmServiceName); + await this.setComboBox('syntheticsTags', tags); + }, + + /** + * Fills in the fields to create a basic HTTP monitor + * @params name {string} the name of the monitor + * @params url {string} the url of the monitor + * + */ + async createBasicHTTPMonitorDetails({ + name, + url, + apmServiceName, + tags, + }: Record) { + await this.createBasicMonitorDetails({ name, apmServiceName, tags }); + await this.fillTextInputByTestSubj('syntheticsUrlField', url); + }, + + /** + * Fills in the fields to create a basic TCP monitor + * @params name {string} the name of the monitor + * @params host {string} the host (and port) of the monitor + * + */ + async createBasicTCPMonitorDetails({ + name, + host, + apmServiceName, + tags, + }: Record) { + await this.selectMonitorType('tcp'); + await this.createBasicMonitorDetails({ name, apmServiceName, tags }); + await this.fillTextInputByTestSubj('syntheticsTCPHostField', host); + }, + + /** + * Creates a basic ICMP monitor + * @params name {string} the name of the monitor + * @params host {string} the host of the monitor + */ + async createBasicICMPMonitorDetails({ + name, + host, + apmServiceName, + tags, + }: Record) { + await this.selectMonitorType('icmp'); + await this.fillTextInputByTestSubj('packagePolicyNameInput', name); + await this.createBasicMonitorDetails({ name, apmServiceName, tags }); + await this.fillTextInputByTestSubj('syntheticsICMPHostField', host); + }, + + /** + * Enables TLS + */ + async enableTLS() { + const tlsCheckbox = await this.findEnableTLSCheckbox(); + await tlsCheckbox.click(); + }, + + /** + * Configures TLS settings + * @params verificationMode {string} the name of the monitor + */ + async configureTLSOptions({ + verificationMode, + ca, + cert, + certKey, + certKeyPassphrase, + }: Record) { + await this.enableTLS(); + await testSubjects.selectValue('syntheticsTLSVerificationMode', verificationMode); + await this.fillTextInputByTestSubj('syntheticsTLSCA', ca); + await this.fillTextInputByTestSubj('syntheticsTLSCert', cert); + await this.fillTextInputByTestSubj('syntheticsTLSCertKey', certKey); + await this.fillTextInputByTestSubj('syntheticsTLSCertKeyPassphrase', certKeyPassphrase); + }, + + /** + * Configure http advanced settings + */ + async configureHTTPAdvancedOptions({ + username, + password, + proxyUrl, + requestMethod, + requestHeaders, + responseStatusCheck, + responseBodyCheckPositive, + responseBodyCheckNegative, + requestBody, + responseHeaders, + indexResponseBody, + indexResponseHeaders, + }: { + username: string; + password: string; + proxyUrl: string; + requestMethod: string; + responseStatusCheck: string; + responseBodyCheckPositive: string; + responseBodyCheckNegative: string; + requestBody: { value: string; type: string }; + requestHeaders: Record; + responseHeaders: Record; + indexResponseBody: boolean; + indexResponseHeaders: boolean; + }) { + await testSubjects.click('syntheticsHTTPAdvancedFieldsAccordion'); + await this.configureResponseHeaders(responseHeaders); + await this.configureRequestHeaders(requestHeaders); + await this.configureRequestBody(requestBody.type, requestBody.value); + await this.configureUsernameAndPassword({ username, password }); + await this.setComboBox('syntheticsResponseStatusCheck', responseStatusCheck); + await this.setComboBox('syntheticsResponseBodyCheckPositive', responseBodyCheckPositive); + await this.setComboBox('syntheticsResponseBodyCheckNegative', responseBodyCheckNegative); + await this.fillTextInputByTestSubj('syntheticsProxyUrl', proxyUrl); + await testSubjects.selectValue('syntheticsRequestMethod', requestMethod); + if (!indexResponseBody) { + const field = await testSubjects.find('syntheticsIndexResponseBody'); + const label = await field.findByCssSelector('label'); + await label.click(); + } + if (!indexResponseHeaders) { + const field = await testSubjects.find('syntheticsIndexResponseHeaders'); + const label = await field.findByCssSelector('label'); + await label.click(); + } + }, + + /** + * Configure tcp advanced settings + */ + async configureTCPAdvancedOptions({ + proxyUrl, + requestSendCheck, + responseReceiveCheck, + proxyUseLocalResolver, + }: { + proxyUrl: string; + requestSendCheck: string; + responseReceiveCheck: string; + proxyUseLocalResolver: boolean; + }) { + await testSubjects.click('syntheticsTCPAdvancedFieldsAccordion'); + await this.fillTextInputByTestSubj('syntheticsProxyUrl', proxyUrl); + await this.fillTextInputByTestSubj('syntheticsTCPRequestSendCheck', requestSendCheck); + await this.fillTextInputByTestSubj('syntheticsTCPResponseReceiveCheck', responseReceiveCheck); + if (proxyUseLocalResolver) { + const field = await testSubjects.find('syntheticsUseLocalResolver'); + const label = await field.findByCssSelector('label'); + await label.click(); + } + }, + }; +} diff --git a/x-pack/test/functional/services/uptime/synthetics_package.ts b/x-pack/test/functional/services/uptime/synthetics_package.ts new file mode 100644 index 0000000000000..78d0fcd61fde4 --- /dev/null +++ b/x-pack/test/functional/services/uptime/synthetics_package.ts @@ -0,0 +1,176 @@ +/* + * 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 { FtrProviderContext } from '../../ftr_provider_context'; +import { + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + DeletePackagePoliciesRequest, + GetPackagePoliciesResponse, + GetFullAgentPolicyResponse, + GetPackagesResponse, + GetAgentPoliciesResponse, +} from '../../../../plugins/fleet/common'; + +const INGEST_API_ROOT = '/api/fleet'; +const INGEST_API_AGENT_POLICIES = `${INGEST_API_ROOT}/agent_policies`; +const INGEST_API_PACKAGE_POLICIES = `${INGEST_API_ROOT}/package_policies`; +const INGEST_API_PACKAGE_POLICIES_DELETE = `${INGEST_API_PACKAGE_POLICIES}/delete`; +const INGEST_API_EPM_PACKAGES = `${INGEST_API_ROOT}/epm/packages`; + +export function SyntheticsPackageProvider({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const log = getService('log'); + const retry = getService('retry'); + + const logSupertestApiErrorAndThrow = (message: string, error: any): never => { + const responseBody = error?.response?.body; + const responseText = error?.response?.text; + log.error(`Error occurred at ${Date.now()} | ${new Date().toISOString()}`); + log.error(JSON.stringify(responseBody || responseText, null, 2)); + log.error(error); + throw new Error(message); + }; + const retrieveSyntheticsPackageInfo = (() => { + // Retrieve information about the Synthetics package + // EPM does not currently have an API to get the "lastest" information for a page given its name, + // so we'll retrieve a list of packages and then find the package info in the list. + let apiRequest: Promise; + + return () => { + if (!apiRequest) { + log.info(`Setting up call to retrieve Synthetics package`); + + // Currently (as of 2020-june) the package registry used in CI is the public one and + // at times it encounters network connection issues. We use `retry.try` below to see if + // subsequent requests get through. + apiRequest = retry.try(() => { + return supertest + .get(INGEST_API_EPM_PACKAGES) + .set('kbn-xsrf', 'xxx') + .expect(200) + .catch((error) => { + return logSupertestApiErrorAndThrow(`Unable to retrieve packages via Ingest!`, error); + }) + .then((response: { body: GetPackagesResponse }) => { + const { body } = response; + const syntheticsPackageInfo = body.response.find( + (epmPackage) => epmPackage.name === 'synthetics' + ); + if (!syntheticsPackageInfo) { + throw new Error( + `Synthetics package was not in response from ${INGEST_API_EPM_PACKAGES}` + ); + } + return Promise.resolve(syntheticsPackageInfo); + }); + }); + } else { + log.info('Using cached retrieval of synthetics package'); + } + return apiRequest; + }; + })(); + + return { + /** + * Returns the synthetics package version for the currently installed package. This version can then + * be used to build URLs for Fleet pages or APIs + */ + async getSyntheticsPackageVersion() { + const syntheticsPackage = await retrieveSyntheticsPackageInfo()!; + + return syntheticsPackage?.version; + }, + + /** + * Retrieves the full Agent policy by id, which mirrors what the Elastic Agent would get + * once they checkin. + */ + async getFullAgentPolicy(agentPolicyId: string): Promise { + let fullAgentPolicy: GetFullAgentPolicyResponse['item']; + try { + const apiResponse: { body: GetFullAgentPolicyResponse } = await supertest + .get(`${INGEST_API_AGENT_POLICIES}/${agentPolicyId}/full`) + .expect(200); + + fullAgentPolicy = apiResponse.body.item; + } catch (error) { + return logSupertestApiErrorAndThrow('Unable to get full Agent policy', error); + } + + return fullAgentPolicy!; + }, + + /** + * Retrieves all the agent policies. + */ + async getAgentPolicyList(): Promise { + let agentPolicyList: GetAgentPoliciesResponse['items']; + try { + const apiResponse: { body: GetAgentPoliciesResponse } = await supertest + .get(INGEST_API_AGENT_POLICIES) + .expect(200); + + agentPolicyList = apiResponse.body.items; + } catch (error) { + return logSupertestApiErrorAndThrow('Unable to get full Agent policy list', error); + } + + return agentPolicyList!; + }, + + /** + * Deletes a policy (Package Policy) by using the policy name + * @param name + */ + async deletePolicyByName(name: string) { + const id = await this.getPackagePolicyIdByName(name); + + if (id) { + try { + const deletePackagePolicyData: DeletePackagePoliciesRequest['body'] = { + packagePolicyIds: [id], + }; + await supertest + .post(INGEST_API_PACKAGE_POLICIES_DELETE) + .set('kbn-xsrf', 'xxx') + .send(deletePackagePolicyData) + .expect(200); + } catch (error) { + logSupertestApiErrorAndThrow( + `Unable to delete Package Policy via Ingest! ${name}`, + error + ); + } + } + }, + + /** + * Gets the policy id (Package Policy) by using the policy name + * @param name + */ + async getPackagePolicyIdByName(name: string) { + const { + body: packagePoliciesResponse, + }: { body: GetPackagePoliciesResponse } = await supertest + .get(INGEST_API_PACKAGE_POLICIES) + .set('kbn-xsrf', 'xxx') + .query({ kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.name: ${name}` }) + .send() + .expect(200); + const packagePolicyList: GetPackagePoliciesResponse['items'] = packagePoliciesResponse.items; + + if (packagePolicyList.length > 1) { + throw new Error(`Found ${packagePolicyList.length} Policies - was expecting only one!`); + } + + if (packagePolicyList.length) { + return packagePolicyList[0].id; + } + }, + }; +} diff --git a/x-pack/test/functional/services/uptime/uptime.ts b/x-pack/test/functional/services/uptime/uptime.ts index b345be012968d..1f808d4e5939a 100644 --- a/x-pack/test/functional/services/uptime/uptime.ts +++ b/x-pack/test/functional/services/uptime/uptime.ts @@ -15,6 +15,7 @@ import { UptimeAlertsProvider } from './alerts'; import { UptimeMLAnomalyProvider } from './ml_anomaly'; import { UptimeCertProvider } from './certificates'; import { UptimeOverviewProvider } from './overview'; +import { SyntheticsPackageProvider } from './synthetics_package'; export function UptimeProvider(context: FtrProviderContext) { const common = UptimeCommonProvider(context); @@ -25,6 +26,7 @@ export function UptimeProvider(context: FtrProviderContext) { const ml = UptimeMLAnomalyProvider(context); const cert = UptimeCertProvider(context); const overview = UptimeOverviewProvider(context); + const syntheticsPackage = SyntheticsPackageProvider(context); return { common, @@ -35,5 +37,6 @@ export function UptimeProvider(context: FtrProviderContext) { ml, cert, overview, + syntheticsPackage, }; } diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 6897de12895df..e36dfc613e613 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -21,7 +21,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); - describe('When on the Endpoint Policy Details Page', function () { + // Failing: See https://github.com/elastic/kibana/issues/100236 + describe.skip('When on the Endpoint Policy Details Page', function () { describe('with an invalid policy id', () => { it('should display an error', async () => { await pageObjects.policy.navigateToPolicyDetails('invalid-id');