diff --git a/.github/workflows/fb-e2e-suite.yml b/.github/workflows/fb-e2e-suite.yml index 12f1391e5..a60f88cda 100644 --- a/.github/workflows/fb-e2e-suite.yml +++ b/.github/workflows/fb-e2e-suite.yml @@ -165,6 +165,20 @@ jobs: sha: ${{ inputs.sha || github.event.pull_request.head.sha || 'null' }} setup_services: '--database mysql' tags_for_tests: '@fb-alerting|@fb-settings' + + # TODO: this section can accomodate the tests migrated from CodeceptJS to Playwright. + alerting_api: + name: Alerting permissions API tests + uses: ./.github/workflows/runner-e2e-tests-playwright.yml + secrets: + LAUNCHABLE_TOKEN: ${{ secrets.LAUNCHABLE_TOKEN }} + with: + pmm_server_version: ${{ inputs.pmm_server_image || 'perconalab/pmm-server:3-dev-latest' }} + pmm_client_version: ${{ inputs.pmm_client_version || 'latest-tarball' }} + launchable_confidence: ${{ inputs.launchable_confidence || '100%' }} + pmm_qa_branch: ${{ github.event_name == 'pull_request' && github.head_ref || (inputs.pmm_qa_branch || 'main') }} + setup_services: '-h' + pmm_test_flag: '@alerting-api' user_and_password: name: User with changed password UI tests @@ -369,6 +383,7 @@ jobs: pmm_qa_branch: ${{ github.event_name == 'pull_request' && github.head_ref || (inputs.pmm_qa_branch || github.event.inputs.pmm_qa_branch || 'main') }} setup_services: '--database pdpgsql' pmm_test_flag: '@docker-configuration' + nomad: name: Nomad tests uses: ./.github/workflows/runner-e2e-tests-codeceptjs.yml @@ -398,3 +413,4 @@ jobs: setup_services: '--database psmdb' pmm_test_flag: '@rta' workers: 1 + diff --git a/codeceptjs-e2e/tests/configuration/permissions_test.js b/codeceptjs-e2e/tests/configuration/permissions_test.js index f081e8464..c480ee01c 100644 --- a/codeceptjs-e2e/tests/configuration/permissions_test.js +++ b/codeceptjs-e2e/tests/configuration/permissions_test.js @@ -244,7 +244,7 @@ Data(settingsReadOnly).Scenario( Cookie: `pmm_session=${cookie.value}`, }); - assert.ok(r.status === 401); + assert.ok(r.status === 403); }, ); diff --git a/codeceptjs-e2e/tests/pages/pmmDemoPage.js b/codeceptjs-e2e/tests/pages/pmmDemoPage.js index c9c885709..103ec7359 100644 --- a/codeceptjs-e2e/tests/pages/pmmDemoPage.js +++ b/codeceptjs-e2e/tests/pages/pmmDemoPage.js @@ -36,7 +36,6 @@ module.exports = { privacy: '//a[contains(text(), "Privacy")]', copyright: '//a[contains(text(), "Copyright")]', legal: '//a[contains(text(), "Legal")]', - accessDenied: '//div[contains(@class, "alert-title") and contains(text(), "Access denied.")]', title: '//span[contains(text(), "Percona Monitoring and Management")]', failedSecurityChecks: '//span[contains(text(), "Failed security check")]', noAccess: '$unauthorized', diff --git a/e2e_tests/helpers/apiEndpoints.ts b/e2e_tests/helpers/apiEndpoints.ts index 7ffa35ed3..877f8fac9 100644 --- a/e2e_tests/helpers/apiEndpoints.ts +++ b/e2e_tests/helpers/apiEndpoints.ts @@ -3,6 +3,10 @@ const apiEndpoints = { roles: '/v1/accesscontrol/roles', rolesAssign: '/v1/accesscontrol/roles:assign', }, + alerting: { + rules: '/v1/alerting/rules', + templates: '/v1/alerting/templates', + }, backups: { artifacts: '/v1/backups/artifacts', locations: '/v1/backups/locations', diff --git a/e2e_tests/tests/api/alerting/alertingPermissions.test.ts b/e2e_tests/tests/api/alerting/alertingPermissions.test.ts new file mode 100644 index 000000000..2470cbd2a --- /dev/null +++ b/e2e_tests/tests/api/alerting/alertingPermissions.test.ts @@ -0,0 +1,103 @@ +import { APIResponse, expect } from '@playwright/test'; +import pmmTest from '@fixtures/pmmTest'; +import apiEndpoints from '@helpers/apiEndpoints'; +import GrafanaHelper from '@helpers/grafana.helper'; + +// Set by the test, deleted by the afterEach hook so cleanup runs even on failure. +let viewerId: number | undefined; +let editorId: number | undefined; + +pmmTest.afterEach(async ({ request }) => { + const admin = GrafanaHelper.getAuthHeader(); + + if (viewerId) await request.delete(`graph/api/admin/users/${viewerId}`, { headers: admin }); + if (editorId) await request.delete(`graph/api/admin/users/${editorId}`, { headers: admin }); +}); + +// PMM-15138: a Viewer may list alert templates but must not create, update or delete +// templates, nor create rules. An Editor must be allowed to manage them. The auth layer +// denies a forbidden write with HTTP 403 / code 7 (PermissionDenied) before the request +// reaches the backend, so the request body is irrelevant for the Viewer assertions. +pmmTest( + 'PMM-15138 - Viewer cannot create/update/delete alert templates or rules; Editor can @alerting-api', + async ({ request }) => { + const suffix = Date.now(); + const viewer = { login: `viewer-${suffix}`, password: 'Viewer-pw-12345' }; + const editor = { login: `editor-${suffix}`, password: 'Editor-pw-12345' }; + const admin = GrafanaHelper.getAuthHeader(); + + // Created users default to the Viewer org role; the editor is promoted below. + const createUser = async ({ login, password }: { login: string; password: string }) => { + const res = await request.post('graph/api/admin/users', { + data: { login, name: login, OrgId: 1, password }, + headers: admin, + }); + + expect(res.status(), `create user ${login}`).toBe(200); + + return (await res.json()).id as number; + }; + + const templateName = `pmm15138_${suffix}`; + const templatePath = `${apiEndpoints.alerting.templates}/${templateName}`; + // Deliberately invalid: the Viewer is rejected by auth first, the Editor by the backend. + const yamlBody = { yaml: 'placeholder' }; + + // Every write the ticket forbids for a Viewer, parameterised by the caller's auth header. + const writes = (headers: Record): { label: string; send: () => Promise }[] => [ + { + label: 'POST templates', + send: () => request.post(apiEndpoints.alerting.templates, { data: yamlBody, headers }), + }, + { + label: 'PUT template', + send: () => request.put(templatePath, { data: { name: templateName, ...yamlBody }, headers }), + }, + { + label: 'DELETE template', + send: () => request.delete(templatePath, { headers }), + }, + { + label: 'POST rule', + send: () => request.post(apiEndpoints.alerting.rules, { data: { template_name: templateName }, headers }), + }, + ]; + + viewerId = await createUser(viewer); + editorId = await createUser(editor); + + const roleRes = await request.patch(`graph/api/org/users/${editorId}`, { + data: { role: 'Editor' }, + headers: admin, + }); + + expect(roleRes.status(), 'promote editor').toBe(200); + + const asViewer = GrafanaHelper.getAuthHeader(viewer.login, viewer.password); + const asEditor = GrafanaHelper.getAuthHeader(editor.login, editor.password); + + await pmmTest.step('Viewer can list alert templates', async () => { + const res = await request.get(apiEndpoints.alerting.templates, { headers: asViewer }); + + expect(res.status()).toBe(200); + }); + + await pmmTest.step('Viewer is denied every alerting write with 403', async () => { + for (const { label, send } of writes(asViewer)) { + const res = await send(); + + expect(res.status(), `viewer ${label}`).toBe(403); + // 7 == codes.PermissionDenied + expect((await res.json()).code, `viewer ${label} code`).toBe(7); + } + }); + + await pmmTest.step('Editor is authorized for every alerting write (not 403)', async () => { + for (const { label, send } of writes(asEditor)) { + const res = await send(); + + expect(res.status(), `editor ${label}`).not.toBe(403); + } + }); + }, +); diff --git a/e2e_tests/tsconfig.json b/e2e_tests/tsconfig.json index f51cf29d7..c9e16b283 100644 --- a/e2e_tests/tsconfig.json +++ b/e2e_tests/tsconfig.json @@ -17,22 +17,22 @@ "isolatedModules": true, "paths": { "@fixtures/*": [ - "fixtures/*" + "./fixtures/*" ], "@interfaces/*": [ - "interfaces/*" + "./interfaces/*" ], "@helpers/*": [ - "helpers/*" + "./helpers/*" ], "@components/*": [ - "components/*" + "./components/*" ], "@pages/*": [ - "pages/*" + "./pages/*" ], "@api/*": [ - "api/*" + "./api/*" ], "@valkey": [ "./pages/dashboards/valkey/index.ts"