Repository: elastic/synthetics
Pull request #952 (2024-09-09 16:05:26)

feat: add support for in-memory client certificates
### Summary

Add support for clientCertificates from Playwright v1.47.0 by updating.

To note, code generation has been moved in https://github.com/microsoft/playwright/pull/32330. Some changes here adjust for that.

vigneshshanmugam: > Add a validation during the push command for public locations and error out since cert paths, key paths wont work on the Service.

@emilioalvap Are you planning to do this? 
emilioalvap: @vigneshshanmugam Sure, I can add that to the PR

Patch:
diff --git a/package-lock.json b/package-lock.json
index 0bdc039..d052b43 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,9 +19,9 @@
         "kleur": "^4.1.5",
         "micromatch": "^4.0.7",
         "pirates": "^4.0.5",
-        "playwright": "=1.45.1",
-        "playwright-chromium": "=1.45.1",
-        "playwright-core": "=1.45.1",
+        "playwright": "=1.47.0",
+        "playwright-chromium": "=1.47.0",
+        "playwright-core": "=1.47.0",
         "semver": "^7.5.4",
         "sharp": "^0.33.5",
         "snakecase-keys": "^4.0.1",
@@ -13825,11 +13825,11 @@
       }
     },
     "node_modules/playwright": {
-      "version": "1.45.1",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.1.tgz",
-      "integrity": "sha512-Hjrgae4kpSQBr98nhCj3IScxVeVUixqj+5oyif8TdIn2opTCPEzqAqNMeK42i3cWDCVu9MI+ZsGWw+gVR4ISBg==",
+      "version": "1.47.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz",
+      "integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==",
       "dependencies": {
-        "playwright-core": "1.45.1"
+        "playwright-core": "1.47.0"
       },
       "bin": {
         "playwright": "cli.js"
@@ -13842,12 +13842,12 @@
       }
     },
     "node_modules/playwright-chromium": {
-      "version": "1.45.1",
-      "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.45.1.tgz",
-      "integrity": "sha512-BlYo+kuMg4Jo40Nems2GGVMWdKI2GeHL85D7pkwEW3aq6iEDW3XL7udmoNLOIfluSCKzVRJMB0ta1mt67B3tGA==",
+      "version": "1.47.0",
+      "resolved": "https://registry.npmjs.org/playwright-chromium/-/playwright-chromium-1.47.0.tgz",
+      "integrity": "sha512-S/9ShSLRK6gZZCuon2K0OcEi/t7vmUmx7vqqcpI9/zzKPMWm/+XKKuOHahKXsZLp3DfmRLv7h/PflC19nXZVhA==",
       "hasInstallScript": true,
       "dependencies": {
-        "playwright-core": "1.45.1"
+        "playwright-core": "1.47.0"
       },
       "bin": {
         "playwright": "cli.js"
@@ -13857,9 +13857,9 @@
       }
     },
     "node_modules/playwright-core": {
-      "version": "1.45.1",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.45.1.tgz",
-      "integrity": "sha512-LF4CUUtrUu2TCpDw4mcrAIuYrEjVDfT1cHbJMfwnE2+1b8PZcFzPNgvZCvq2JfQ4aTjRCCHw5EJ2tmr2NSzdPg==",
+      "version": "1.47.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz",
+      "integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==",
       "bin": {
         "playwright-core": "cli.js"
       },
diff --git a/package.json b/package.json
index 4440d13..aa3a2dd 100644
--- a/package.json
+++ b/package.json
@@ -61,9 +61,9 @@
     "kleur": "^4.1.5",
     "micromatch": "^4.0.7",
     "pirates": "^4.0.5",
-    "playwright": "=1.45.1",
-    "playwright-chromium": "=1.45.1",
-    "playwright-core": "=1.45.1",
+    "playwright": "=1.47.0",
+    "playwright-chromium": "=1.47.0",
+    "playwright-core": "=1.47.0",
     "semver": "^7.5.4",
     "sharp": "^0.33.5",
     "snakecase-keys": "^4.0.1",
diff --git a/src/config.ts b/src/config.ts
index 64c8b0e..c9982ea 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -59,6 +59,7 @@ export async function readConfig(
       options = await optionsPromise;
     }
   }
+
   return options;
 }
 
