diff --git a/.github/workflows/dev-fe-e2e.yaml b/.github/workflows/dev-fe-e2e.yaml index 436aef006..ce84f3b42 100644 --- a/.github/workflows/dev-fe-e2e.yaml +++ b/.github/workflows/dev-fe-e2e.yaml @@ -7,6 +7,10 @@ on: required: true CI_PASSWORD: required: true + RBAC_USER: + required: true + RBAC_PASSWORD: + required: true jobs: e2e: @@ -148,6 +152,8 @@ jobs: EVEREST_LOCATION_URL: "https://minio.minio.svc.cluster.local" CI_USER: "admin" CI_PASSWORD: "${{ env.EVEREST_ADMIN_PASSWORD }}" + RBAC_USER: "rbac_user" + RBAC_PASSWORD: "rbac-e2e-test" MONITORING_USER: "admin" MONITORING_PASSWORD: "admin" run: | diff --git a/.github/workflows/dev-fe-gatekeeper.yaml b/.github/workflows/dev-fe-gatekeeper.yaml index fe198a8e9..b7cd57a1e 100644 --- a/.github/workflows/dev-fe-gatekeeper.yaml +++ b/.github/workflows/dev-fe-gatekeeper.yaml @@ -129,6 +129,8 @@ jobs: secrets: CI_USER: everestadmin CI_PASSWORD: everestadmin + RBAC_USER: rbac_user + RBAC_PASSWORD: rbac-e2e-test merge-gatekeeper: needs: [CI_checks, permission_checks, E2E_tests_workflow] diff --git a/ui/apps/everest/.e2e/.env.test b/ui/apps/everest/.e2e/.env.test index 54b2e87bd..4868007f0 100644 --- a/ui/apps/everest/.e2e/.env.test +++ b/ui/apps/everest/.e2e/.env.test @@ -8,3 +8,5 @@ MONITORING_USER="" MONITORING_PASSWORD="" CI_USER="" CI_PASSWORD="" +RBAC_USER="" +RBAC_PASSWORD="" diff --git a/ui/apps/everest/.e2e/.gitignore b/ui/apps/everest/.e2e/.gitignore index d10ef2053..efbf56ddd 100644 --- a/ui/apps/everest/.e2e/.gitignore +++ b/ui/apps/everest/.e2e/.gitignore @@ -1,3 +1,4 @@ tests-out .env user.json +old_rbac_permissions \ No newline at end of file diff --git a/ui/apps/everest/.e2e/playwright.config.ts b/ui/apps/everest/.e2e/playwright.config.ts index d8cfa4788..5b7d619bc 100644 --- a/ui/apps/everest/.e2e/playwright.config.ts +++ b/ui/apps/everest/.e2e/playwright.config.ts @@ -16,7 +16,7 @@ import { defineConfig } from '@playwright/test'; import path from 'path'; import { dirname } from 'path'; import { fileURLToPath } from 'url'; -import { STORAGE_STATE_FILE, TIMEOUTS } from './constants'; +import { STORAGE_STATE_FILE } from './constants'; import 'dotenv/config'; // Convert 'import.meta.url' to the equivalent __filename and __dirname @@ -67,13 +67,13 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ { - testDir: '.', name: 'auth', + testDir: './setup', testMatch: /auth.setup\.ts/, }, { - testDir: '.', name: 'setup', + testDir: './setup', testMatch: /global.setup\.ts/, teardown: 'teardown', use: { @@ -82,20 +82,49 @@ export default defineConfig({ dependencies: ['auth'], }, { - testDir: '.', name: 'teardown', + testDir: './teardown', use: { storageState: STORAGE_STATE_FILE, }, testMatch: /global\.teardown\.ts/, }, + { + name: 'rbac-setup', + testDir: './setup', + testMatch: /rbac.setup\.ts/, + use: { + storageState: STORAGE_STATE_FILE, + }, + dependencies: ['setup'], + }, + { + name: 'rbac', + use: { + browserName: 'chromium', + channel: 'chrome', + storageState: STORAGE_STATE_FILE, + }, + testDir: './pr/rbac', + dependencies: ['setup', 'rbac-setup'], + }, + { + name: 'rbac-teardown', + testDir: './teardown', + testMatch: /rbac\.teardown\.ts/, + use: { + storageState: STORAGE_STATE_FILE, + }, + dependencies: ['rbac'], + }, { name: 'pr', use: { storageState: STORAGE_STATE_FILE, }, testDir: 'pr', - dependencies: ['setup'], + testIgnore: ['pr/rbac/**/*'], + dependencies: ['setup', 'rbac', 'rbac-teardown'], }, { name: 'release', diff --git a/ui/apps/everest/.e2e/pr/rbac/backups.e2e.ts b/ui/apps/everest/.e2e/pr/rbac/backups.e2e.ts new file mode 100644 index 000000000..50e863b2f --- /dev/null +++ b/ui/apps/everest/.e2e/pr/rbac/backups.e2e.ts @@ -0,0 +1,123 @@ +import { getTokenFromLocalStorage } from '@e2e/utils/localStorage'; +import { getNamespacesFn } from '@e2e/utils/namespaces'; +import { setRBACPermissionsK8S } from '@e2e/utils/rbac-cmd-line'; +import { expect, test } from '@playwright/test'; +import { + MOCK_CLUSTER_NAME, + mockBackups, + mockClusters, + mockStorages, +} from './utils'; + +test.describe('Backups RBAC', () => { + let namespace = ''; + test.beforeAll(async ({ request }) => { + const token = await getTokenFromLocalStorage(); + const namespaces = await getNamespacesFn(token, request); + namespace = namespaces[0]; + console.log('Namespace:', namespace); + }); + + test('Hide Backups', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['backup-storages', '*', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/*`], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await mockStorages(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await expect(page.getByRole('table')).toBeVisible(); + const rows = page.locator('.MuiTableRow-root:not(.MuiTableRow-head)'); + expect(await rows.count()).toBe(0); + }); + + test('Show Backups', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['backup-storages', '*', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/*`], + ['database-cluster-backups', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await mockStorages(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await expect(page.getByRole('table')).toBeVisible(); + await expect(page.getByTestId('row-actions-menu-button')).not.toBeVisible(); + const rows = page.locator('.MuiTableRow-root:not(.MuiTableRow-head)'); + expect(await rows.count()).toBe(1); + }); + + test('Delete backup', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['backup-storages', '*', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/*`], + ['database-cluster-backups', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + [ + 'database-cluster-backups', + 'delete', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await mockStorages(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await expect(page.getByTestId('row-actions-menu-button')).toBeVisible(); + await page.getByTestId('row-actions-menu-button').click(); + await expect(page.getByText('Delete')).toBeVisible(); + await expect(page.getByText('Restore to this DB')).not.toBeVisible(); + await expect(page.getByText('Create new DB')).not.toBeVisible(); + }); + + test('Create on-demand backup', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['backup-storages', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + [ + 'database-cluster-backups', + 'create', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await mockStorages(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await expect(page.getByTestId('menu-button')).toBeVisible(); + await page.getByText('Create backup').click(); + await expect(page.getByText('Now', { exact: true })).toBeVisible(); + await expect(page.getByText('Schedule', { exact: true })).not.toBeVisible(); + }); + + test('Create scheduled backup', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['backup-storages', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ['database-clusters', 'update', `${namespace}/*`], + [ + 'database-cluster-backups', + 'create', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await mockStorages(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await expect(page.getByTestId('menu-button')).toBeVisible(); + await page.getByText('Create backup').click(); + await expect(page.getByText('Schedule', { exact: true })).toBeVisible(); + }); +}); +1; diff --git a/ui/apps/everest/.e2e/pr/rbac/clusters.e2e.ts b/ui/apps/everest/.e2e/pr/rbac/clusters.e2e.ts new file mode 100644 index 000000000..f003b9235 --- /dev/null +++ b/ui/apps/everest/.e2e/pr/rbac/clusters.e2e.ts @@ -0,0 +1,113 @@ +import { expect, test } from '@playwright/test'; +import { mockEngines, MOCK_CLUSTER_NAME, mockClusters } from './utils'; +import { getTokenFromLocalStorage } from '@e2e/utils/localStorage'; +import { getNamespacesFn } from '@e2e/utils/namespaces'; +import { setRBACPermissionsK8S } from '@e2e/utils/rbac-cmd-line'; + +const { CI_USER: user } = process.env; + +test.describe('Clusters RBAC', () => { + let namespace = ''; + + test.beforeAll(async ({ request }) => { + const token = await getTokenFromLocalStorage(); + const namespaces = await getNamespacesFn(token, request); + namespace = namespaces[0]; + }); + + test('permitted cluster creation with present clusters', async ({ page }) => { + await mockEngines(page, namespace); + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/*`], + ]); + await mockClusters(page, namespace); + await page.goto('/databases'); + await expect(page.getByText('Create database')).toBeVisible(); + await expect(page.getByText('Create database')).not.toBeDisabled(); + }); + + test('permitted cluster creation without present clusters', async ({ + page, + }) => { + await mockEngines(page, namespace); + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/*`], + ]); + await page.goto('/databases'); + await expect(page.getByText('Create database')).toBeVisible(); + await expect(page.getByText('Create database')).not.toBeDisabled(); + }); + + test('not permitted cluster creation with present clusters', async ({ + page, + }) => { + await mockEngines(page, namespace); + await mockClusters(page, namespace); + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ]); + await page.goto('/databases'); + await expect(page.getByText('Create database')).not.toBeVisible(); + }); + + test('not permitted cluster creation without present clusters', async ({ + page, + }) => { + await mockEngines(page, namespace); + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ]); + await page.goto('/databases'); + await expect(page.getByText('Create database')).not.toBeVisible(); + }); + + test('visible actions', async ({ page }) => { + await mockEngines(page, namespace); + await mockClusters(page, namespace); + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/${MOCK_CLUSTER_NAME}`], + ]); + await page.goto('/databases'); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Delete')).toBeVisible(); + await expect(page.getByText('Suspend')).toBeVisible(); + await expect(page.getByText('Restart')).toBeVisible(); + + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}`); + await expect( + page.getByTestId('edit-advanced-configuration-db-btn') + ).toBeVisible(); + await expect( + page.getByTestId('edit-advanced-configuration-db-btn') + ).not.toBeDisabled(); + await expect(page.getByTestId('edit-resources-button')).toBeVisible(); + await expect(page.getByTestId('edit-resources-button')).not.toBeDisabled(); + }); + + test('not visible actions', async ({ page }) => { + await mockEngines(page, namespace); + await mockClusters(page, namespace); + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ]); + await page.goto('/databases'); + await expect(page.getByTestId('actions-menu-button')).not.toBeVisible(); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}`); + await expect( + page.getByTestId('edit-advanced-configuration-db-btn') + ).not.toBeVisible(); + await expect(page.getByTestId('edit-resources-button')).toBeDisabled(); + }); +}); diff --git a/ui/apps/everest/.e2e/pr/rbac/namespaces.e2e.ts b/ui/apps/everest/.e2e/pr/rbac/namespaces.e2e.ts new file mode 100644 index 000000000..b22c1b405 --- /dev/null +++ b/ui/apps/everest/.e2e/pr/rbac/namespaces.e2e.ts @@ -0,0 +1,144 @@ +import { expect, test } from '@playwright/test'; +import { getTokenFromLocalStorage } from '@e2e/utils/localStorage'; +import { getNamespacesFn } from '@e2e/utils/namespaces'; +import { setRBACPermissionsK8S } from '@e2e/utils/rbac-cmd-line'; + +// Namespaces, engines and DBs are already filtered by the API according to permissions, so here we test the UI +test.describe('Namespaces RBAC', () => { + let namespaces = []; + + test.beforeAll(async ({ request }) => { + const token = await getTokenFromLocalStorage(); + namespaces = await getNamespacesFn(token, request); + }); + + test('should show upgrade button when there is permission to update DB engines', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespaces[0]], + ['database-engines', '*', `${namespaces[0]}/*`], + ['database-clusters', '*', `${namespaces[0]}/*`], + ]); + await page.route( + `/v1/namespaces/${namespaces[0]}/database-engines`, + async (route) => { + await route.fulfill({ + json: { + items: [ + { + metadata: { + name: 'percona-xtradb-cluster-operator', + namespace: namespaces[0], + }, + spec: { + type: 'pxc', + }, + status: { + status: 'installed', + availableVersions: { + engine: { + '8.0.36-28.1': { + status: 'available', + }, + }, + }, + pendingOperatorUpgrades: [ + { + targetVersion: '1.15.0', + }, + ], + }, + }, + ], + }, + }); + } + ); + await page.route( + `/v1/namespaces/${namespaces[0]}/database-engines/upgrade-plan`, + async (route) => { + await route.fulfill({ + json: { + upgrades: [ + { + name: 'percona-xtradb-cluster-operator', + currentVersion: '1.14.0', + targetVersion: '1.15.0', + }, + ], + pendingActions: [], + }, + }); + } + ); + await page.goto(`/settings/namespaces/${namespaces[0]}`); + await expect(page.getByText('Upgrade Operators')).toBeVisible(); + await expect(page.getByText('Upgrade Operators')).not.toBeDisabled(); + }); + + test('should disable upgrade button when there is no permission to update DB engines', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespaces[0]], + ['database-engines', 'read', `${namespaces[0]}/*`], + ['database-clusters', '*', `${namespaces[0]}/*`], + ]); + await page.route( + `/v1/namespaces/${namespaces[0]}/database-engines`, + async (route) => { + await route.fulfill({ + json: { + items: [ + { + metadata: { + name: 'percona-xtradb-cluster-operator', + namespace: namespaces[0], + }, + spec: { + type: 'pxc', + }, + status: { + status: 'installed', + availableVersions: { + engine: { + '8.0.36-28.1': { + status: 'available', + }, + }, + }, + pendingOperatorUpgrades: [ + { + targetVersion: '1.15.0', + }, + ], + }, + }, + ], + }, + }); + } + ); + await page.route( + `/v1/namespaces/${namespaces[0]}/database-engines/upgrade-plan`, + async (route) => { + await route.fulfill({ + json: { + upgrades: [ + { + name: 'percona-xtradb-cluster-operator', + currentVersion: '1.14.0', + targetVersion: '1.15.0', + }, + ], + pendingActions: [], + }, + }); + } + ); + await page.goto(`/settings/namespaces/${namespaces[0]}`); + await expect(page.getByText('Upgrade Operators')).toBeVisible(); + await expect(page.getByText('Upgrade Operators')).toBeDisabled(); + }); +}); diff --git a/ui/apps/everest/.e2e/pr/rbac/restores.e2e.ts b/ui/apps/everest/.e2e/pr/rbac/restores.e2e.ts new file mode 100644 index 000000000..ef6e7bdc6 --- /dev/null +++ b/ui/apps/everest/.e2e/pr/rbac/restores.e2e.ts @@ -0,0 +1,259 @@ +import { getTokenFromLocalStorage } from '@e2e/utils/localStorage'; +import { getNamespacesFn } from '@e2e/utils/namespaces'; +import { setRBACPermissionsK8S } from '@e2e/utils/rbac-cmd-line'; +import { expect, test } from '@playwright/test'; +import { MOCK_CLUSTER_NAME, mockBackups, mockClusters } from './utils'; + +const { CI_USER: user } = process.env; + +test.describe('Restores RBAC', () => { + let namespace = ''; + test.beforeAll(async ({ request }) => { + const token = await getTokenFromLocalStorage(); + const namespaces = await getNamespacesFn(token, request); + namespace = namespaces[0]; + }); + + test('Restore to same DB', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-cluster-backups', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto('/databases'); + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Restore from a backup')).toBeVisible(); + }); + + test('Create DB from backup', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-cluster-backups', '*', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['monitoring-instances', '*', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto('/databases'); + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Create DB from a backup')).toBeVisible(); + }); + + [ + { + permissionToRemove: 'database-cluster-credentials', + }, + { + permissionToRemove: 'database-cluster-restores', + }, + ].forEach(({ permissionToRemove }) => { + test(`Hide Restore to same DB if "${permissionToRemove}" permission is removed`, async ({ + page, + }) => { + await setRBACPermissionsK8S( + //@ts-expect-error + [ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/${MOCK_CLUSTER_NAME}`], + [ + 'database-cluster-backups', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ].filter(([permission]) => permission !== permissionToRemove) + ); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto('/databases'); + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Restore from a backup')).not.toBeVisible(); + }); + }); + + [ + { + permissionToRemove: 'database-cluster-credentials', + }, + { + permissionToRemove: 'database-cluster-restores', + }, + { + permissionToRemove: 'database-cluster-backups', + }, + ].forEach(({ permissionToRemove }) => { + test(`Hide Create DB from backup if "${permissionToRemove}" permission is removed`, async ({ + page, + }) => { + await setRBACPermissionsK8S( + //@ts-expect-error + [ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['database-clusters', '*', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + [ + 'database-cluster-backups', + '*', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ].filter(([permission]) => permission !== permissionToRemove) + ); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto('/databases'); + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Create DB from a backup')).not.toBeVisible(); + }); + }); + + test('Hide Create DB from backup if DB has schedules and not allowed to create backups', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-cluster-backups', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto('/databases'); + + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Create DB from a backup')).not.toBeVisible(); + }); + + test('Show Create DB from backup if DB has no schedules, even if not allowed to create backups', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-cluster-backups', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['monitoring-instances', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace, { enableSchedules: false }); + await mockBackups(page, namespace); + await page.goto('/databases'); + + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Create DB from a backup')).toBeVisible(); + }); + + test('Hide Create DB from backup if DB has monitoring enabled and not allowed to read monitoring', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + [ + 'database-cluster-backups', + 'create', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto('/databases'); + + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Create DB from a backup')).not.toBeVisible(); + }); + + test('Show Create DB from backup if DB has monitoring disabled, even if not allowed to read monitoring', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', 'read', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/${MOCK_CLUSTER_NAME}`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + [ + 'database-cluster-backups', + 'create', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ['database-cluster-restores', 'create', `${namespace}/*`], + [ + 'database-cluster-credentials', + 'read', + `${namespace}/${MOCK_CLUSTER_NAME}`, + ], + ]); + await mockClusters(page, namespace, { enableMonitoring: false }); + await mockBackups(page, namespace); + await page.goto('/databases'); + + await expect(page.getByText(MOCK_CLUSTER_NAME)).toBeVisible(); + await page.getByTestId('actions-menu-button').click(); + await expect(page.getByText('Create DB from a backup')).toBeVisible(); + }); +}); diff --git a/ui/apps/everest/.e2e/pr/rbac/schedules.e2e.ts b/ui/apps/everest/.e2e/pr/rbac/schedules.e2e.ts new file mode 100644 index 000000000..0cd743fce --- /dev/null +++ b/ui/apps/everest/.e2e/pr/rbac/schedules.e2e.ts @@ -0,0 +1,142 @@ +import { getTokenFromLocalStorage } from '@e2e/utils/localStorage'; +import { getNamespacesFn } from '@e2e/utils/namespaces'; +import { setRBACPermissionsK8S } from '@e2e/utils/rbac-cmd-line'; +import { expect, test } from '@playwright/test'; +import { moveForward } from '@e2e/utils/db-wizard'; +import { + MOCK_CLUSTER_NAME, + MOCK_SCHEDULE_NAME, + mockBackups, + mockClusters, + mockEngines, + mockStorages, +} from './utils'; + +const { CI_USER: user } = process.env; + +test.describe('Schedules RBAC', () => { + let namespace = ''; + test.beforeAll(async ({ request }) => { + const token = await getTokenFromLocalStorage(); + const namespaces = await getNamespacesFn(token, request); + namespace = namespaces[0]; + }); + + test('Schedule creation from wizard', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-cluster-backups', 'create', `${namespace}/*`], + ]); + await mockEngines(page, namespace); + await mockStorages(page, namespace); + await mockClusters(page, namespace); + await page.goto('/databases'); + await page.getByTestId('add-db-cluster-button').click(); + await expect( + page.getByText('Basic information', { exact: true }) + ).toBeVisible(); + await moveForward(page); + await moveForward(page); + await expect( + page.getByRole('button').filter({ hasText: 'Create backup schedule' }) + ).toBeVisible(); + }); + + test('Hide schedule button from wizard when not allowed to create backups', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ['database-clusters', 'create', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ]); + await mockEngines(page, namespace); + await mockStorages(page, namespace); + await mockClusters(page, namespace); + await page.goto('/databases'); + await page.getByTestId('add-db-cluster-button').click(); + await expect( + page.getByText('Basic information', { exact: true }) + ).toBeVisible(); + await moveForward(page); + await moveForward(page); + await expect( + page.getByRole('button').filter({ hasText: 'Create backup schedule' }) + ).not.toBeVisible(); + }); + + test('Schedule creation from DB details', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ['database-clusters', 'update', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-cluster-backups', 'create', `${namespace}/*`], + ]); + await mockEngines(page, namespace); + await mockStorages(page, namespace); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await page.getByText('Create backup').click(); + await expect(page.getByText('Schedule', { exact: true })).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByTestId('scheduled-backups').click(); + await expect(page.getByText(MOCK_SCHEDULE_NAME)).toBeVisible(); + await expect(page.getByTestId('edit-schedule-button')).toBeVisible(); + await expect(page.getByTestId('delete-schedule-button')).toBeVisible(); + }); + + test('Hide schedule button from DB details when not allowed to create backups', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ['database-clusters', 'update', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ]); + await mockEngines(page, namespace); + await mockStorages(page, namespace); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await expect(page.getByText('Overview')).toBeVisible(); + await expect(page.getByText('Create backup')).not.toBeVisible(); + await page.getByTestId('scheduled-backups').click(); + await expect(page.getByTestId('edit-schedule-button')).not.toBeVisible(); + await expect(page.getByTestId('delete-schedule-button')).not.toBeVisible(); + }); + + test('Hide schedule button from DB details when not allowed to update DB', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['database-engines', '*', `${namespace}/*`], + ['database-clusters', 'read', `${namespace}/*`], + ['backup-storages', 'read', `${namespace}/*`], + ['database-cluster-backups', 'create', `${namespace}/*`], + ]); + await mockEngines(page, namespace); + await mockStorages(page, namespace); + await mockClusters(page, namespace); + await mockBackups(page, namespace); + await page.goto(`/databases/${namespace}/${MOCK_CLUSTER_NAME}/backups`); + await page.getByText('Create backup').click(); + await expect(page.getByText('Now', { exact: true })).toBeVisible(); + await expect(page.getByText('Schedule', { exact: true })).not.toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByTestId('scheduled-backups').click(); + await expect(page.getByTestId('edit-schedule-button')).not.toBeVisible(); + await expect(page.getByTestId('delete-schedule-button')).not.toBeVisible(); + }); +}); diff --git a/ui/apps/everest/.e2e/pr/rbac/storages.e2e.ts b/ui/apps/everest/.e2e/pr/rbac/storages.e2e.ts new file mode 100644 index 000000000..22cca3185 --- /dev/null +++ b/ui/apps/everest/.e2e/pr/rbac/storages.e2e.ts @@ -0,0 +1,69 @@ +import { getTokenFromLocalStorage } from '@e2e/utils/localStorage'; +import { getNamespacesFn } from '@e2e/utils/namespaces'; +import { setRBACPermissionsK8S } from '@e2e/utils/rbac-cmd-line'; +import { expect, test } from '@playwright/test'; +import { MOCK_STORAGE_NAME, mockStorages } from './utils'; + +const { CI_USER: user } = process.env; + +test.describe('Backup Storages RBAC', () => { + let namespace = ''; + test.beforeAll(async ({ request }) => { + const token = await getTokenFromLocalStorage(); + const namespaces = await getNamespacesFn(token, request); + namespace = namespaces[0]; + }); + + test('Show Backup Storages', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['backup-storages', 'read', `${namespace}/${MOCK_STORAGE_NAME}`], + ]); + await mockStorages(page, namespace); + await page.goto('/settings/storage-locations'); + await expect(page.getByText(MOCK_STORAGE_NAME)).toBeVisible(); + await expect(page.getByTestId('add-backup-storage')).not.toBeVisible(); + }); + + test('Hide Backup Storages when no namespaces allowed', async ({ page }) => { + await setRBACPermissionsK8S([ + ['backup-storages', 'read', `${namespace}/${MOCK_STORAGE_NAME}`], + ]); + await mockStorages(page, namespace); + await page.goto('/settings/storage-locations'); + await expect(page.getByRole('table')).toBeVisible(); + await expect(page.getByText(MOCK_STORAGE_NAME)).not.toBeVisible(); + }); + + test('Hide Backup Storages when no storage allowed', async ({ page }) => { + await setRBACPermissionsK8S([['namespaces', 'read', namespace]]); + await mockStorages(page, namespace); + await page.goto('/settings/storage-locations'); + await expect(page.getByText(MOCK_STORAGE_NAME)).not.toBeVisible(); + }); + + test('Create Backup Storages', async ({ page }) => { + await setRBACPermissionsK8S([ + ['namespaces', 'read', namespace], + ['backup-storages', 'read', `${namespace}/${MOCK_STORAGE_NAME}`], + ['backup-storages', 'create', `${namespace}/*`], + ]); + await mockStorages(page, namespace); + await page.goto('/settings/storage-locations'); + await expect(page.getByText(MOCK_STORAGE_NAME)).toBeVisible(); + await expect(page.getByTestId('add-backup-storage')).toBeVisible(); + }); + + test('Hide create Backup Storages button when no namespace available', async ({ + page, + }) => { + await setRBACPermissionsK8S([ + ['backup-storages', 'read', `${namespace}/${MOCK_STORAGE_NAME}`], + ['backup-storages', 'create', `${namespace}/*`], + ]); + await mockStorages(page, namespace); + await page.goto('/settings/storage-locations'); + await expect(page.getByRole('table')).toBeVisible(); + await expect(page.getByTestId('add-backup-storage')).not.toBeVisible(); + }); +}); diff --git a/ui/apps/everest/.e2e/pr/rbac/utils.ts b/ui/apps/everest/.e2e/pr/rbac/utils.ts new file mode 100644 index 000000000..40fad1ade --- /dev/null +++ b/ui/apps/everest/.e2e/pr/rbac/utils.ts @@ -0,0 +1,183 @@ +import { Page } from '@playwright/test'; + +type ClusterConfigOptions = { + enableSchedules?: boolean; + enableMonitoring?: boolean; +}; + +export const MOCK_CLUSTER_NAME = 'cluster-1'; +export const MOCK_BACKUP_NAME = 'backup-1'; +export const MOCK_STORAGE_NAME = 'storage-1'; +export const MOCK_SCHEDULE_NAME = 'schedule-1'; +export const MOCK_MONITORING_CONFIG_NAME = 'monitoring-1'; + +const DEFAULT_CLUSTER_CONFIG_OPTIONS: ClusterConfigOptions = { + enableMonitoring: true, + enableSchedules: true, +}; + +const getClusterConfig = ( + namespace: string, + options?: ClusterConfigOptions +) => { + const mergedOptions: ClusterConfigOptions = { + ...DEFAULT_CLUSTER_CONFIG_OPTIONS, + ...options, + }; + + return { + metadata: { + name: MOCK_CLUSTER_NAME, + namespace, + }, + spec: { + engine: { + replicas: 1, + type: 'pxc', + storage: { + size: '1Gi', + }, + resources: { + cpu: '1', + memory: '1Gi', + }, + }, + proxy: { + replicas: 1, + resources: { + cpu: '1', + memory: '1Gi', + }, + }, + backup: { + enabled: !!mergedOptions.enableSchedules, + ...(mergedOptions.enableSchedules && { + schedules: [ + { + enabled: true, + schedule: '0 0 * * *', + backupStorageName: MOCK_STORAGE_NAME, + name: MOCK_SCHEDULE_NAME, + }, + ], + }), + }, + ...(mergedOptions.enableMonitoring && { + monitoring: { + monitoringConfigName: MOCK_MONITORING_CONFIG_NAME, + }, + }), + }, + }; +}; + +export const mockEngines = async (page: Page, namespace: string) => + page.route(`/v1/namespaces/${namespace}/database-engines`, async (route) => { + await route.fulfill({ + json: { + items: [ + { + metadata: { + name: 'percona-xtradb-cluster-operator', + namespace: namespace, + }, + spec: { + type: 'pxc', + }, + status: { + status: 'installed', + availableVersions: { + engine: { + '8.0.36-28.1': { + status: 'available', + }, + }, + }, + pendingOperatorUpgrades: [ + { + targetVersion: '1.15.0', + }, + ], + }, + }, + ], + }, + }); + }); + +export const mockClusters = async ( + page: Page, + namespace: string, + options?: ClusterConfigOptions +) => { + await page.route( + `/v1/namespaces/${namespace}/database-clusters/${MOCK_CLUSTER_NAME}/pitr`, + async (route) => { + await route.fulfill({}); + } + ); + + await page.route( + `/v1/namespaces/${namespace}/database-clusters/${MOCK_CLUSTER_NAME}`, + async (route) => { + await route.fulfill({ + json: getClusterConfig(namespace, options), + }); + } + ); + + await page.route( + `/v1/namespaces/${namespace}/database-clusters`, + async (route) => { + await route.fulfill({ + json: { + items: [getClusterConfig(namespace, options)], + }, + }); + } + ); +}; + +export const mockBackups = (page: Page, namespace: string) => + page.route( + `/v1/namespaces/${namespace}/database-clusters/${MOCK_CLUSTER_NAME}/backups`, + async (route) => { + await route.fulfill({ + json: { + items: [ + { + metadata: { + name: 'backup-1', + namespace, + }, + spec: { + backupStorageName: MOCK_BACKUP_NAME, + dbClusterName: MOCK_CLUSTER_NAME, + }, + status: { + created: '2024-12-20T00:54:18Z', + completed: '2024-12-20T01:00:13Z', + state: 'Succeeded', + }, + }, + ], + }, + }); + } + ); + +export const mockStorages = (page: Page, namespace: string) => + page.route(`/v1/namespaces/${namespace}/backup-storages`, async (route) => { + await route.fulfill({ + json: [ + { + bucketName: 'bucket-1', + name: MOCK_STORAGE_NAME, + namespace, + region: 'us-east-1', + type: 's3', + url: 's3://bucket-1', + }, + ], + }); + }); diff --git a/ui/apps/everest/.e2e/auth.setup.ts b/ui/apps/everest/.e2e/setup/auth.setup.ts similarity index 95% rename from ui/apps/everest/.e2e/auth.setup.ts rename to ui/apps/everest/.e2e/setup/auth.setup.ts index 16f252e94..43e615c30 100644 --- a/ui/apps/everest/.e2e/auth.setup.ts +++ b/ui/apps/everest/.e2e/setup/auth.setup.ts @@ -22,7 +22,7 @@ setup('Login', async ({ page }) => { await page.getByTestId('text-input-username').fill(CI_USER); await page.getByTestId('text-input-password').fill(CI_PASSWORD); await page.getByTestId('login-button').click(); - await expect(page.getByText('Create database')).toBeVisible({ + await expect(page.getByTestId('user-appbar-button')).toBeVisible({ timeout: TIMEOUTS.ThirtySeconds, }); diff --git a/ui/apps/everest/.e2e/global.setup.ts b/ui/apps/everest/.e2e/setup/global.setup.ts similarity index 93% rename from ui/apps/everest/.e2e/global.setup.ts rename to ui/apps/everest/.e2e/setup/global.setup.ts index 1f179b0a8..c1933087e 100644 --- a/ui/apps/everest/.e2e/global.setup.ts +++ b/ui/apps/everest/.e2e/setup/global.setup.ts @@ -15,14 +15,14 @@ import { test as setup, expect, APIResponse } from '@playwright/test'; import 'dotenv/config'; -import { getTokenFromLocalStorage } from './utils/localStorage'; -import { getBucketNamespacesMap } from './constants'; +import { getTokenFromLocalStorage } from '../utils/localStorage'; +import { getBucketNamespacesMap } from '../constants'; +import { saveOldRBACPermissions } from '@e2e/utils/rbac-cmd-line'; const { EVEREST_LOCATION_ACCESS_KEY, EVEREST_LOCATION_SECRET_KEY, EVEREST_LOCATION_REGION, EVEREST_LOCATION_URL, - EVEREST_BUCKETS_NAMESPACES_MAP, } = process.env; const doBackupCall = async (fn: () => Promise, retries = 3) => { @@ -124,6 +124,10 @@ setup('Close modal permanently', async ({ page }) => { await page.context().storageState({ path: 'user.json' }); }); +setup('Save old RBAC permissions', async ({ request }) => { + await saveOldRBACPermissions(); +}); + // setup('Monitoring setup', async ({ request }) => { // await createMonitoringInstance(request, testMonitoringName); // await createMonitoringInstance(request, testMonitoringName2); diff --git a/ui/apps/everest/.e2e/setup/rbac.setup.ts b/ui/apps/everest/.e2e/setup/rbac.setup.ts new file mode 100644 index 000000000..41c9ab0d4 --- /dev/null +++ b/ui/apps/everest/.e2e/setup/rbac.setup.ts @@ -0,0 +1,10 @@ +import { execSync } from 'child_process'; +import { switchUser } from '@e2e/utils/user'; +import { test as setup } from '@playwright/test'; + +setup('RBAC setup', async ({ page }) => { + execSync( + `kubectl patch configmap/everest-rbac --namespace everest-system --type merge -p '{"data":{"enabled": "true", "policy.csv":"g,${process.env.RBAC_USER},role:admin"}}'` + ); + await switchUser(page, process.env.RBAC_USER, process.env.RBAC_PASSWORD); +}); diff --git a/ui/apps/everest/.e2e/global.teardown.ts b/ui/apps/everest/.e2e/teardown/global.teardown.ts similarity index 94% rename from ui/apps/everest/.e2e/global.teardown.ts rename to ui/apps/everest/.e2e/teardown/global.teardown.ts index d81e479e6..08e0c0731 100644 --- a/ui/apps/everest/.e2e/global.teardown.ts +++ b/ui/apps/everest/.e2e/teardown/global.teardown.ts @@ -14,8 +14,8 @@ // limitations under the License. import { test as setup, expect } from '@playwright/test'; -import { getTokenFromLocalStorage } from './utils/localStorage'; -import { getBucketNamespacesMap } from './constants'; +import { getTokenFromLocalStorage } from '../utils/localStorage'; +import { getBucketNamespacesMap } from '../constants'; setup.describe.serial('Teardown', () => { setup('Delete backup storage', async ({ request }) => { diff --git a/ui/apps/everest/.e2e/teardown/rbac.teardown.ts b/ui/apps/everest/.e2e/teardown/rbac.teardown.ts new file mode 100644 index 000000000..2c49058cc --- /dev/null +++ b/ui/apps/everest/.e2e/teardown/rbac.teardown.ts @@ -0,0 +1,8 @@ +import { restoreOldRBACPermissions } from '@e2e/utils/rbac-cmd-line'; +import { switchUser } from '@e2e/utils/user'; +import { test as setup } from '@playwright/test'; + +setup('RBAC teardown', async ({ page }) => { + await switchUser(page, process.env.CI_USER, process.env.CI_PASSWORD); + await restoreOldRBACPermissions(); +}); diff --git a/ui/apps/everest/.e2e/utils/db-wizard.ts b/ui/apps/everest/.e2e/utils/db-wizard.ts index a38467a71..6ab3857d3 100644 --- a/ui/apps/everest/.e2e/utils/db-wizard.ts +++ b/ui/apps/everest/.e2e/utils/db-wizard.ts @@ -15,8 +15,12 @@ export const storageLocationAutocompleteEmptyValidationCheck = async ( ).toBeVisible(); }; -export const moveForward = (page: Page) => - page.getByTestId('db-wizard-continue-button').click(); +export const moveForward = async (page: Page) => { + await expect( + page.getByTestId('db-wizard-continue-button') + ).not.toBeDisabled(); + await page.getByTestId('db-wizard-continue-button').click(); +}; export const moveBack = (page: Page) => page.getByTestId('db-wizard-previous-button').click(); diff --git a/ui/apps/everest/.e2e/utils/rbac-cmd-line.ts b/ui/apps/everest/.e2e/utils/rbac-cmd-line.ts new file mode 100644 index 000000000..6c8d7c3a9 --- /dev/null +++ b/ui/apps/everest/.e2e/utils/rbac-cmd-line.ts @@ -0,0 +1,28 @@ +import { execSync } from 'child_process'; + +const OLD_RBAC_FILE = 'old_rbac_permissions'; + +export const saveOldRBACPermissions = async () => { + const command = `kubectl get configmap everest-rbac --namespace everest-system -o jsonpath="{.data}" > ${OLD_RBAC_FILE}`; + execSync(command); +}; + +export const restoreOldRBACPermissions = async () => { + const oldRbacFileContent = execSync(`cat ${OLD_RBAC_FILE}`).toString(); + const command = `kubectl patch configmap/everest-rbac --namespace everest-system --type merge -p '{"data":${oldRbacFileContent}}'`; + execSync(command); +}; + +export const setRBACPermissionsK8S = async ( + permissions: [string, string, string][] = [] +) => { + const command = `kubectl patch configmap/everest-rbac --namespace everest-system --type merge -p '{"data":{"enabled": "${permissions !== undefined}", "policy.csv":"g,${process.env.RBAC_USER},role:e2e-rbac-user\\n${permissions.map((p) => `p,role:e2e-rbac-user,${p.join(',')}`).join('\\n')}"}}'`; + execSync(command); + + // We need this to give time for the RBAC to be applied, or headless tests might fail for being too fast + return new Promise((resolve) => + setTimeout(() => { + resolve(); + }, 500) + ); +}; diff --git a/ui/apps/everest/.e2e/utils/user.ts b/ui/apps/everest/.e2e/utils/user.ts new file mode 100644 index 000000000..6d913b301 --- /dev/null +++ b/ui/apps/everest/.e2e/utils/user.ts @@ -0,0 +1,22 @@ +import { STORAGE_STATE_FILE, TIMEOUTS } from '@e2e/constants'; +import { Page, expect } from '@playwright/test'; + +export const switchUser = async ( + page: Page, + user: string, + password: string +) => { + await page.goto('/'); + await page.getByTestId('user-appbar-button').click(); + await page.getByRole('menuitem').filter({ hasText: 'Log out' }).click(); + await expect( + page.getByRole('button').filter({ hasText: 'Log in' }) + ).toBeVisible(); + await page.getByTestId('text-input-username').fill(user); + await page.getByTestId('text-input-password').fill(password); + await page.getByTestId('login-button').click(); + await expect(page.getByTestId('user-appbar-button')).toBeVisible({ + timeout: TIMEOUTS.ThirtySeconds, + }); + await page.context().storageState({ path: STORAGE_STATE_FILE }); +}; diff --git a/ui/apps/everest/package.json b/ui/apps/everest/package.json index af90e63de..3b4240bfe 100644 --- a/ui/apps/everest/package.json +++ b/ui/apps/everest/package.json @@ -8,7 +8,7 @@ "test": "vitest run", "test:watch": "vitest", "pre-e2e": "rm -rf tests-out test-results && tsc --incremental --noImplicitAny false -p .e2e/tsconfig.json && tsc-alias -p .e2e/tsconfig.json", - "e2e": "pnpm pre-e2e && cd .e2e && playwright test --project=pr", + "e2e": "node ./setup-e2e-rbac-user.js && pnpm pre-e2e && cd .e2e && playwright test --project=pr", "e2e:upgrade": "pnpm pre-e2e && cd .e2e && playwright test --project=upgrade", "e2e:release": "pnpm pre-e2e && cd .e2e && playwright test --project=release", "build": "tsc --incremental && vite build --emptyOutDir", @@ -54,7 +54,7 @@ "devDependencies": { "@percona/eslint-config-react": "workspace:*", "@percona/prettier-config": "workspace:*", - "@playwright/test": "^1.39.0", + "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.1.4", "@testing-library/react": "^14.0.0", "@types/css-mediaquery": "^0.1.3", diff --git a/ui/apps/everest/setup-e2e-rbac-user.js b/ui/apps/everest/setup-e2e-rbac-user.js new file mode 100644 index 000000000..f93adcc77 --- /dev/null +++ b/ui/apps/everest/setup-e2e-rbac-user.js @@ -0,0 +1,19 @@ +import { execSync } from 'child_process'; +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file +const __dirname = path.dirname(__filename); // get the name of the directory +const envFilePath = path.resolve(__dirname, './.e2e', '.env'); + +dotenv.config({ path: envFilePath }); + +const createE2EUser = async () => { + // eslint-disable-next-line no-undef + const { RBAC_USER, RBAC_PASSWORD } = process.env; + const command = `../../../bin/everestctl accounts create -u${RBAC_USER} -p${RBAC_PASSWORD} || true`; + execSync(command); +}; + +createE2EUser(); diff --git a/ui/apps/everest/src/components/db-actions/db-actions.tsx b/ui/apps/everest/src/components/db-actions/db-actions.tsx index bbcb3df22..8da80ed5d 100644 --- a/ui/apps/everest/src/components/db-actions/db-actions.tsx +++ b/ui/apps/everest/src/components/db-actions/db-actions.tsx @@ -56,6 +56,12 @@ export const DbActions = ({ const namespace = dbCluster.metadata.namespace; const restoring = dbCluster.status?.status === DbClusterStatus.restoring; const deleting = dbCluster.status?.status === DbClusterStatus.deleting; + const hasSchedules = !!( + dbCluster.spec.backup && (dbCluster.spec.backup.schedules || []).length > 0 + ); + const monitoringEnabled = !!( + dbCluster.spec.monitoring && dbCluster.spec.monitoring.monitoringConfigName + ); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); setAnchorEl(event.currentTarget); @@ -84,9 +90,28 @@ export const DbActions = ({ `${namespace}/${dbClusterName}` ); + const { canCreate: canCreateBackups } = useRBACPermissions( + 'database-cluster-backups', + `${namespace}/${dbClusterName}` + ); + + const { canRead: canReadMonitoring } = useRBACPermissions( + 'monitoring-instances', + `${namespace}/${dbClusterName}` + ); + const canRestore = canCreateRestore && canReadCredentials; - const canCreateClusterFromBackup = canRestore && canCreateClusters; const noActionAvailable = !canUpdate && !canDelete && !canRestore; + let canCreateClusterFromBackup = canRestore && canCreateClusters; + + if (hasSchedules) { + canCreateClusterFromBackup = canCreateClusterFromBackup && canCreateBackups; + } + + if (monitoringEnabled) { + canCreateClusterFromBackup = + canCreateClusterFromBackup && canReadMonitoring; + } const sx = { display: 'flex', diff --git a/ui/apps/everest/src/pages/db-cluster-details/db-cluster-details.tsx b/ui/apps/everest/src/pages/db-cluster-details/db-cluster-details.tsx index 0c54d73fa..33d3fce23 100644 --- a/ui/apps/everest/src/pages/db-cluster-details/db-cluster-details.tsx +++ b/ui/apps/everest/src/pages/db-cluster-details/db-cluster-details.tsx @@ -1,6 +1,7 @@ import { Alert, Box, Skeleton, Tab, Tabs } from '@mui/material'; import { Link, + Navigate, Outlet, useMatch, useNavigate, @@ -20,14 +21,17 @@ import { Messages } from './db-cluster-details.messages'; import { useRBACPermissionRoute } from 'hooks/rbac'; import DeletedDbDialog from './deleted-db-dialog'; -export const DbClusterDetails = () => { - const { dbClusterName = '' } = useParams(); - - const { dbCluster, isLoading, clusterDeleted } = useContext(DbClusterContext); - const routeMatch = useMatch('/databases/:namespace/:dbClusterName/:tabs'); +const WithPermissionDetails = ({ + namespace, + dbClusterName, + tab, +}: { + namespace: string; + dbClusterName: string; + tab: string; +}) => { + const { dbCluster, clusterDeleted } = useContext(DbClusterContext); const navigate = useNavigate(); - const currentTab = routeMatch?.params?.tabs; - const namespace = routeMatch?.params?.namespace; useRBACPermissionRoute([ { @@ -37,25 +41,6 @@ export const DbClusterDetails = () => { }, ]); - if (isLoading) { - return ( - <> - - - - - - - - ); - } - - // We went through the array and know the cluster is not there. Safe to show 404 - if (!dbCluster) { - return ; - } - - // All clear, show the cluster data return ( <> @@ -90,7 +75,7 @@ export const DbClusterDetails = () => { dbCluster?.status?.status || DbClusterStatus.unknown )} - + { }} > { ))} - {dbCluster.status?.status === DbClusterStatus.restoring && ( + {dbCluster!.status?.status === DbClusterStatus.restoring && ( {Messages.restoringDb} @@ -135,3 +120,43 @@ export const DbClusterDetails = () => { ); }; + +export const DbClusterDetails = () => { + const { dbClusterName = '' } = useParams(); + + const { dbCluster, isLoading } = useContext(DbClusterContext); + const routeMatch = useMatch('/databases/:namespace/:dbClusterName/:tabs'); + const currentTab = routeMatch?.params?.tabs; + const namespace = routeMatch?.params?.namespace; + + if (!currentTab) { + return ; + } + + if (isLoading) { + return ( + <> + + + + + + + + ); + } + + // We went through the array and know the cluster is not there. Safe to show 404 + if (!dbCluster) { + return ; + } + + // All clear, show the cluster data + return ( + + ); +}; diff --git a/ui/apps/everest/src/router.tsx b/ui/apps/everest/src/router.tsx index 3d32f3cfc..4ec6b036f 100644 --- a/ui/apps/everest/src/router.tsx +++ b/ui/apps/everest/src/router.tsx @@ -54,11 +54,11 @@ const router = createBrowserRouter([ ), children: [ { - index: true, path: DBClusterDetailsTabs.backups, element: , }, { + index: true, path: DBClusterDetailsTabs.overview, element: , }, diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 363f72b3d..949848b96 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -129,8 +129,8 @@ importers: specifier: workspace:* version: link:../../packages/prettier-config '@playwright/test': - specifier: ^1.39.0 - version: 1.49.0 + specifier: ^1.49.1 + version: 1.49.1 '@testing-library/jest-dom': specifier: ^6.1.4 version: 6.6.3 @@ -1631,8 +1631,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@playwright/test@1.49.0': - resolution: {integrity: sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==} + '@playwright/test@1.49.1': + resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} engines: {node: '>=18'} hasBin: true @@ -3796,13 +3796,13 @@ packages: resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} engines: {node: '>=14.16'} - playwright-core@1.49.0: - resolution: {integrity: sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==} + playwright-core@1.49.1: + resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} engines: {node: '>=18'} hasBin: true - playwright@1.49.0: - resolution: {integrity: sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==} + playwright@1.49.1: + resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} engines: {node: '>=18'} hasBin: true @@ -6072,9 +6072,9 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 - '@playwright/test@1.49.0': + '@playwright/test@1.49.1': dependencies: - playwright: 1.49.0 + playwright: 1.49.1 '@popperjs/core@2.11.8': {} @@ -8396,11 +8396,11 @@ snapshots: dependencies: find-up: 6.3.0 - playwright-core@1.49.0: {} + playwright-core@1.49.1: {} - playwright@1.49.0: + playwright@1.49.1: dependencies: - playwright-core: 1.49.0 + playwright-core: 1.49.1 optionalDependencies: fsevents: 2.3.2