From 43fccb8c8fbaeb89eb0c792a263bfe60ef9b38eb Mon Sep 17 00:00:00 2001 From: JComins000 Date: Mon, 28 Oct 2024 09:46:51 -0700 Subject: [PATCH] test: rbac config [TESTENG-108] (#10032) Co-authored-by: John Kim --- .../devcluster/react-rbac.devcluster.yaml | 47 +++++++++++++++++ .circleci/real_config.yml | 24 ++++++++- .../src/e2e/fixtures/api.roles.fixture.ts | 51 +++++++++++++++++++ webui/react/src/e2e/fixtures/auth.fixture.ts | 7 ++- .../react/src/e2e/fixtures/global-fixtures.ts | 16 +++++- webui/react/src/e2e/fixtures/user.fixture.ts | 27 ++++++---- .../src/e2e/models/pages/DefaultRoute.ts | 11 ++++ webui/react/src/e2e/tests/auth.spec.ts | 6 ++- webui/react/src/e2e/tests/navigation.spec.ts | 5 +- webui/react/src/e2e/utils/rbac.ts | 18 +++++++ 10 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 .circleci/devcluster/react-rbac.devcluster.yaml create mode 100644 webui/react/src/e2e/fixtures/api.roles.fixture.ts create mode 100644 webui/react/src/e2e/models/pages/DefaultRoute.ts create mode 100644 webui/react/src/e2e/utils/rbac.ts diff --git a/.circleci/devcluster/react-rbac.devcluster.yaml b/.circleci/devcluster/react-rbac.devcluster.yaml new file mode 100644 index 00000000000..af6fb92f9c1 --- /dev/null +++ b/.circleci/devcluster/react-rbac.devcluster.yaml @@ -0,0 +1,47 @@ +temp_dir: /tmp/priority_scheduler + +stages: + - db: + name: db + + - master: + pre: + - sh: make -C tools prep-root + config_file: + security: + initial_user_password: $INITIAL_USER_PASSWORD + authz: + rbac_ui_enabled: true + port: 8082 + db: + host: localhost + port: 5432 + password: postgres + user: postgres + name: determined + checkpoint_storage: + type: shared_fs + host_path: /tmp + storage_path: determined-cp + log: + level: debug + root: tools/build + cache: + cache_dir: /tmp/determined-cache + launch_error: false + telemetry: + enabled: false + resource_manager: + default_aux_resource_pool: default + default_compute_resource_pool: default + type: agent + + - agent: + name: agent1 + config_file: + master_host: 127.0.0.1 + master_port: 8082 + agent_id: agent1 + container_master_host: $DOCKER_LOCALHOST + agent_reconnect_attempts: 24 + agent_reconnect_backoff: 5 diff --git a/.circleci/real_config.yml b/.circleci/real_config.yml index 56961ac63c2..25a4bab9dc5 100644 --- a/.circleci/real_config.yml +++ b/.circleci/real_config.yml @@ -2116,6 +2116,12 @@ jobs: test-e2e-react: parameters: + devcluster-config: + type: enum + enum: + - react.devcluster.yaml + - react-rbac.devcluster.yaml + - react-sso.devcluster.yaml ee: type: boolean default: false @@ -2148,7 +2154,7 @@ jobs: - run: make -C agent get-deps - install-devcluster - start-devcluster: - devcluster-config: react.devcluster.yaml + devcluster-config: <> - run: make -C webui/react get-playwright-ci - run: SERVER_ADDRESS=${PW_SERVER_ADDRESS} npm run build --prefix webui/react - wait-for-master: @@ -4112,6 +4118,7 @@ workflows: dev-mode: true - test-e2e-react: name: test-e2e-react-oss + devcluster-config: react.devcluster.yaml requires: - build-go-oss context: @@ -4201,6 +4208,7 @@ workflows: - test-e2e-react: name: test-e2e-react-ee ee: true + devcluster-config: react-rbac.devcluster.yaml requires: - build-go-ee context: @@ -4208,6 +4216,19 @@ workflows: - github-read - dev-ci-cluster-default-user-credentials filters: *any-upstream + # this will be used once sso tests are prioritized (after rbac tests) + # - test-e2e-react: + # name: test-e2e-react-ee-sso + # ee: true + # devcluster-config: react-sso.devcluster.yaml + # playwright-options: "-g sso" + # requires: + # - build-go-ee + # context: + # - playwright + # - github-read + # - dev-ci-cluster-default-user-credentials + # filters: *any-upstream - build-docs: requires: - build-helm @@ -5443,6 +5464,7 @@ workflows: context: github-read - test-e2e-react: ee: << pipeline.parameters.ee >> + devcluster-config: react.devcluster.yaml playwright-options: << pipeline.parameters.e2e-react >> requires: - build-go diff --git a/webui/react/src/e2e/fixtures/api.roles.fixture.ts b/webui/react/src/e2e/fixtures/api.roles.fixture.ts new file mode 100644 index 00000000000..3801118d6cd --- /dev/null +++ b/webui/react/src/e2e/fixtures/api.roles.fixture.ts @@ -0,0 +1,51 @@ +import streamConsumers from 'stream/consumers'; + +import _ from 'lodash'; + +import { RBACApi, V1AssignRolesRequest, V1AssignRolesResponse } from 'services/api-ts-sdk/api'; + +import { ApiAuthFixture } from './api.auth.fixture'; + +export class ApiRoleFixture { + readonly apiAuth: ApiAuthFixture; + constructor(apiAuth: ApiAuthFixture) { + this.apiAuth = apiAuth; + } + + new({ roleProps = {} } = {}): V1AssignRolesRequest { + const defaults = {}; + return { + ...defaults, + ...roleProps, + }; + } + + private static normalizeUrl(url: string): string { + if (url.endsWith('/')) { + return url.substring(0, url.length - 1); + } + return url; + } + + private async startRoleRequest(): Promise { + return new RBACApi( + { apiKey: await this.apiAuth.getBearerToken() }, + ApiRoleFixture.normalizeUrl(this.apiAuth.baseURL), + fetch, + ); + } + + async createAssignment(req: V1AssignRolesRequest): Promise { + const roleResp = await (await this.startRoleRequest()) + .assignRoles(req, {}) + .catch(async function (error) { + const respBody = await streamConsumers.text(error.body); + throw new Error( + `Create Assignment Failed: ${error.status} Request: ${JSON.stringify( + req, + )} Response: ${respBody}`, + ); + }); + return _.merge(req, roleResp); + } +} diff --git a/webui/react/src/e2e/fixtures/auth.fixture.ts b/webui/react/src/e2e/fixtures/auth.fixture.ts index b85007f3c5c..52bff6a9f16 100644 --- a/webui/react/src/e2e/fixtures/auth.fixture.ts +++ b/webui/react/src/e2e/fixtures/auth.fixture.ts @@ -1,6 +1,7 @@ import { Page } from '@playwright/test'; import { expect } from 'e2e/fixtures/global-fixtures'; +import { DefaultRoute } from 'e2e/models/pages/DefaultRoute'; import { SignIn } from 'e2e/models/pages/SignIn'; import { password, username } from 'e2e/utils/envVars'; @@ -18,7 +19,7 @@ export class AuthFixture { } async login({ - expectedURL = /dashboard/, + expectedURL, username = this.#USERNAME, password = this.#PASSWORD, }: { @@ -35,7 +36,9 @@ export class AuthFixture { await detAuth.username.pwLocator.fill(username); await detAuth.password.pwLocator.fill(password); await detAuth.submit.pwLocator.click(); - await this.#page.waitForURL(expectedURL); + + const defaultPage = new DefaultRoute(this.#page); + await this.#page.waitForURL(expectedURL ?? defaultPage.url); } async logout(): Promise { diff --git a/webui/react/src/e2e/fixtures/global-fixtures.ts b/webui/react/src/e2e/fixtures/global-fixtures.ts index df4b0208fd9..be4cbf784a7 100644 --- a/webui/react/src/e2e/fixtures/global-fixtures.ts +++ b/webui/react/src/e2e/fixtures/global-fixtures.ts @@ -15,6 +15,7 @@ import { import { ApiAuthFixture } from './api.auth.fixture'; import { ApiProjectFixture } from './api.project.fixture'; +import { ApiRoleFixture } from './api.roles.fixture'; import { ApiUserFixture } from './api.user.fixture'; import { ApiWorkspaceFixture } from './api.workspace.fixture'; import { AuthFixture } from './auth.fixture'; @@ -39,6 +40,7 @@ type CustomWorkerFixtures = { newProject: { request: V1PostProjectRequest; response: V1PostProjectResponse }; backgroundApiAuth: ApiAuthFixture; backgroundApiUser: ApiUserFixture; + backgroundApiRole: ApiRoleFixture; backgroundApiWorkspace: ApiWorkspaceFixture; backgroundApiProject: ApiProjectFixture; backgroundAuthedPage: Page; @@ -128,6 +130,13 @@ export const test = baseTest.extend({ }, { scope: 'worker' }, ], + backgroundApiRole: [ + async ({ backgroundApiAuth }, use) => { + const backgroundApiRole = new ApiRoleFixture(backgroundApiAuth); + await use(backgroundApiRole); + }, + { scope: 'worker' }, + ], /** * Allows calling the user api without a page so that it can run in beforeAll(). You will need to get a bearer * token by calling backgroundApiUser.apiAuth.loginAPI(). This will also provision a page in the background which @@ -181,7 +190,7 @@ export const test = baseTest.extend({ * Creates an admin and logs in as that admin for the duraction of the test suite */ newAdmin: [ - async ({ backgroundApiUser }, use, workerInfo) => { + async ({ backgroundApiUser, backgroundApiRole }, use, workerInfo) => { const request = backgroundApiUser.new({ userProps: { user: { @@ -192,6 +201,11 @@ export const test = baseTest.extend({ }, }); const adminUser = await backgroundApiUser.createUser(request); + await backgroundApiRole.createAssignment({ + userRoleAssignments: [ + { roleAssignment: { role: { roleId: 1 } }, userId: adminUser.user!.id! }, + ], + }); await use({ request, response: adminUser }); await backgroundApiUser.patchUser(adminUser.user!.id!, { active: false }); }, diff --git a/webui/react/src/e2e/fixtures/user.fixture.ts b/webui/react/src/e2e/fixtures/user.fixture.ts index a941c5013aa..4f5982a334d 100644 --- a/webui/react/src/e2e/fixtures/user.fixture.ts +++ b/webui/react/src/e2e/fixtures/user.fixture.ts @@ -4,6 +4,7 @@ import { expect } from 'e2e/fixtures/global-fixtures'; import { UserManagement } from 'e2e/models/pages/Admin/UserManagement'; import { safeName } from 'e2e/utils/naming'; import { repeatWithFallback } from 'e2e/utils/polling'; +import { isRbacEnabled } from 'e2e/utils/rbac'; import { TestUser } from 'e2e/utils/users'; interface CreateUserFields { @@ -43,16 +44,18 @@ export class UserFixture { ); } - const checkedAttribute = - await this.userManagementPage.createUserModal.adminToggle.pwLocator.getAttribute( - 'aria-checked', - ); - if (checkedAttribute === null) { - throw new Error('Expected attribute aria-checked to be present.'); - } - const adminState = JSON.parse(checkedAttribute); - if (!!formValues.admin !== adminState) { - await this.userManagementPage.createUserModal.adminToggle.pwLocator.click(); + if (!isRbacEnabled()) { + const checkedAttribute = + await this.userManagementPage.createUserModal.adminToggle.pwLocator.getAttribute( + 'aria-checked', + ); + if (checkedAttribute === null) { + throw new Error('Expected attribute aria-checked to be present.'); + } + const adminState = JSON.parse(checkedAttribute); + if (!!formValues.admin !== adminState) { + await this.userManagementPage.createUserModal.adminToggle.pwLocator.click(); + } } // password and username are required to create a user; if these are filled, submit should be enabled @@ -180,7 +183,9 @@ export class UserFixture { } else { await row.user.alias.pwLocator.waitFor({ state: 'hidden' }); } - await expect(row.role.pwLocator).toContainText(user.admin ? 'Admin' : 'Member'); + if (!isRbacEnabled()) { + await expect(row.role.pwLocator).toContainText(user.admin ? 'Admin' : 'Member'); + } await expect(row.status.pwLocator).toContainText(user.active ? 'Active' : 'Inactive'); } diff --git a/webui/react/src/e2e/models/pages/DefaultRoute.ts b/webui/react/src/e2e/models/pages/DefaultRoute.ts new file mode 100644 index 00000000000..887ab9adcf7 --- /dev/null +++ b/webui/react/src/e2e/models/pages/DefaultRoute.ts @@ -0,0 +1,11 @@ +import { DeterminedPage } from 'e2e/models/common/base/BasePage'; +import { isRbacEnabled } from 'e2e/utils/rbac'; + +/** + * Represents the DefaultRoute page from src/pages/DefaultRoute.tsx + */ +export class DefaultRoute extends DeterminedPage { + // only redirects to Dashboard or WorkspaceList page + readonly title = isRbacEnabled() ? 'Workspaces' : 'Home'; + readonly url = isRbacEnabled() ? /workspaces/ : /dashboard/; +} diff --git a/webui/react/src/e2e/tests/auth.spec.ts b/webui/react/src/e2e/tests/auth.spec.ts index 01cbe901266..a5f84a736f3 100644 --- a/webui/react/src/e2e/tests/auth.spec.ts +++ b/webui/react/src/e2e/tests/auth.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from 'e2e/fixtures/global-fixtures'; import { Cluster } from 'e2e/models/pages/Cluster'; +import { DefaultRoute } from 'e2e/models/pages/DefaultRoute'; import { SignIn } from 'e2e/models/pages/SignIn'; test.describe('Authentication', () => { @@ -13,8 +14,9 @@ test.describe('Authentication', () => { test('Login and Logout', async ({ page, auth }) => { await test.step('Login', async () => { await auth.login(); - await expect(page).toHaveDeterminedTitle('Home'); - await expect(page).toHaveURL(/dashboard/); + const defaultPage = new DefaultRoute(page); + await expect(page).toHaveDeterminedTitle(defaultPage.title); + await expect(page).toHaveURL(defaultPage.url); }); await test.step('Logout', async () => { diff --git a/webui/react/src/e2e/tests/navigation.spec.ts b/webui/react/src/e2e/tests/navigation.spec.ts index 23b8b3ebaad..7217b902e29 100644 --- a/webui/react/src/e2e/tests/navigation.spec.ts +++ b/webui/react/src/e2e/tests/navigation.spec.ts @@ -7,9 +7,8 @@ test.describe('Navigation', () => { // we need any page to access the sidebar, and i haven't modeled the homepage yet const userManagementPage = new UserManagement(authedPage); - await test.step('Login steps', async () => { - await expect(authedPage).toHaveDeterminedTitle('Home'); - await expect(authedPage).toHaveURL(/dashboard/); + await test.step('Load page', async () => { + await userManagementPage.goto(); }); await test.step('Uncategorized', async () => { diff --git a/webui/react/src/e2e/utils/rbac.ts b/webui/react/src/e2e/utils/rbac.ts new file mode 100644 index 00000000000..2af45c09ca0 --- /dev/null +++ b/webui/react/src/e2e/utils/rbac.ts @@ -0,0 +1,18 @@ +import { detExecSync } from 'e2e/utils/detCLI'; + +let rbacEnabled: boolean; + +const getRbacEnabled = (): boolean => { + const masterInfo = detExecSync('master info'); + const regexp = /rbacEnabled:\s*(?true|false)/; + + const { groups } = regexp.exec(masterInfo) || {}; + + return groups?.enabled === 'true'; +}; + +export const isRbacEnabled = (): boolean => { + if (rbacEnabled === undefined) rbacEnabled = getRbacEnabled(); + + return rbacEnabled; +};