diff --git a/src/formatter/javascript.ts b/src/formatter/javascript.ts
index 2b8af6a..6a9d8bf 100644
--- a/src/formatter/javascript.ts
+++ b/src/formatter/javascript.ts
@@ -26,7 +26,7 @@
 import {
   JavaScriptLanguageGenerator,
   JavaScriptFormatter,
-} from 'playwright-core/lib/server/recorder/javascript';
+} from 'playwright-core/lib/server/codegen/javascript';
 
 export type Step = {
   actions: ActionInContext[];
@@ -272,7 +272,7 @@ export class SyntheticsGenerator extends JavaScriptLanguageGenerator {
     if (isAssert && action.command) {
       formatter.add(toAssertCall(pageAlias, action));
     } else {
-      formatter.add(super._generateActionCall(subject, action));
+      formatter.add(super._generateActionCall(subject, actionInContext));
     }
 
     if (signals.popup)
diff --git a/src/options.ts b/src/options.ts
index d2be373..78ed784 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -77,7 +77,15 @@ export async function normalizeOptions(
    */
   const playwrightOpts = merge(
     config.playwrightOptions,
-    cliArgs.playwrightOptions || {}
+    cliArgs.playwrightOptions || {},
+    {
+      arrayMerge(target, source) {
+        if (source && source.length > 0) {
+          return [...new Set(source)];
+        }
+        return target;
+      },
+    }
   );
   options.playwrightOptions = {
     ...playwrightOpts,
@@ -195,7 +203,7 @@ export function getCommonCommandOpts() {
   const playwrightOpts = createOption(
     '--playwright-options <jsonstring>',
     'JSON object to pass in custom Playwright options for the agent. Options passed will be merged with Playwright options defined in your synthetics.config.js file.'
-  ).argParser(JSON.parse);
+  ).argParser(parsePlaywrightOptions);
 
   const pattern = createOption(
     '--pattern <pattern>',
@@ -237,3 +245,35 @@ export function getCommonCommandOpts() {
     match,
   };
 }
+
+export function parsePlaywrightOptions(playwrightOpts: string) {
+  return JSON.parse(playwrightOpts, (key, value) => {
+    if (key !== 'clientCertificates') {
+      return value;
+    }
+
+    // Revive serialized clientCertificates buffer objects
+    return (value ?? []).map(item => {
+      const revived = { ...item };
+      if (item.cert && !Buffer.isBuffer(item.cert)) {
+        revived.cert = parseAsBuffer(item.cert);
+      }
+      if (item.key && !Buffer.isBuffer(item.key)) {
+        revived.key = parseAsBuffer(item.key);
+      }
+      if (item.pfx && !Buffer.isBuffer(item.pfx)) {
+        revived.pfx = parseAsBuffer(item.pfx);
+      }
+
+      return revived;
+    });
+  });
+}
+
+function parseAsBuffer(value: any): Buffer {
+  try {
+    return Buffer.from(value);
+  } catch (e) {
+    return value;
+  }
+}
diff --git a/src/push/index.ts b/src/push/index.ts
index fe4f9db..2a12314 100644
--- a/src/push/index.ts
+++ b/src/push/index.ts
@@ -223,8 +223,16 @@ export function validateSettings(opts: PushOptions) {
   - CLI '--schedule <mins>'
   - Config file 'monitors.schedule' field`;
   } else if (opts.schedule && !ALLOWED_SCHEDULES.includes(opts.schedule)) {
-    reason = `Set default schedule(${opts.schedule
-      }) to one of the allowed values - ${ALLOWED_SCHEDULES.join(',')}`;
+    reason = `Set default schedule(${
+      opts.schedule
+    }) to one of the allowed values - ${ALLOWED_SCHEDULES.join(',')}`;
+  } else if (
+    opts.locations.length > 0 &&
+    (opts?.playwrightOptions?.clientCertificates ?? []).filter(cert => {
+      return cert.certPath || cert.keyPath || cert.pfxPath;
+    }).length > 0
+  ) {
+    reason = `Certificate path options (certPath, keyPath, pfxPath) are not supported on globally managed locations, use in-memory alternatives (cert, key, pfx) when running on cloud.`;
   }
 
   if (!reason) return;


Test patch:
diff --git a/__tests__/options.test.ts b/__tests__/options.test.ts
index 283a755..0d68140 100644
--- a/__tests__/options.test.ts
+++ b/__tests__/options.test.ts
@@ -24,7 +24,7 @@
  */
 
 import { CliArgs } from '../src/common_types';
-import { normalizeOptions } from '../src/options';
+import { normalizeOptions, parsePlaywrightOptions } from '../src/options';
 import { join } from 'path';
 
 describe('options', () => {
@@ -66,7 +66,7 @@ describe('options', () => {
         ignoreHTTPSErrors: undefined,
         isMobile: true,
         userAgent:
-          'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.17 Mobile Safari/537.36',
+          'Mozilla/5.0 (Linux; Android 8.0.0; SM-G965U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6668.29 Mobile Safari/537.36',
         viewport: {
           height: 658,
           width: 320,
@@ -148,4 +148,34 @@ describe('options', () => {
       },
     });
   });
+
+  it('parses cli playwrightOptions.clientCertificates', async () => {
+    const test = {
+      clientCertificates: [
+        {
+          key: Buffer.from('This should be revived'),
+          cert: Buffer.from('This should be revived'),
+          pfx: Buffer.from('This should be revived'),
+          origin: Buffer.from('This should not be revived'),
+          passphrase: Buffer.from('This should not be revived'),
+        },
+        {
+          key: 'This should be revived',
+          cert: 'This should be revived',
+          pfx: 'This should be revived',
+          origin: 'This should not be revived',
+          passphrase: 'This should not be revived',
+        },
+      ],
+    };
+    const result = parsePlaywrightOptions(JSON.stringify(test));
+
+    result.clientCertificates.forEach(t => {
+      expect(Buffer.isBuffer(t.cert)).toBeTruthy();
+      expect(Buffer.isBuffer(t.key)).toBeTruthy();
+      expect(Buffer.isBuffer(t.pfx)).toBeTruthy();
+      expect(Buffer.isBuffer(t.origin)).toBeFalsy();
+      expect(Buffer.isBuffer(t.passphrase)).toBeFalsy();
+    });
+  });
 });
diff --git a/__tests__/push/__snapshots__/index.test.ts.snap b/__tests__/push/__snapshots__/index.test.ts.snap
index 82e93d0..0875127 100644
--- a/__tests__/push/__snapshots__/index.test.ts.snap
+++ b/__tests__/push/__snapshots__/index.test.ts.snap
@@ -1,5 +1,32 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`Push abort on push with clientCertificate.certPath used in cloud 1`] = `
+"Aborted. Invalid synthetics project settings.
+
+Certificate path options (certPath, keyPath, pfxPath) are not supported on globally managed locations, use in-memory alternatives (cert, key, pfx) when running on cloud.
+
+Run 'npx @elastic/synthetics init' to create project with default settings.
+"
+`;
+
+exports[`Push abort on push with clientCertificate.keyPath used in cloud 1`] = `
+"Aborted. Invalid synthetics project settings.
+
+Certificate path options (certPath, keyPath, pfxPath) are not supported on globally managed locations, use in-memory alternatives (cert, key, pfx) when running on cloud.
+
+Run 'npx @elastic/synthetics init' to create project with default settings.
+"
+`;
+
+exports[`Push abort on push with clientCertificate.pfxPath used in cloud 1`] = `
+"Aborted. Invalid synthetics project settings.
+
+Certificate path options (certPath, keyPath, pfxPath) are not supported on globally managed locations, use in-memory alternatives (cert, key, pfx) when running on cloud.
+
+Run 'npx @elastic/synthetics init' to create project with default settings.
+"
+`;
+
 exports[`Push error on empty project id 1`] = `
 "Aborted. Invalid synthetics project settings.
 
diff --git a/__tests__/push/index.test.ts b/__tests__/push/index.test.ts
index 151762a..3c8e6ec 100644
--- a/__tests__/push/index.test.ts
+++ b/__tests__/push/index.test.ts
@@ -53,9 +53,7 @@ describe('Push', () => {
   ) {
     await writeFile(
       join(PROJECT_DIR, filename),
-      `export default { monitor: ${JSON.stringify(
-        monitor
-      )}, project: ${JSON.stringify(settings)} }`
+      `export default ${JSON.stringify({ ...settings, monitor })}`
     );
   }
 
@@ -89,20 +87,23 @@ describe('Push', () => {
   });
 
   it('error on invalid location', async () => {
-    await fakeProjectSetup({ id: 'test-project' }, {});
+    await fakeProjectSetup({ project: { id: 'test-project' } }, {});
     const output = await runPush();
     expect(output).toMatchSnapshot();
   });
 
   it('error when schedule is not present', async () => {
-    await fakeProjectSetup({ id: 'test-project' }, { locations: ['test-loc'] });
+    await fakeProjectSetup(
+      { project: { id: 'test-project' } },
+      { locations: ['test-loc'] }
+    );
     const output = await runPush();
     expect(output).toMatchSnapshot();
   });
 
   it('error on invalid schedule', async () => {
     await fakeProjectSetup(
-      { id: 'test-project' },
+      { project: { id: 'test-project' } },
       { locations: ['test-loc'], schedule: 12 }
     );
     const output = await runPush();
@@ -111,7 +112,7 @@ describe('Push', () => {
 
   it('abort on push with different project id', async () => {
     await fakeProjectSetup(
-      { id: 'test-project' },
+      { project: { id: 'test-project' } },
       { locations: ['test-loc'], schedule: 3 }
     );
     const output = await runPush(
@@ -125,7 +126,13 @@ describe('Push', () => {
 
   it('error on invalid schedule in monitor DSL', async () => {
     await fakeProjectSetup(
-      { id: 'test-project', space: 'dummy', url: 'http://localhost:8080' },
+      {
+        project: {
+          id: 'test-project',
+          space: 'dummy',
+          url: 'http://localhost:8080',
+        },
+      },
       { locations: ['test-loc'], schedule: 3 }
     );
     const testJourney = join(PROJECT_DIR, 'test.journey.ts');
@@ -141,7 +148,7 @@ journey('journey 1', () => monitor.use({ id: 'j1', schedule: 8 }));`
 
   it('errors on duplicate browser monitors', async () => {
     await fakeProjectSetup(
-      { id: 'test-project', space: 'dummy', url: server.PREFIX },
+      { project: { id: 'test-project', space: 'dummy', url: server.PREFIX } },
       { locations: ['test-loc'], schedule: 3 }
     );
 
@@ -164,7 +171,7 @@ journey('duplicate name', () => monitor.use({ schedule: 15 }));`
 
   it('warn if throttling config is set', async () => {
     await fakeProjectSetup(
-      { id: 'test-project' },
+      { project: { id: 'test-project' } },
       { locations: ['test-loc'], schedule: 3 }
     );
     const testJourney = join(PROJECT_DIR, 'test.journey.ts');
@@ -180,7 +187,7 @@ journey('duplicate name', () => monitor.use({ schedule: 15 }));`
 
   it('errors on duplicate lightweight monitors', async () => {
     await fakeProjectSetup(
-      { id: 'test-project', space: 'dummy', url: server.PREFIX },
+      { project: { id: 'test-project', space: 'dummy', url: server.PREFIX } },
       { locations: ['test-loc'], schedule: 3 }
     );
 
@@ -220,7 +227,7 @@ heartbeat.monitors:
 
   it('error on invalid CHUNK SIZE', async () => {
     await fakeProjectSetup(
-      { id: 'test-project', space: 'dummy', url: server.PREFIX },
+      { project: { id: 'test-project', space: 'dummy', url: server.PREFIX } },
       { locations: ['test-loc'], schedule: 3 }
     );
     const output = await runPush(undefined, { CHUNK_SIZE: '251' });
@@ -231,7 +238,7 @@ heartbeat.monitors:
 
   it('respects valid CHUNK SIZE', async () => {
     await fakeProjectSetup(
-      { id: 'test-project', space: 'dummy', url: server.PREFIX },
+      { project: { id: 'test-project', space: 'dummy', url: server.PREFIX } },
       { locations: ['test-loc'], schedule: 3 }
     );
     const testJourney = join(PROJECT_DIR, 'chunk.journey.ts');
@@ -260,7 +267,9 @@ heartbeat.monitors:
       beforeAll(async () => {
         server = await createKibanaTestServer(version);
         await fakeProjectSetup(
-          { id: 'test-project', space: 'dummy', url: server.PREFIX },
+          {
+            project: { id: 'test-project', space: 'dummy', url: server.PREFIX },
+          },
           { locations: ['test-loc'], schedule: 3 }
         );
       });
@@ -314,7 +323,7 @@ heartbeat.monitors:
         journey('journey 1', () => monitor.use({ id: 'j1' }));`
         );
         await fakeProjectSetup(
-          { id: 'bar', space: 'dummy', url: server.PREFIX },
+          { project: { id: 'bar', space: 'dummy', url: server.PREFIX } },
           { locations: ['test-loc'], schedule: 3 },
           'synthetics.config.test.ts'
         );
@@ -330,4 +339,18 @@ heartbeat.monitors:
       });
     });
   });
+
+  ['certPath', 'keyPath', 'pfxPath'].forEach(key => {
+    it(`abort on push with clientCertificate.${key} used in cloud`, async () => {
+      await fakeProjectSetup(
+        {
+          project: { id: 'test-project', space: 'dummy', url: server.PREFIX },
+          playwrightOptions: { clientCertificates: [{ [key]: 'test.file' }] },
+        },
+        { locations: ['test-loc'], schedule: 3 }
+      );
+      const output = await runPush();
+      expect(output).toMatchSnapshot();
+    });
+  });
 });