From f100190ac191c87240f5c7b189d7fe35d20eaa92 Mon Sep 17 00:00:00 2001 From: Stan Ryzhov <60649800+stasryzhov@users.noreply.github.com> Date: Fri, 15 Mar 2024 11:55:13 -0400 Subject: [PATCH] =?UTF-8?q?test:=20=F0=9F=92=8D=20Add=20storage=20policies?= =?UTF-8?q?=20e2e-test=20(#2175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 💍 Add storage policies e2e-test ✅ Closes: ICU-12883 * chore: Adjust required env for e2e-tests ✅ Closes: ICU-12883 * CR: Refactor helper method to pass in env --------- Co-authored-by: Michael Li --- ui/admin/tests/e2e/helpers/boundary-cli.js | 100 ++++++--- ui/admin/tests/e2e/helpers/boundary-ui.js | 155 ++++++++++++- .../e2e/tests/session-recording-ent.spec.js | 211 ++++++++++++++++++ ...ssh-credential-injection-vault-ent.spec.js | 1 + ui/admin/tests/e2e/tests/target-ent.spec.js | 1 + 5 files changed, 441 insertions(+), 27 deletions(-) create mode 100644 ui/admin/tests/e2e/tests/session-recording-ent.spec.js diff --git a/ui/admin/tests/e2e/helpers/boundary-cli.js b/ui/admin/tests/e2e/helpers/boundary-cli.js index 98f0c4509..7a312068b 100644 --- a/ui/admin/tests/e2e/helpers/boundary-cli.js +++ b/ui/admin/tests/e2e/helpers/boundary-cli.js @@ -25,13 +25,13 @@ exports.authenticateBoundaryCli = async () => { try { execSync( 'boundary authenticate password' + - ' -addr=' + - process.env.BOUNDARY_ADDR + - ' -auth-method-id=' + - process.env.E2E_PASSWORD_AUTH_METHOD_ID + - ' -login-name=' + - process.env.E2E_PASSWORD_ADMIN_LOGIN_NAME + - ' -password=env://E2E_PASSWORD_ADMIN_PASSWORD', + ' -addr=' + + process.env.BOUNDARY_ADDR + + ' -auth-method-id=' + + process.env.E2E_PASSWORD_AUTH_METHOD_ID + + ' -login-name=' + + process.env.E2E_PASSWORD_ADMIN_LOGIN_NAME + + ' -password=env://E2E_PASSWORD_ADMIN_PASSWORD', ); } catch (e) { console.log(`${e.stderr}`); @@ -48,18 +48,18 @@ exports.connectToTarget = async (targetId) => { try { connect = exec( 'boundary connect' + - ' -target-id=' + - targetId + - ' -exec /usr/bin/ssh --' + - ' -l ' + - process.env.E2E_SSH_USER + - ' -i ' + - process.env.E2E_SSH_KEY_PATH + - ' -o UserKnownHostsFile=/dev/null' + - ' -o StrictHostKeyChecking=no' + - ' -o IdentitiesOnly=yes' + // forces the use of the provided key - ' -p {{boundary.port}}' + - ' {{boundary.ip}}', + ' -target-id=' + + targetId + + ' -exec /usr/bin/ssh --' + + ' -l ' + + process.env.E2E_SSH_USER + + ' -i ' + + process.env.E2E_SSH_KEY_PATH + + ' -o UserKnownHostsFile=/dev/null' + + ' -o StrictHostKeyChecking=no' + + ' -o IdentitiesOnly=yes' + // forces the use of the provided key + ' -p {{boundary.port}}' + + ' {{boundary.ip}}', ); } catch (e) { console.log(`${e.stderr}`); @@ -77,12 +77,12 @@ exports.connectSshToTarget = async (targetId) => { try { connect = exec( 'boundary connect ssh' + - ' -target-id=' + - targetId + - ' --' + - ' -o UserKnownHostsFile=/dev/null' + - ' -o StrictHostKeyChecking=no' + - ' -o IdentitiesOnly=yes', // forces the use of the provided key + ' -target-id=' + + targetId + + ' --' + + ' -o UserKnownHostsFile=/dev/null' + + ' -o StrictHostKeyChecking=no' + + ' -o IdentitiesOnly=yes', // forces the use of the provided key ); } catch (e) { console.log(`${e.stderr}`); @@ -124,6 +124,30 @@ exports.deleteOrgCli = async (orgId) => { } }; +/** + * Uses the boundary CLI to delete the specified storage bucket + * @param {string} storageBucketId ID of the storage bucket to be deleted + */ +exports.deleteStorageBucketCli = async (storageBucketId) => { + try { + exec('boundary storage-buckets delete -id=' + storageBucketId); + } catch (e) { + console.log(`${e.stderr}`); + } +}; + +/** + * Uses the boundary CLI to delete the specified policy + * @param {string} policyId ID of the policy to be deleted + */ +exports.deletePolicyCli = async (policyId) => { + try { + exec('boundary policies delete -id=' + policyId); + } catch (e) { + console.log(`${e.stderr}`); + } +}; + /** * Uses the boundary CLI to create a new project. * @param {string} orgId ID of the organization under which the project will be created. @@ -580,3 +604,29 @@ exports.getSessionCli = async (orgName, projectName, targetName) => { } return session; }; + +/** + * Uses the boundary CLI to wait for a session recording to be available. + * @param {string} storageBucketId ID of storage bucket that the session recording is associated with + * @returns An object representing a session recording + */ +exports.waitForSessionRecordingCli = async (storageBucketId) => { + let i = 0; + let filteredSessionRecording = []; + do { + i = i + 1; + const sessionRecordings = JSON.parse( + execSync('boundary session-recordings list -format json'), + ); + filteredSessionRecording = sessionRecordings.items.filter( + (obj) => + obj.storage_bucket_id == storageBucketId && obj.state == 'available', + ); + if (filteredSessionRecording.length > 0) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } while (i < 5); + + return filteredSessionRecording[0]; +}; diff --git a/ui/admin/tests/e2e/helpers/boundary-ui.js b/ui/admin/tests/e2e/helpers/boundary-ui.js index 76f0d7bd2..d074e5e42 100644 --- a/ui/admin/tests/e2e/helpers/boundary-ui.js +++ b/ui/admin/tests/e2e/helpers/boundary-ui.js @@ -443,6 +443,46 @@ exports.createSshTargetWithAddressEnt = async (page) => { return targetName; }; +/** + * Uses the UI to create a new SSH target with address in boundary-enterprise + * Assumes you have selected the desired project. + * @param {Page} page Playwright page object + * @param {string} address Address of the target + * @param {string} port Port of the target + * @param {string} workerFilterEgress Egress worker filter + * @returns Name of the target + */ +exports.createSshTargetWithAddressAndWorkerFilterEnt = async ( + page, + address, + port, + workerFilterEgress, +) => { + const targetName = 'Target ' + nanoid(); + await page + .getByRole('navigation', { name: 'Resources' }) + .getByRole('link', { name: 'Targets' }) + .click(); + await page.getByRole('link', { name: 'New', exact: true }).click(); + await page.getByLabel('Name').fill(targetName); + await page.getByLabel('Description').fill('This is an automated test'); + await page.getByRole('group', { name: 'Type' }).getByLabel('SSH').click(); + await page.getByLabel('Target Address').fill(address); + await page.getByLabel('Default Port').fill(port); + await page.getByLabel('Egress worker filter').click(); + await page.getByRole('textbox', { name: 'Filter' }).fill(workerFilterEgress); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect( + page.getByRole('navigation', { name: 'breadcrumbs' }).getByText(targetName), + ).toBeVisible(); + + return targetName; +}; + /** * Uses the UI to delete a Boundary resource. Assume you have selected the desired resource. * Note: For a resource to be deleted using this method, @@ -498,7 +538,8 @@ exports.addHostSourceToTarget = async (page, hostSourceName) => { }; /** - * Uses the UI to navigate to Sessions and waits for the session to appear. + * Uses the UI to navigate to Sessions and waits for the session to appear + * and be in Active state. * @param {Page} page Playwright page object * @param {string} targetName Name of the target associated with the session */ @@ -509,12 +550,16 @@ exports.waitForSessionToBeVisible = async (page, targetName) => { .click(); let i = 0; let sessionIsVisible = false; + let sessionIsActive = false; do { i = i + 1; sessionIsVisible = await page .getByRole('cell', { name: targetName }) .isVisible(); - if (sessionIsVisible) { + sessionIsActive = await page + .getByRole('cell', { name: 'Active' }) + .isVisible(); + if (sessionIsVisible && sessionIsActive) { break; } await page.getByRole('button', { name: 'Refresh' }).click(); @@ -858,3 +903,109 @@ exports.addGrantsToGroup = async (page, grants) => { page.getByRole('textbox', { name: 'Grant', exact: true }), ).toHaveValue(grants); }; + +/** + * Uses the UI to create a new Storage Bucket. Assumes you have selected the desired scope. + * @param {Page} page Playwright page object + * @returns Name of the Storage Bucket + */ +exports.createStorageBucket = async (page) => { + const storageBucketName = 'Bucket ' + nanoid(); + await page + .getByRole('link', { name: 'Storage Buckets', exact: true }) + .click(); + await page.getByRole('link', { name: 'New Storage Bucket' }).click(); + await page.getByLabel(new RegExp('Name*')).fill(storageBucketName); + await page.getByLabel('Bucket name').fill(process.env.E2E_AWS_BUCKET_NAME); + await page.getByLabel('Region').fill(process.env.E2E_AWS_REGION); + await page + .getByLabel('Access key ID') + .fill(process.env.E2E_AWS_ACCESS_KEY_ID); + await page + .getByLabel('Secret access key') + .fill(process.env.E2E_AWS_SECRET_ACCESS_KEY); + const workerTag = `"${process.env.E2E_WORKER_TAG_EGRESS}" in "/tags/type"`; + await page.getByLabel('Worker filter').fill(workerTag); + await page.getByLabel('Disable credential rotation').click(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText(storageBucketName), + ).toBeVisible(); + + return storageBucketName; +}; + +/** + * Uses the UI to enable session recording for a target. Assumes you have selected the desired target. + * @param {Page} page Playwright page object + * @param {string} storageBucketName name of the Storage Bucket used for session recording + */ +exports.enableSessionRecording = async (page, storageBucketName) => { + await page.getByText('Enable recording').click(); + await page.getByLabel('Record sessions for this target').click(); + await page + .getByLabel('AWS storage buckets') + .selectOption({ label: storageBucketName }); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect( + page.getByRole('listitem').getByText(storageBucketName), + ).toBeVisible(); +}; + +/** + * Uses the UI to create a Storage Policy. Assumes you have selected the desired scope. + * @param {Page} page Playwright page object + * @returns Name of the Storage Policy + */ +exports.createStoragePolicy = async (page) => { + const storagePolicyName = 'Policy ' + nanoid(); + await page + .getByRole('link', { name: 'Storage Policies', exact: true }) + .click(); + await page + .getByRole('link', { name: 'Create a new storage policy', exact: true }) + .click(); + await page.getByLabel('Name').fill(storagePolicyName); + await page.getByLabel('Retention Policy').selectOption({ label: 'Forever' }); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect( + page + .getByRole('navigation', { name: 'breadcrumbs' }) + .getByText(storagePolicyName), + ).toBeVisible(); + + return storagePolicyName; +}; + +/** + * Uses the UI to attach Storage Policy to a scope. Assumes you have selected the desired scope. + * @param {Page} page Playwright page object + * @param {string} policyName name of the Policy to be attached to the scope + */ +exports.attachStoragePolicy = async (page, policyName) => { + await page + .getByRole('link', { name: new RegExp('/*Settings'), exact: true }) + .click(); + await page.getByText('Add Storage Policy').click(); + await page.getByLabel('Storage Policy').selectOption({ label: policyName }); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + await expect(page.getByRole('listitem').getByText(policyName)).toBeVisible(); +}; diff --git a/ui/admin/tests/e2e/tests/session-recording-ent.spec.js b/ui/admin/tests/e2e/tests/session-recording-ent.spec.js new file mode 100644 index 000000000..cdd02c8c5 --- /dev/null +++ b/ui/admin/tests/e2e/tests/session-recording-ent.spec.js @@ -0,0 +1,211 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +/* eslint-disable no-undef */ +const { test, expect } = require('@playwright/test'); +const { execSync } = require('child_process'); +const { checkEnv, authenticatedState } = require('../helpers/general'); +const { + authenticateBoundaryCli, + checkBoundaryCli, + connectSshToTarget, + deleteOrgCli, + waitForSessionRecordingCli, + deleteStorageBucketCli, + deletePolicyCli, +} = require('../helpers/boundary-cli'); +const { + createSshTargetWithAddressAndWorkerFilterEnt, + waitForSessionToBeVisible, + createStorageBucket, + enableSessionRecording, + createStoragePolicy, + attachStoragePolicy, + createNewOrg, + createNewProject, + createStaticCredentialStore, + createStaticCredentialKeyPair, + addInjectedCredentialsToTarget, +} = require('../helpers/boundary-ui'); + +test.use({ storageState: authenticatedState }); + +test.beforeAll(async () => { + await checkEnv([ + 'E2E_SSH_USER', + 'E2E_SSH_KEY_PATH', + 'E2E_TARGET_ADDRESS', + 'E2E_TARGET_PORT', + 'E2E_AWS_BUCKET_NAME', + 'E2E_AWS_REGION', + 'E2E_AWS_ACCESS_KEY_ID', + 'E2E_AWS_SECRET_ACCESS_KEY', + 'E2E_WORKER_TAG_EGRESS', + ]); + + await checkBoundaryCli(); +}); + +test('Verify session recording can be deleted @ent @aws', async ({ page }) => { + await page.goto('/'); + let orgId; + let storagePolicy; + let storageBucket; + let connect; + try { + await authenticateBoundaryCli(); + // Create org + const orgName = await createNewOrg(page); + await authenticateBoundaryCli(); + const orgs = JSON.parse(execSync('boundary scopes list -format json')); + const org = orgs.items.filter((obj) => obj.name == orgName)[0]; + orgId = org.id; + + // Create project + const projectName = await createNewProject(page); + const projects = JSON.parse( + execSync('boundary scopes list -format json -scope-id ' + org.id), + ); + const project = projects.items.filter((obj) => obj.name == projectName)[0]; + const projectId = project.id; + + // Create Storage Bucket + await page.getByRole('link', { name: 'Orgs', exact: true }).click(); + const storageBucketName = await createStorageBucket(page); + const storageBuckets = JSON.parse( + execSync('boundary storage-buckets list -format json'), + ); + storageBucket = storageBuckets.items.filter( + (obj) => obj.name == storageBucketName, + )[0]; + + // Create SSH Target + await page.getByRole('link', { name: 'Orgs', exact: true }).click(); + await page.getByRole('link', { name: orgId }).click(); + await page.getByRole('link', { name: projectId }).click(); + const targetName = await createSshTargetWithAddressAndWorkerFilterEnt( + page, + process.env.E2E_TARGET_ADDRESS, + process.env.E2E_TARGET_PORT, + `"${process.env.E2E_WORKER_TAG_EGRESS}" in "/tags/type"`, + ); + const targets = JSON.parse( + execSync('boundary targets list -format json -scope-id ' + projectId), + ); + const target = targets.items.filter((obj) => obj.name == targetName)[0]; + await createStaticCredentialStore(page); + const credentialName = await createStaticCredentialKeyPair(page); + await addInjectedCredentialsToTarget(page, targetName, credentialName); + + // Enable session recording for Target + await page.getByRole('link', { name: targetName }).click(); + await enableSessionRecording(page, storageBucketName); + + // Create Storage Policy in org scope: keep session recordings forever + await page.getByRole('link', { name: 'Orgs', exact: true }).click(); + await page.getByRole('link', { name: orgId }).click(); + const policyName = await createStoragePolicy(page); + + // Attach Storage Policy to org scope + await attachStoragePolicy(page, policyName); + + // Establish connection to Target and cancel it + connect = await connectSshToTarget(target.id); + await page.getByRole('link', { name: 'Projects', exact: true }).click(); + await page.getByRole('link', { name: projectName }).click(); + await waitForSessionToBeVisible(page, targetName); + await page + .getByRole('cell', { name: targetName }) + .locator('..') + .getByRole('button', { name: 'Cancel' }) + .click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + + // Try to delete Session Recording: expect failure + await waitForSessionRecordingCli(storageBucket.id); + await page.getByRole('link', { name: 'Orgs', exact: true }).click(); + await page + .getByRole('link', { name: 'Session Recordings', exact: true }) + .click(); + await page + .getByRole('row', { name: targetName }) + .getByRole('link', { name: 'View' }) + .click(); + await page.getByText('Manage').click(); + await page.getByRole('button', { name: 'Delete recording' }).click(); + await page.getByRole('button', { name: 'OK' }).click(); + await expect( + page.getByRole('alert').getByText('Error', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + + // Edit Storage Policy: do not protect from deletion + const storagePolicies = JSON.parse( + execSync('boundary policies list -format json -scope-id ' + orgId), + ); + storagePolicy = storagePolicies.items.filter( + (obj) => obj.name == policyName, + )[0]; + await page.getByRole('link', { name: 'Orgs', exact: true }).click(); + await page.getByRole('link', { name: orgId }).click(); + await page + .getByRole('link', { name: 'Storage Policies', exact: true }) + .click(); + await page.getByRole('link', { name: policyName }).click(); + await page.getByRole('button', { name: 'Edit form' }).click(); + await page + .getByLabel('Retention Policy') + .selectOption({ label: 'Do not protect, allow deletion any time' }); + await page.getByLabel('Deletion Policy').selectOption({ label: 'Custom' }); + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + + // Re-apply Storage Policy to the Session Recording + await page.reload(); // fixes error appearing on re-applying storage policy + await page.getByRole('link', { name: 'Orgs', exact: true }).click(); + await page + .getByRole('link', { name: 'Session Recordings', exact: true }) + .click(); + await page + .getByRole('row', { name: targetName }) + .getByRole('link', { name: 'View' }) + .click(); + await page.getByText('Manage').click(); + await page.getByRole('button', { name: 'Re-apply storage policy' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + + // Delete Session Recording + await page.getByText('Manage').click(); + await page.getByRole('button', { name: 'Delete recording' }).click(); + await page.getByRole('button', { name: 'OK' }).click(); + await expect( + page.getByRole('alert').getByText('Success', { exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Dismiss' }).click(); + } finally { + if (storagePolicy) { + await deletePolicyCli(storagePolicy.id); + } + if (orgId) { + await deleteOrgCli(orgId); + } + if (storageBucket) { + await deleteStorageBucketCli(storageBucket.id); + } + // End `boundary connect` process + if (connect) { + connect.kill('SIGTERM'); + } + } +}); diff --git a/ui/admin/tests/e2e/tests/ssh-credential-injection-vault-ent.spec.js b/ui/admin/tests/e2e/tests/ssh-credential-injection-vault-ent.spec.js index 2599b83af..85bf992ec 100644 --- a/ui/admin/tests/e2e/tests/ssh-credential-injection-vault-ent.spec.js +++ b/ui/admin/tests/e2e/tests/ssh-credential-injection-vault-ent.spec.js @@ -39,6 +39,7 @@ test.beforeAll(async () => { 'E2E_TARGET_ADDRESS', 'E2E_SSH_USER', 'E2E_SSH_KEY_PATH', + 'E2E_WORKER_TAG_EGRESS', ]); await checkBoundaryCli(); diff --git a/ui/admin/tests/e2e/tests/target-ent.spec.js b/ui/admin/tests/e2e/tests/target-ent.spec.js index ba98afd66..8ecfb885b 100644 --- a/ui/admin/tests/e2e/tests/target-ent.spec.js +++ b/ui/admin/tests/e2e/tests/target-ent.spec.js @@ -33,6 +33,7 @@ test.beforeAll(async () => { 'E2E_SSH_KEY_PATH', 'E2E_TARGET_ADDRESS', 'E2E_TARGET_PORT', + 'E2E_WORKER_TAG_EGRESS', ]); await checkBoundaryCli();