From 40b7ac0e1bd32e9db8b33830e14edc6d052a21a3 Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Fri, 6 Mar 2026 08:41:21 -0500 Subject: [PATCH 1/6] feat(playwright): add the rbac and auth api helpers Signed-off-by: Patrick Knight --- src/playwright/helpers/auth-api-helper.ts | 42 ++++++++++++ src/playwright/helpers/index.ts | 2 + src/playwright/helpers/rbac-api-helper.ts | 83 +++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/playwright/helpers/auth-api-helper.ts create mode 100644 src/playwright/helpers/rbac-api-helper.ts diff --git a/src/playwright/helpers/auth-api-helper.ts b/src/playwright/helpers/auth-api-helper.ts new file mode 100644 index 0000000..92b2713 --- /dev/null +++ b/src/playwright/helpers/auth-api-helper.ts @@ -0,0 +1,42 @@ +import { Page } from "@playwright/test"; + +export class AuthApiHelper { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async getToken( + provider: string = "oidc", + environment: string = "production", + ) { + try { + const response = await this.page.request.get( + `/api/auth/${provider}/refresh?optional=&scope=&env=${environment}`, + { + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + "x-requested-with": "XMLHttpRequest", + }, + }, + ); + + if (!response.ok()) { + throw new Error(`HTTP error! Status: ${response.status()}`); + } + + const body = await response.json(); + + if (typeof body?.backstageIdentity?.token === "string") { + return body.backstageIdentity.token; + } else { + throw new TypeError("Token not found in response body"); + } + } catch (error) { + console.error("Failed to retrieve the token:", error); + + throw error; + } + } +} diff --git a/src/playwright/helpers/index.ts b/src/playwright/helpers/index.ts index 4f4bb99..72f3a89 100644 --- a/src/playwright/helpers/index.ts +++ b/src/playwright/helpers/index.ts @@ -2,3 +2,5 @@ export { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; export { APIHelper } from "./api-helper.js"; export { LoginHelper, setupBrowser } from "./common.js"; export { UIhelper } from "./ui-helper.js"; +export { RbacApiHelper } from "./rbac-api-helper.js"; +export { AuthApiHelper } from "./auth-api-helper.js"; diff --git a/src/playwright/helpers/rbac-api-helper.ts b/src/playwright/helpers/rbac-api-helper.ts new file mode 100644 index 0000000..dfbcbcc --- /dev/null +++ b/src/playwright/helpers/rbac-api-helper.ts @@ -0,0 +1,83 @@ +import { APIRequestContext, APIResponse, request } from "@playwright/test"; + +export interface Policy { + entityReference: string; + permission: string; + policy: string; + effect: string; +} + +export class RbacApiHelper { + private readonly apiUrl = process.env.RHDH_BASE_URL + "/api/permission/"; + private readonly authHeader: { + // eslint-disable-next-line @typescript-eslint/naming-convention + Accept: "application/json"; + // eslint-disable-next-line @typescript-eslint/naming-convention + Authorization: string; + }; + private myContext!: APIRequestContext; + + private constructor(private readonly token: string) { + this.authHeader = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + }; + } + + public static async build(token: string): Promise { + const instance = new RbacApiHelper(token); + instance.myContext = await request.newContext({ + baseURL: instance.apiUrl, + extraHTTPHeaders: instance.authHeader, + }); + return instance; + } + + // Used during the afterAll to ensure we clean up any policies that are left over due to failing tests + public async getPoliciesByRole(policy: string): Promise { + return await this.myContext.get(`policies/role/default/${policy}`); + } + + // Used during the afterAll to ensure we clean up any roles that are left over due to failing tests + public async deleteRole(role: string): Promise { + return await this.myContext.delete(`roles/role/default/${role}`); + } + + // Used during the afterAll to ensure we clean up any policies that are left over due to failing tests + public async deletePolicy(policy: string, policies: Policy[]) { + return await this.myContext.delete(`policies/role/default/${policy}`, { + data: policies, + }); + } +} + +export class Response { + static async removeMetadataFromResponse( + response: APIResponse, + ): Promise { + try { + const responseJson = await response.json(); + + // Validate that the response is an array + if (!Array.isArray(responseJson)) { + console.warn( + `Expected an array but received: ${JSON.stringify(responseJson)}`, + ); + return []; // Return an empty array as a fallback + } + + // Clean metadata from the response + const responseClean = responseJson.map((item: { metadata: unknown }) => { + if (item.metadata) { + delete item.metadata; + } + return item; + }); + + return responseClean; + } catch (error) { + console.error("Error processing API response:", error); + throw new Error("Failed to process the API response"); + } + } +} From 09594c4f0007043ec184fd26566378d4f3685338 Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Fri, 6 Mar 2026 08:42:18 -0500 Subject: [PATCH 2/6] feat(playwright): add docs for the additional api helpers Signed-off-by: Patrick Knight --- docs/.vitepress/config.ts | 4 + docs/api/helpers/auth-api-helper.md | 53 +++++++ docs/api/helpers/rbac-api-helper.md | 116 +++++++++++++++ docs/guide/helpers/auth-api-helper.md | 116 +++++++++++++++ docs/guide/helpers/index.md | 44 +++++- docs/guide/helpers/rbac-api-helper.md | 201 ++++++++++++++++++++++++++ 6 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 docs/api/helpers/auth-api-helper.md create mode 100644 docs/api/helpers/rbac-api-helper.md create mode 100644 docs/guide/helpers/auth-api-helper.md create mode 100644 docs/guide/helpers/rbac-api-helper.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d76cfae..1e00396 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -116,6 +116,8 @@ export default defineConfig({ { text: "UIhelper", link: "/guide/helpers/ui-helper" }, { text: "LoginHelper", link: "/guide/helpers/login-helper" }, { text: "APIHelper", link: "/guide/helpers/api-helper" }, + { text: "AuthApiHelper", link: "/guide/helpers/auth-api-helper" }, + { text: "RbacApiHelper", link: "/guide/helpers/rbac-api-helper" }, ], }, { @@ -219,6 +221,8 @@ export default defineConfig({ { text: "UIhelper", link: "/api/helpers/ui-helper" }, { text: "LoginHelper", link: "/api/helpers/login-helper" }, { text: "APIHelper", link: "/api/helpers/api-helper" }, + { text: "AuthApiHelper", link: "/api/helpers/auth-api-helper" }, + { text: "RbacApiHelper", link: "/api/helpers/rbac-api-helper" }, ], }, { diff --git a/docs/api/helpers/auth-api-helper.md b/docs/api/helpers/auth-api-helper.md new file mode 100644 index 0000000..9a77bfa --- /dev/null +++ b/docs/api/helpers/auth-api-helper.md @@ -0,0 +1,53 @@ +# AuthApiHelper API + +Retrieves Backstage identity tokens from a running RHDH auth API. + +## Import + +```typescript +import { AuthApiHelper } from '@red-hat-developer-hub/e2e-test-utils/helpers'; +``` + +## Constructor + +```typescript +new AuthApiHelper(page: Page) +``` + +| Parameter | Type | Description | +| --------- | ------ | --------------------------------------------- | +| `page` | `Page` | Playwright `Page` with an active RHDH session | + +## Methods + +### `getToken()` + +```typescript +async getToken( + provider?: string, + environment?: string +): Promise +``` + +| Parameter | Type | Default | Description | +| ------------- | -------- | -------------- | ------------------------------------- | +| `provider` | `string` | `"oidc"` | Auth provider name configured in RHDH | +| `environment` | `string` | `"production"` | Auth environment | + +**Returns** `Promise` — the Backstage identity token string. + +**Throws** `Error` if the HTTP response is not OK, or `TypeError` if the token is absent from the response body. + +## Example + +```typescript +import { AuthApiHelper } from '@red-hat-developer-hub/e2e-test-utils/helpers'; + +const authApiHelper = new AuthApiHelper(page); + +// Default provider (oidc) and environment (production) +const token = await authApiHelper.getToken(); + +// Custom provider +const token = await authApiHelper.getToken('github'); +``` diff --git a/docs/api/helpers/rbac-api-helper.md b/docs/api/helpers/rbac-api-helper.md new file mode 100644 index 0000000..77ff9a2 --- /dev/null +++ b/docs/api/helpers/rbac-api-helper.md @@ -0,0 +1,116 @@ +# RbacApiHelper API + +Manages RBAC roles and policies via the RHDH Permission API. + +## Import + +```typescript +import { + RbacApiHelper, + Response, + type Policy, +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; +``` + +## Types + +### `Policy` + +```typescript +interface Policy { + entityReference: string; + permission: string; + policy: string; + effect: string; +} +``` + +## `RbacApiHelper` + +### Static Methods + +#### `build()` + +```typescript +static async build(token: string): Promise +``` + +| Parameter | Type | Description | +| --------- | -------- | ------------------------ | +| `token` | `string` | Backstage identity token | + +**Returns** `Promise` — a fully initialized instance. + +### Instance Methods + +#### `getPoliciesByRole()` + +```typescript +async getPoliciesByRole(role: string): Promise +``` + +| Parameter | Type | Description | +| --------- | -------- | ------------------------------------ | +| `role` | `string` | Role name in the `default` namespace | + +#### `deleteRole()` + +```typescript +async deleteRole(role: string): Promise +``` + +| Parameter | Type | Description | +| --------- | -------- | ------------------------------------ | +| `role` | `string` | Role name in the `default` namespace | + +#### `deletePolicy()` + +```typescript +async deletePolicy(role: string, policies: Policy[]): Promise +``` + +| Parameter | Type | Description | +| ---------- | ---------- | ------------------------------------ | +| `role` | `string` | Role name in the `default` namespace | +| `policies` | `Policy[]` | Array of policy objects to delete | + +## `Response` + +### Static Methods + +#### `removeMetadataFromResponse()` + +```typescript +static async removeMetadataFromResponse( + response: APIResponse +): Promise +``` + +Parses a Playwright `APIResponse` and strips the `metadata` field from each item in the response array. + +**Throws** `Error` if the response cannot be parsed or is not an array. + +## Example + +```typescript +import { + AuthApiHelper, + RbacApiHelper, + Response, + type Policy, +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; + +const authApiHelper = new AuthApiHelper(page); +const token = await authApiHelper.getToken(); +const rbacApiHelper = await RbacApiHelper.build(token); + +// Get policies for a role +const apiResponse = await rbacApiHelper.getPoliciesByRole('my-role'); +const policies = (await Response.removeMetadataFromResponse( + apiResponse, +)) as Policy[]; + +// Delete policies and role +await rbacApiHelper.deletePolicy('my-role', policies); +await rbacApiHelper.deleteRole('my-role'); +``` diff --git a/docs/guide/helpers/auth-api-helper.md b/docs/guide/helpers/auth-api-helper.md new file mode 100644 index 0000000..7534f4f --- /dev/null +++ b/docs/guide/helpers/auth-api-helper.md @@ -0,0 +1,116 @@ +# AuthApiHelper + +The `AuthApiHelper` class retrieves authentication tokens from a running RHDH instance via its auth API. It uses an existing Playwright `Page` to make authenticated requests, which means it works within the context of a test that has already navigated to RHDH. + +## Importing + +```typescript +import { AuthApiHelper } from '@red-hat-developer-hub/e2e-test-utils/helpers'; +``` + +## Setup + +`AuthApiHelper` requires a Playwright `Page` object. Create an instance after navigating to RHDH and completing login: + +```typescript +import { test } from '@red-hat-developer-hub/e2e-test-utils/test'; +import { AuthApiHelper } from '@red-hat-developer-hub/e2e-test-utils/helpers'; + +test('example', async ({ page }) => { + const authApiHelper = new AuthApiHelper(page); +}); +``` + +## Methods + +### `getToken(provider?, environment?)` + +Fetches a Backstage identity token from the RHDH auth refresh endpoint. + +```typescript +async getToken( + provider: string = "oidc", + environment: string = "production" +): Promise +``` + +**Parameters** + +| Parameter | Type | Default | Description | +| ------------- | -------- | -------------- | ------------------------------------------------------------------ | +| `provider` | `string` | `"oidc"` | The auth provider configured in RHDH (e.g. `"oidc"`, `"github"`) | +| `environment` | `string` | `"production"` | The auth environment to use (e.g. `"production"`, `"development"`) | + +**Returns** `Promise` — the Backstage identity token from `backstageIdentity.token`. + +**Throws** if the HTTP request fails or if the token is not found in the response body. + +```typescript +// Using defaults (OIDC provider, production environment) +const token = await authApiHelper.getToken(); + +// Using a custom provider +const token = await authApiHelper.getToken('github'); + +// Using a custom provider and environment +const token = await authApiHelper.getToken('oidc', 'development'); +``` + +## Complete Example + +### Fetching a Token to Use with RbacApiHelper + +A common pattern is to retrieve a token after login and pass it to `RbacApiHelper` (or another API helper) to make authenticated API calls: + +```typescript +import { test } from '@red-hat-developer-hub/e2e-test-utils/test'; +import { + AuthApiHelper, + RbacApiHelper, +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; + +test.describe('RBAC policy management', () => { + let rbacApiHelper: RbacApiHelper; + + test.beforeAll(async ({ page, loginHelper }) => { + // Log in first so the page session is authenticated + await page.goto('/'); + await loginHelper.loginAsKeycloakUser(); + + // Retrieve the Backstage identity token + const authApiHelper = new AuthApiHelper(page); + const token = await authApiHelper.getToken(); + + // Build the RBAC helper with the token + rbacApiHelper = await RbacApiHelper.build(token); + }); + + test('verify role policies exist', async () => { + const response = await rbacApiHelper.getPoliciesByRole('my-role'); + // assert on response... + }); +}); +``` + +## Error Handling + +`getToken` throws on HTTP errors or if the token is missing from the response body. Wrap calls in a try/catch when you need to handle failures gracefully: + +```typescript +try { + const token = await authApiHelper.getToken(); +} catch (error) { + console.error('Could not retrieve auth token:', error); + throw error; +} +``` + +## Environment Variables + +`AuthApiHelper` does not read any environment variables directly. The `Page` instance used to construct it should already be pointing at the correct RHDH base URL (set via `RHDH_BASE_URL` or your Playwright `baseURL` config). + +## Related Pages + +- [RbacApiHelper](/guide/helpers/rbac-api-helper) — uses tokens obtained from `AuthApiHelper` +- [LoginHelper](/guide/helpers/login-helper) — authenticates the browser session before calling `getToken` +- [APIHelper](/guide/helpers/api-helper) — catalog and GitHub API operations diff --git a/docs/guide/helpers/index.md b/docs/guide/helpers/index.md index 23c1b89..56fdd55 100644 --- a/docs/guide/helpers/index.md +++ b/docs/guide/helpers/index.md @@ -9,6 +9,8 @@ The package provides helper classes for common testing operations in RHDH. | [UIhelper](/guide/helpers/ui-helper) | Material-UI component interactions | | [LoginHelper](/guide/helpers/login-helper) | Authentication flows | | [APIHelper](/guide/helpers/api-helper) | GitHub and Backstage API operations | +| [AuthApiHelper](/guide/helpers/auth-api-helper) | Retrieve Backstage identity tokens | +| [RbacApiHelper](/guide/helpers/rbac-api-helper) | Manage RBAC roles and policies | ## Importing Helpers @@ -22,7 +24,7 @@ test("example", async ({ uiHelper, loginHelper }) => { }); // Direct import -import { UIhelper, LoginHelper, APIHelper, setupBrowser } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +import { UIhelper, LoginHelper, APIHelper, AuthApiHelper, RbacApiHelper, setupBrowser } from "@red-hat-developer-hub/e2e-test-utils/helpers"; const uiHelper = new UIhelper(page); const loginHelper = new LoginHelper(page); @@ -96,6 +98,44 @@ const groups = await apiHelper.getAllCatalogGroupsFromAPI(); [Learn more about APIHelper →](/guide/helpers/api-helper) +## AuthApiHelper + +Retrieves Backstage identity tokens from RHDH's auth API: + +```typescript +import { AuthApiHelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +const authApiHelper = new AuthApiHelper(page); + +// Using default OIDC provider +const token = await authApiHelper.getToken(); + +// Using a specific provider +const token = await authApiHelper.getToken("github"); +``` + +[Learn more about AuthApiHelper →](/guide/helpers/auth-api-helper) + +## RbacApiHelper + +Manages RBAC roles and policies via the RHDH Permission API. Built with an async factory method that requires a Backstage identity token: + +```typescript +import { AuthApiHelper, RbacApiHelper, Response, type Policy } from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +const authApiHelper = new AuthApiHelper(page); +const token = await authApiHelper.getToken(); +const rbacApiHelper = await RbacApiHelper.build(token); + +// Retrieve policies and clean up +const apiResponse = await rbacApiHelper.getPoliciesByRole("my-role"); +const policies = await Response.removeMetadataFromResponse(apiResponse) as Policy[]; +await rbacApiHelper.deletePolicy("my-role", policies); +await rbacApiHelper.deleteRole("my-role"); +``` + +[Learn more about RbacApiHelper →](/guide/helpers/rbac-api-helper) + ## setupBrowser Utility for shared browser context in serial tests: @@ -138,3 +178,5 @@ test("first test", async () => { | Query Backstage catalog | APIHelper | | Interact with tables | UIhelper | | Fill forms | UIhelper | +| Retrieve a Backstage identity token | AuthApiHelper | +| Clean up RBAC roles/policies after tests | RbacApiHelper | diff --git a/docs/guide/helpers/rbac-api-helper.md b/docs/guide/helpers/rbac-api-helper.md new file mode 100644 index 0000000..d46fd5c --- /dev/null +++ b/docs/guide/helpers/rbac-api-helper.md @@ -0,0 +1,201 @@ +# RbacApiHelper + +The `RbacApiHelper` class provides utilities for managing RBAC (Role-Based Access Control) policies and roles in RHDH via the Permission API. It is primarily used in test teardown (`afterAll`) to clean up roles and policies created during a test run. + +## Importing + +```typescript +import { RbacApiHelper, Policy } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +``` + +## Setup + +`RbacApiHelper` uses an async static factory method (`build`) rather than a plain constructor. This is because it needs to create a Playwright `APIRequestContext` before it is usable. + +It requires a Backstage identity token. Use [`AuthApiHelper.getToken()`](/guide/helpers/auth-api-helper) to obtain one after logging in. + +```typescript +import { AuthApiHelper, RbacApiHelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +// Inside a test or beforeAll hook: +const authApiHelper = new AuthApiHelper(page); +const token = await authApiHelper.getToken(); + +const rbacApiHelper = await RbacApiHelper.build(token); +``` + +### Prerequisites + +Set the `RHDH_BASE_URL` environment variable. `RbacApiHelper` constructs its API URL as: + +``` +${RHDH_BASE_URL}/api/permission/ +``` + +## Types + +### `Policy` + +Represents a single RBAC policy entry: + +```typescript +interface Policy { + entityReference: string; // e.g. "role:default/my-role" + permission: string; // e.g. "catalog.entity.read" + policy: string; // e.g. "allow" or "deny" + effect: string; // e.g. "allow" or "deny" +} +``` + +## Methods + +### `RbacApiHelper.build(token)` (static) + +Creates and returns a fully initialized `RbacApiHelper` instance. + +```typescript +static async build(token: string): Promise +``` + +```typescript +const rbacApiHelper = await RbacApiHelper.build(token); +``` + +### `getPoliciesByRole(role)` + +Fetches all policies associated with a role in the `default` namespace. + +```typescript +async getPoliciesByRole(role: string): Promise +``` + +```typescript +const response = await rbacApiHelper.getPoliciesByRole("my-role"); +const policies = await response.json(); +``` + +### `deleteRole(role)` + +Deletes a role from the `default` namespace. + +```typescript +async deleteRole(role: string): Promise +``` + +```typescript +await rbacApiHelper.deleteRole("my-role"); +``` + +### `deletePolicy(role, policies)` + +Deletes specific policies for a role in the `default` namespace. + +```typescript +async deletePolicy(role: string, policies: Policy[]): Promise +``` + +**Parameters** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `role` | `string` | The role name in the `default` namespace | +| `policies` | `Policy[]` | Array of policy objects to delete | + +```typescript +const policies: Policy[] = [ + { + entityReference: "role:default/my-role", + permission: "catalog.entity.read", + policy: "allow", + effect: "allow", + }, +]; + +await rbacApiHelper.deletePolicy("my-role", policies); +``` + +## `Response` Utility Class + +The `rbac-api-helper` module also exports a `Response` utility class with a static helper for stripping metadata from API responses. + +```typescript +import { Response } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +``` + +### `Response.removeMetadataFromResponse(response)` + +Parses a Playwright `APIResponse`, validates it is an array, and strips the `metadata` field from each item. Useful when comparing policies without server-added metadata fields. + +```typescript +static async removeMetadataFromResponse( + response: APIResponse +): Promise +``` + +```typescript +const apiResponse = await rbacApiHelper.getPoliciesByRole("my-role"); +const policies = await Response.removeMetadataFromResponse(apiResponse); +// policies is now a clean array without metadata fields +``` + +## Complete Example + +### Cleanup in afterAll + +The primary use case for `RbacApiHelper` is ensuring that any roles and policies created during a test run are removed even if tests fail: + +```typescript +import { test } from "@red-hat-developer-hub/e2e-test-utils/test"; +import { + AuthApiHelper, + RbacApiHelper, + Response, + type Policy, +} from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +test.describe("RBAC feature", () => { + let rbacApiHelper: RbacApiHelper; + const roleName = "test-role"; + + test.beforeAll(async ({ page, loginHelper }) => { + await page.goto("/"); + await loginHelper.loginAsKeycloakUser(); + + const authApiHelper = new AuthApiHelper(page); + const token = await authApiHelper.getToken(); + rbacApiHelper = await RbacApiHelper.build(token); + }); + + test.afterAll(async () => { + // Clean up policies first, then the role + const policiesResponse = await rbacApiHelper.getPoliciesByRole(roleName); + + if (policiesResponse.ok()) { + const policies = + (await Response.removeMetadataFromResponse(policiesResponse)) as Policy[]; + + if (policies.length > 0) { + await rbacApiHelper.deletePolicy(roleName, policies); + } + } + + await rbacApiHelper.deleteRole(roleName); + }); + + test("assign and verify role", async ({ page }) => { + // test body... + }); +}); +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `RHDH_BASE_URL` | Base URL of the RHDH instance (e.g. `https://rhdh.example.com`) | + +## Related Pages + +- [AuthApiHelper](/guide/helpers/auth-api-helper) — obtain the token required by `RbacApiHelper.build()` +- [APIHelper](/guide/helpers/api-helper) — catalog and GitHub API operations +- [LoginHelper](/guide/helpers/login-helper) — authenticate the browser session From 11d66e0f8928bb9a5af531d5d8df15addd152359 Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Sun, 8 Mar 2026 11:22:10 -0400 Subject: [PATCH 3/6] feat(playwright): add the ability to fetch and delete rbac conditions Signed-off-by: Patrick Knight --- docs/api/helpers/rbac-api-helper.md | 51 ++++- docs/guide/helpers/rbac-api-helper.md | 216 ++++++++++++++++++---- package.json | 1 + src/playwright/helpers/auth-api-helper.ts | 1 + src/playwright/helpers/rbac-api-helper.ts | 36 +++- yarn.lock | 11 ++ 6 files changed, 277 insertions(+), 39 deletions(-) diff --git a/docs/api/helpers/rbac-api-helper.md b/docs/api/helpers/rbac-api-helper.md index 77ff9a2..0e24c02 100644 --- a/docs/api/helpers/rbac-api-helper.md +++ b/docs/api/helpers/rbac-api-helper.md @@ -1,6 +1,6 @@ # RbacApiHelper API -Manages RBAC roles and policies via the RHDH Permission API. +Manages RBAC roles, policies, and conditional permission policies via the RHDH Permission API. ## Import @@ -53,6 +53,30 @@ async getPoliciesByRole(role: string): Promise | --------- | -------- | ------------------------------------ | | `role` | `string` | Role name in the `default` namespace | +#### `getConditions()` + +```typescript +async getConditions(): Promise +``` + +Fetches all conditional policies across every role. + +#### `getConditionsByRole()` + +```typescript +async getConditionsByRole( + role: string, + remainingConditions: RoleConditionalPolicyDecision[] +): Promise[]> +``` + +| Parameter | Type | Description | +| --------------------- | --------------------------------------------------- | --------------------------------------------------------- | +| `role` | `string` | Full role entity reference, e.g. `"role:default/my-role"` | +| `remainingConditions` | `RoleConditionalPolicyDecision[]` | Conditions array fetched from `getConditions()` | + +Filters locally — no additional HTTP request is made. + #### `deleteRole()` ```typescript @@ -74,6 +98,16 @@ async deletePolicy(role: string, policies: Policy[]): Promise | `role` | `string` | Role name in the `default` namespace | | `policies` | `Policy[]` | Array of policy objects to delete | +#### `deleteCondition()` + +```typescript +async deleteCondition(id: string): Promise +``` + +| Parameter | Type | Description | +| --------- | -------- | ------------------------------------------------------------ | +| `id` | `string` | The `id` field from a `RoleConditionalPolicyDecision` object | + ## `Response` ### Static Methods @@ -104,13 +138,22 @@ const authApiHelper = new AuthApiHelper(page); const token = await authApiHelper.getToken(); const rbacApiHelper = await RbacApiHelper.build(token); -// Get policies for a role +// Delete conditional policies for a role +const conditionsResponse = await rbacApiHelper.getConditions(); +const allConditions = await conditionsResponse.json(); +const roleConditions = await rbacApiHelper.getConditionsByRole( + 'role:default/my-role', + allConditions, +); +for (const condition of roleConditions) { + await rbacApiHelper.deleteCondition(condition.id); +} + +// Delete standard policies and role const apiResponse = await rbacApiHelper.getPoliciesByRole('my-role'); const policies = (await Response.removeMetadataFromResponse( apiResponse, )) as Policy[]; - -// Delete policies and role await rbacApiHelper.deletePolicy('my-role', policies); await rbacApiHelper.deleteRole('my-role'); ``` diff --git a/docs/guide/helpers/rbac-api-helper.md b/docs/guide/helpers/rbac-api-helper.md index d46fd5c..0e6b3b1 100644 --- a/docs/guide/helpers/rbac-api-helper.md +++ b/docs/guide/helpers/rbac-api-helper.md @@ -1,11 +1,14 @@ # RbacApiHelper -The `RbacApiHelper` class provides utilities for managing RBAC (Role-Based Access Control) policies and roles in RHDH via the Permission API. It is primarily used in test teardown (`afterAll`) to clean up roles and policies created during a test run. +The `RbacApiHelper` class provides utilities for managing RBAC (Role-Based Access Control) policies, roles, and conditional permission policies in RHDH via the Permission API. It is primarily used in test teardown (`afterAll`) to clean up roles, policies, and conditions created during a test run. ## Importing ```typescript -import { RbacApiHelper, Policy } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +import { + RbacApiHelper, + Policy, +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; ``` ## Setup @@ -15,7 +18,10 @@ import { RbacApiHelper, Policy } from "@red-hat-developer-hub/e2e-test-utils/hel It requires a Backstage identity token. Use [`AuthApiHelper.getToken()`](/guide/helpers/auth-api-helper) to obtain one after logging in. ```typescript -import { AuthApiHelper, RbacApiHelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +import { + AuthApiHelper, + RbacApiHelper, +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; // Inside a test or beforeAll hook: const authApiHelper = new AuthApiHelper(page); @@ -41,9 +47,22 @@ Represents a single RBAC policy entry: ```typescript interface Policy { entityReference: string; // e.g. "role:default/my-role" - permission: string; // e.g. "catalog.entity.read" - policy: string; // e.g. "allow" or "deny" - effect: string; // e.g. "allow" or "deny" + permission: string; // e.g. "catalog.entity.read" + policy: string; // e.g. "allow" or "deny" + effect: string; // e.g. "allow" or "deny" +} +``` + +### `RoleConditionalPolicyDecision` + +Conditional policies are typed using `RoleConditionalPolicyDecision` from `@backstage-community/plugin-rbac-common`. Each entry includes an `id` field assigned by the server, a `roleEntityRef` identifying the owning role, and the condition criteria. + +```typescript +// Simplified shape — see @backstage-community/plugin-rbac-common for the full type +interface RoleConditionalPolicyDecision { + id: string; + roleEntityRef: string; // e.g. "role:default/my-role" + // ...condition fields } ``` @@ -70,10 +89,51 @@ async getPoliciesByRole(role: string): Promise ``` ```typescript -const response = await rbacApiHelper.getPoliciesByRole("my-role"); +const response = await rbacApiHelper.getPoliciesByRole('my-role'); const policies = await response.json(); ``` +### `getConditions()` + +Fetches all conditional policies across every role. Returns the raw `APIResponse` — call `.json()` to get the array of `RoleConditionalPolicyDecision` objects. + +```typescript +async getConditions(): Promise +``` + +```typescript +const response = await rbacApiHelper.getConditions(); +const allConditions = await response.json(); +``` + +### `getConditionsByRole(role, remainingConditions)` + +Filters an array of conditions down to those that belong to a specific role entity reference. This is a local filter — no additional HTTP request is made. + +```typescript +async getConditionsByRole( + role: string, + remainingConditions: RoleConditionalPolicyDecision[] +): Promise[]> +``` + +**Parameters** + +| Parameter | Type | Description | +| --------------------- | --------------------------------------------------- | --------------------------------------------------------- | +| `role` | `string` | Full role entity reference, e.g. `"role:default/my-role"` | +| `remainingConditions` | `RoleConditionalPolicyDecision[]` | Array previously fetched via `getConditions()` | + +```typescript +const response = await rbacApiHelper.getConditions(); +const allConditions = await response.json(); + +const myRoleConditions = await rbacApiHelper.getConditionsByRole( + 'role:default/my-role', + allConditions, +); +``` + ### `deleteRole(role)` Deletes a role from the `default` namespace. @@ -83,7 +143,7 @@ async deleteRole(role: string): Promise ``` ```typescript -await rbacApiHelper.deleteRole("my-role"); +await rbacApiHelper.deleteRole('my-role'); ``` ### `deletePolicy(role, policies)` @@ -96,22 +156,49 @@ async deletePolicy(role: string, policies: Policy[]): Promise **Parameters** -| Parameter | Type | Description | -|-----------|------|-------------| -| `role` | `string` | The role name in the `default` namespace | -| `policies` | `Policy[]` | Array of policy objects to delete | +| Parameter | Type | Description | +| ---------- | ---------- | ---------------------------------------- | +| `role` | `string` | The role name in the `default` namespace | +| `policies` | `Policy[]` | Array of policy objects to delete | ```typescript const policies: Policy[] = [ { - entityReference: "role:default/my-role", - permission: "catalog.entity.read", - policy: "allow", - effect: "allow", + entityReference: 'role:default/my-role', + permission: 'catalog.entity.read', + policy: 'allow', + effect: 'allow', }, ]; -await rbacApiHelper.deletePolicy("my-role", policies); +await rbacApiHelper.deletePolicy('my-role', policies); +``` + +### `deleteCondition(id)` + +Deletes a single conditional policy by its server-assigned `id`. Obtain the `id` from a `RoleConditionalPolicyDecision` object returned by `getConditions()`. + +```typescript +async deleteCondition(id: string): Promise +``` + +**Parameters** + +| Parameter | Type | Description | +| --------- | -------- | ------------------------------------------------------------ | +| `id` | `string` | The `id` field from a `RoleConditionalPolicyDecision` object | + +```typescript +const response = await rbacApiHelper.getConditions(); +const allConditions = await response.json(); +const myConditions = await rbacApiHelper.getConditionsByRole( + 'role:default/my-role', + allConditions, +); + +for (const condition of myConditions) { + await rbacApiHelper.deleteCondition(condition.id); +} ``` ## `Response` Utility Class @@ -119,7 +206,7 @@ await rbacApiHelper.deletePolicy("my-role", policies); The `rbac-api-helper` module also exports a `Response` utility class with a static helper for stripping metadata from API responses. ```typescript -import { Response } from "@red-hat-developer-hub/e2e-test-utils/helpers"; +import { Response } from '@red-hat-developer-hub/e2e-test-utils/helpers'; ``` ### `Response.removeMetadataFromResponse(response)` @@ -133,32 +220,32 @@ static async removeMetadataFromResponse( ``` ```typescript -const apiResponse = await rbacApiHelper.getPoliciesByRole("my-role"); +const apiResponse = await rbacApiHelper.getPoliciesByRole('my-role'); const policies = await Response.removeMetadataFromResponse(apiResponse); // policies is now a clean array without metadata fields ``` -## Complete Example +## Complete Examples -### Cleanup in afterAll +### Cleanup in afterAll (policies and role) The primary use case for `RbacApiHelper` is ensuring that any roles and policies created during a test run are removed even if tests fail: ```typescript -import { test } from "@red-hat-developer-hub/e2e-test-utils/test"; +import { test } from '@red-hat-developer-hub/e2e-test-utils/test'; import { AuthApiHelper, RbacApiHelper, Response, type Policy, -} from "@red-hat-developer-hub/e2e-test-utils/helpers"; +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; -test.describe("RBAC feature", () => { +test.describe('RBAC feature', () => { let rbacApiHelper: RbacApiHelper; - const roleName = "test-role"; + const roleName = 'test-role'; test.beforeAll(async ({ page, loginHelper }) => { - await page.goto("/"); + await page.goto('/'); await loginHelper.loginAsKeycloakUser(); const authApiHelper = new AuthApiHelper(page); @@ -171,18 +258,85 @@ test.describe("RBAC feature", () => { const policiesResponse = await rbacApiHelper.getPoliciesByRole(roleName); if (policiesResponse.ok()) { - const policies = - (await Response.removeMetadataFromResponse(policiesResponse)) as Policy[]; + const policies = (await Response.removeMetadataFromResponse( + policiesResponse, + )) as Policy[]; + + if (policies.length > 0) { + await rbacApiHelper.deletePolicy(roleName, policies); + } + } + + await rbacApiHelper.deleteRole(roleName); + }); + + test('assign and verify role', async ({ page }) => { + // test body... + }); +}); +``` + +### Cleanup in afterAll (including conditional policies) + +When your tests also create conditional permission policies, delete them before removing the role: + +```typescript +import { test } from '@red-hat-developer-hub/e2e-test-utils/test'; +import { + AuthApiHelper, + RbacApiHelper, + Response, + type Policy, +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; + +test.describe('RBAC conditional policies', () => { + let rbacApiHelper: RbacApiHelper; + const roleName = 'test-role'; + const roleEntityRef = `role:default/${roleName}`; + + test.beforeAll(async ({ page, loginHelper }) => { + await page.goto('/'); + await loginHelper.loginAsKeycloakUser(); + + const authApiHelper = new AuthApiHelper(page); + const token = await authApiHelper.getToken(); + rbacApiHelper = await RbacApiHelper.build(token); + }); + + test.afterAll(async () => { + // 1. Remove conditional policies for this role + const conditionsResponse = await rbacApiHelper.getConditions(); + + if (conditionsResponse.ok()) { + const allConditions = await conditionsResponse.json(); + const roleConditions = await rbacApiHelper.getConditionsByRole( + roleEntityRef, + allConditions, + ); + + for (const condition of roleConditions) { + await rbacApiHelper.deleteCondition(condition.id); + } + } + + // 2. Remove standard policies + const policiesResponse = await rbacApiHelper.getPoliciesByRole(roleName); + + if (policiesResponse.ok()) { + const policies = (await Response.removeMetadataFromResponse( + policiesResponse, + )) as Policy[]; if (policies.length > 0) { await rbacApiHelper.deletePolicy(roleName, policies); } } + // 3. Remove the role itself await rbacApiHelper.deleteRole(roleName); }); - test("assign and verify role", async ({ page }) => { + test('assign and verify conditional policy', async ({ page }) => { // test body... }); }); @@ -190,8 +344,8 @@ test.describe("RBAC feature", () => { ## Environment Variables -| Variable | Description | -|----------|-------------| +| Variable | Description | +| --------------- | --------------------------------------------------------------- | | `RHDH_BASE_URL` | Base URL of the RHDH instance (e.g. `https://rhdh.example.com`) | ## Related Pages diff --git a/package.json b/package.json index 7dacfdd..5e9bea1 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@playwright/test": "^1.57.0" }, "devDependencies": { + "@backstage-community/plugin-rbac-common": "1.23.0", "@backstage/catalog-model": "1.7.5", "@playwright/test": "^1.57.0", "@types/fs-extra": "^11.0.4", diff --git a/src/playwright/helpers/auth-api-helper.ts b/src/playwright/helpers/auth-api-helper.ts index 92b2713..5f1ff94 100644 --- a/src/playwright/helpers/auth-api-helper.ts +++ b/src/playwright/helpers/auth-api-helper.ts @@ -1,5 +1,6 @@ import { Page } from "@playwright/test"; +// here, we spy on the request to get the Backstage token to use APIs export class AuthApiHelper { private readonly page: Page; diff --git a/src/playwright/helpers/rbac-api-helper.ts b/src/playwright/helpers/rbac-api-helper.ts index dfbcbcc..590cf67 100644 --- a/src/playwright/helpers/rbac-api-helper.ts +++ b/src/playwright/helpers/rbac-api-helper.ts @@ -1,3 +1,7 @@ +import type { + PermissionAction, + RoleConditionalPolicyDecision, +} from "@backstage-community/plugin-rbac-common"; import { APIRequestContext, APIResponse, request } from "@playwright/test"; export interface Policy { @@ -7,6 +11,11 @@ export interface Policy { effect: string; } +/** + * Thin HTTP client for the RHDH RBAC permission API. + * Uses a static factory (`build`) because the Playwright `APIRequestContext` + * must be created asynchronously — a constructor cannot await it. + */ export class RbacApiHelper { private readonly apiUrl = process.env.RHDH_BASE_URL + "/api/permission/"; private readonly authHeader: { @@ -24,6 +33,7 @@ export class RbacApiHelper { }; } + /** Creates a fully-initialised instance with a live Playwright request context. */ public static async build(token: string): Promise { const instance = new RbacApiHelper(token); instance.myContext = await request.newContext({ @@ -33,22 +43,39 @@ export class RbacApiHelper { return instance; } - // Used during the afterAll to ensure we clean up any policies that are left over due to failing tests public async getPoliciesByRole(policy: string): Promise { return await this.myContext.get(`policies/role/default/${policy}`); } - // Used during the afterAll to ensure we clean up any roles that are left over due to failing tests + /** Fetches all conditional policies across all roles. */ + public async getConditions(): Promise { + return await this.myContext.get(`roles/conditions`); + } + + /** Filters a full conditions list down to those belonging to a specific role entity ref. */ + public async getConditionsByRole( + role: string, + remainingConditions: RoleConditionalPolicyDecision[], + ): Promise[]> { + return remainingConditions.filter( + (condition) => condition.roleEntityRef === role, + ); + } + public async deleteRole(role: string): Promise { return await this.myContext.delete(`roles/role/default/${role}`); } - // Used during the afterAll to ensure we clean up any policies that are left over due to failing tests public async deletePolicy(policy: string, policies: Policy[]) { return await this.myContext.delete(`policies/role/default/${policy}`, { data: policies, }); } + + /** `id` comes from the `RoleConditionalPolicyDecision.id` field returned by the API. */ + public async deleteCondition(id: string): Promise { + return await this.myContext.delete(`roles/conditions/${id}`); + } } export class Response { @@ -66,7 +93,8 @@ export class Response { return []; // Return an empty array as a fallback } - // Clean metadata from the response + // Strip the `metadata` field before passing policies to the delete endpoint, + // which rejects payloads that contain it const responseClean = responseJson.map((item: { metadata: unknown }) => { if (item.metadata) { delete item.metadata; diff --git a/yarn.lock b/yarn.lock index 76feb5e..9c2dfc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,6 +16,16 @@ __metadata: languageName: node linkType: hard +"@backstage-community/plugin-rbac-common@npm:1.23.0": + version: 1.23.0 + resolution: "@backstage-community/plugin-rbac-common@npm:1.23.0" + peerDependencies: + "@backstage/errors": ^1.2.7 + "@backstage/plugin-permission-common": ^0.9.5 + checksum: 8cbb67a4854b9a72d329459cf37d05628da4ca4c665fb1e9c34e2dcbcd8d4399264b00bab8e4ee786717e2c0c5b0e3db7ee4eee6705424047a799fbf311eb5f4 + languageName: node + linkType: hard + "@backstage/catalog-model@npm:1.7.5": version: 1.7.5 resolution: "@backstage/catalog-model@npm:1.7.5" @@ -327,6 +337,7 @@ __metadata: resolution: "@red-hat-developer-hub/e2e-test-utils@workspace:." dependencies: "@axe-core/playwright": ^4.11.0 + "@backstage-community/plugin-rbac-common": 1.23.0 "@backstage/catalog-model": 1.7.5 "@eslint/js": ^9.39.1 "@keycloak/keycloak-admin-client": ^26.0.0 From 7d9b72d39ea766dfc76b3a79f6bbb8f90dbb2cae Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Mon, 9 Mar 2026 13:48:14 -0400 Subject: [PATCH 4/6] feat(playwright): handle some review suggestions Signed-off-by: Patrick Knight --- docs/changelog.md | 60 ++++++++++++++++++++++----- docs/guide/helpers/auth-api-helper.md | 37 +---------------- docs/overlay/reference/patterns.md | 34 +++++++++++++++ package.json | 2 +- src/playwright/helpers/index.ts | 2 +- 5 files changed, 87 insertions(+), 48 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 1970fe7..f7a970d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,65 +2,85 @@ All notable changes to this project will be documented in this file. -## [1.1.14] - Current +## [1.1.15] - Current ### Added + +- **`RbacApiHelper`**: New HTTP client for the RHDH RBAC permission API (`/api/permission/`). Uses a static `build(token)` factory to asynchronously initialise a Playwright `APIRequestContext`. Provides `getPoliciesByRole()`, `getConditions()`, `getConditionsByRole()`, `deleteRole()`, `deletePolicy()`, and `deleteCondition()` for managing roles, policies, and conditional permission policies — primarily intended for `afterAll` cleanup. Exported from `@red-hat-developer-hub/e2e-test-utils/helpers`. +- **`AuthApiHelper`**: New helper for retrieving Backstage identity tokens from a running RHDH instance via the auth refresh endpoint (`/api/auth/{provider}/refresh`). Accepts an existing Playwright `Page` and exposes a single `getToken(provider?, environment?)` method. Defaults to the `oidc` provider and `production` environment. Typically used in `beforeAll` to obtain a token for `RbacApiHelper.build()`. Exported from `@red-hat-developer-hub/e2e-test-utils/helpers`. +- **`Response`** utility class (exported alongside `RbacApiHelper`): provides `Response.removeMetadataFromResponse(apiResponse)` to strip server-added `metadata` fields from policy arrays before passing them to delete endpoints. + +## [1.1.14] + +### Added + - **`deploy()` built-in protection**: `rhdh.deploy()` now automatically skips if the deployment already succeeded in the current test run. No code changes needed — existing `beforeAll` patterns work as before, but deployments are no longer repeated when Playwright restarts workers after test failures. - **`test.runOnce(key, fn)`**: Execute any function exactly once per test run, even across worker restarts. Use for expensive pre-deploy operations (external services, setup scripts, data seeding) that `deploy()` alone doesn't cover. Safe to nest with `deploy()`'s built-in protection. - **Teardown reporter**: Built-in Playwright reporter that automatically deletes Kubernetes namespaces after all tests complete. Active only in CI (`process.env.CI`). - **`registerTeardownNamespace(projectName, namespace)`**: Register custom namespaces for automatic cleanup. Import from `@red-hat-developer-hub/e2e-test-utils/teardown`. ### Changed + - Namespace cleanup moved from worker fixture to teardown reporter to prevent premature deletion on test failures. ## [1.1.13] ### Added + - Support for GitHub authentication provider ### Changed -- `LoginHelper.loginAsGithubUser` now pulls default user credentials from the following vault keys: `VAULT_GH_USER_ID`, `VAULT_GH_USER_PASS`, `VAULT_GH_2FA_SECRET` + +- `LoginHelper.loginAsGithubUser` now pulls default user credentials from the following vault keys: `VAULT_GH_USER_ID`, `VAULT_GH_USER_PASS`, `VAULT_GH_2FA_SECRET` - `APIHelper.githubRequest` pulls default user token from vault key `VAULT_GITHUB_USER_TOKEN` ### Environment Variables -- `VAULT_GITHUB_OAUTH_OVERLAYS_APP_ID` - ID for GitHub OAuth application used as auth provider -- `VAULT_GITHUB_OAUTH_OVERLAYS_APP_SECRET`- Client secret for GitHub OAuth application -- `VAULT_GH_USER_ID` - GitHub user name -- `VAULT_GH_USER_PASS` - GitHub user password -- `VAULT_GH_2FA_SECRET` - GitHub user secret for 2 factor authentication -- `VAULT_GITHUB_USER_TOKEN` - Github user token + +- `VAULT_GITHUB_OAUTH_OVERLAYS_APP_ID` - ID for GitHub OAuth application used as auth provider +- `VAULT_GITHUB_OAUTH_OVERLAYS_APP_SECRET`- Client secret for GitHub OAuth application +- `VAULT_GH_USER_ID` - GitHub user name +- `VAULT_GH_USER_PASS` - GitHub user password +- `VAULT_GH_2FA_SECRET` - GitHub user secret for 2 factor authentication +- `VAULT_GITHUB_USER_TOKEN` - Github user token ## [1.1.12] - Current ### Changed + - **`deploy()` timeout is now configurable**: `deploy()` accepts an optional `{ timeout }` parameter to control the Playwright test timeout during deployment. Defaults to `600_000` (600s). Pass a custom number to override, `0` for no timeout (infinite), or `null` to skip setting the timeout entirely and let the consumer control it. ## [1.1.11] ### Added + - **`runQuietUnlessFailure()`**: New utility that captures command output silently on success and displays full output on failure for better debugging. Used in Keycloak deployment for `helm repo update` and `helm upgrade --install`. ## [1.1.10] ### Fixed + - **`plugins-list.yaml` parsing**: Parse as proper YAML instead of text splitting, correctly handling entries with build flags (e.g., `--embed-package`, `--suppress-native-package`) and YAML comments. ### Changed + - **Video recording**: Changed mode from `"on"` to `"retain-on-failure"` and reduced size from `1920x1080` to `1280x720` to save disk space. - **Workers and retries**: Now configurable via `PLAYWRIGHT_WORKERS` (default: `"50%"`) and `PLAYWRIGHT_RETRIES` (default: `0`) environment variables. ## [1.1.9] ### Fixed + - **OCI URL replacement with user-provided `dynamic-plugins.yaml`**: When a workspace provides its own `dynamic-plugins.yaml`, plugin package paths were not replaced with OCI URLs for PR builds. Extracted shared `replaceWithOCIUrls()` function so both `generateDynamicPluginsConfigFromMetadata()` and `loadAndInjectPluginMetadata()` code paths now perform OCI replacement when `GIT_PR_NUMBER` is set. ## [1.1.8] ### Fixed + - Fixed namespace deletion race condition during test retries - Improved 404 error detection for different Kubernetes client versions ### Changed + - Increased default timeouts (300s → 500s) and test timeout (600s) - Reduced CI retries from 2 to 1 - Added pod diagnostics logging on timeout and periodic status updates @@ -68,28 +88,34 @@ All notable changes to this project will be documented in this file. ## [1.1.7] ### Fixed + - **Secrets with control characters**: Fixed `SyntaxError: Bad control character in string literal` when secrets contain newlines or special characters (e.g., GitHub App private keys) ### Dependencies + - Added `lodash.clonedeepwith@^4.5.0` for safe environment variable substitution ## [1.1.6] ### Added + - **"next" tag support**: Both Helm and Operator deployments now support `RHDH_VERSION=next` - Helm: Resolves "next" to semantic version by querying `rhdh-hub-rhel9` image tags - Operator: Uses `main` branch and `--next` flag instead of release branch ### Changed + - **Default values**: `RHDH_VERSION` defaults to `next` and `INSTALLATION_METHOD` defaults to `helm` when not set ### Environment Variables + - `RHDH_VERSION`: RHDH version to deploy (default: `next`) - `INSTALLATION_METHOD`: Deployment method - `helm` or `operator` (default: `helm`) ## [1.1.5] ### Added + - **Plugin metadata auto-generation**: When `dynamic-plugins.yaml` doesn't exist, configuration is automatically generated from `metadata/*.yaml` files - **OCI URL generation for PR builds**: When `GIT_PR_NUMBER` is set, local plugin paths are replaced with OCI URLs (e.g., `oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/my-plugin:pr_1234__1.0.0`) - Plugin metadata injection into existing `dynamic-plugins.yaml` configurations @@ -97,6 +123,7 @@ All notable changes to this project will be documented in this file. - **Early pod failure detection**: `waitForPodsWithFailureDetection()` in KubernetesClientHelper detects CrashLoopBackOff, ImagePullBackOff, init container failures, etc. within seconds instead of waiting for full timeout ### Changed + - Plugin versions for OCI URLs are fetched from source repo's `package.json` using `source.json` commit ref - Metadata handling disabled for periodic builds (when `JOB_NAME` contains `periodic-`) - Strict error handling for PR builds (fails if source files missing or fetch fails) @@ -104,29 +131,35 @@ All notable changes to this project will be documented in this file. - RHDH and Keycloak deployments now use early failure detection for faster error reporting ### Environment Variables + - `GIT_PR_NUMBER`: Enables OCI URL generation for PR builds - `RHDH_SKIP_PLUGIN_METADATA_INJECTION`: Disables all metadata handling ## [1.1.4] ### Fixed + - Keycloak: Use plain HTTP route to avoid certificate issues (#19) ### Security + - Bump `lodash` from 4.17.21 to 4.17.23 - Bump `tar` from 7.5.2 to 7.5.6 ## [1.1.3] ### Added + - Comprehensive VitePress documentation site (#14) ### Fixed + - Corepack setup for Yarn 3 in CI workflow (#16) ## [1.1.2] ### Added + - Keycloak integration with modular auth configuration (#8) - KeycloakHelper class for Keycloak deployment and management - Support for guest and Keycloak authentication providers @@ -135,6 +168,7 @@ All notable changes to this project will be documented in this file. - Keycloak integration documentation (#9) ### Changed + - Improved RHDHDeployment class with `configure()` method - Enhanced configuration merging for auth-specific configs - Better environment variable handling @@ -142,12 +176,14 @@ All notable changes to this project will be documented in this file. ## [1.1.1] ### Added + - Playwright helpers: UIHelper, LoginHelper, APIHelper (#7) - Page objects: CatalogPage, HomePage, CatalogImportPage, ExtensionsPage, NotificationPage ## [1.1.0] ### Added + - Initial release of `@red-hat-developer-hub/e2e-test-utils` - RHDHDeployment class for RHDH deployment - Playwright test fixtures (rhdh, uiHelper, loginHelper, baseURL) @@ -160,11 +196,13 @@ All notable changes to this project will be documented in this file. - Support for Helm and Operator deployment methods ### Fixed + - Config file resolution for published package (#6) ## [1.0.0] ### Added + - Initial project setup - Basic deployment functionality - Playwright integration @@ -178,21 +216,23 @@ All notable changes to this project will be documented in this file. 1. **Update imports** - No changes required 2. **Configure authentication** - Use the new `auth` option: ```typescript - await rhdh.configure({ auth: "keycloak" }); + await rhdh.configure({ auth: 'keycloak' }); ``` 3. **Keycloak auto-deployment** - Keycloak is now automatically deployed unless `SKIP_KEYCLOAK_DEPLOYMENT=true` ### New Authentication Configuration Before (1.0.x): + ```typescript // Manual Keycloak setup required await rhdh.deploy(); ``` After (1.1.x): + ```typescript // Keycloak is auto-deployed and configured -await rhdh.configure({ auth: "keycloak" }); +await rhdh.configure({ auth: 'keycloak' }); await rhdh.deploy(); ``` diff --git a/docs/guide/helpers/auth-api-helper.md b/docs/guide/helpers/auth-api-helper.md index 7534f4f..e7b6aa0 100644 --- a/docs/guide/helpers/auth-api-helper.md +++ b/docs/guide/helpers/auth-api-helper.md @@ -56,42 +56,6 @@ const token = await authApiHelper.getToken('github'); const token = await authApiHelper.getToken('oidc', 'development'); ``` -## Complete Example - -### Fetching a Token to Use with RbacApiHelper - -A common pattern is to retrieve a token after login and pass it to `RbacApiHelper` (or another API helper) to make authenticated API calls: - -```typescript -import { test } from '@red-hat-developer-hub/e2e-test-utils/test'; -import { - AuthApiHelper, - RbacApiHelper, -} from '@red-hat-developer-hub/e2e-test-utils/helpers'; - -test.describe('RBAC policy management', () => { - let rbacApiHelper: RbacApiHelper; - - test.beforeAll(async ({ page, loginHelper }) => { - // Log in first so the page session is authenticated - await page.goto('/'); - await loginHelper.loginAsKeycloakUser(); - - // Retrieve the Backstage identity token - const authApiHelper = new AuthApiHelper(page); - const token = await authApiHelper.getToken(); - - // Build the RBAC helper with the token - rbacApiHelper = await RbacApiHelper.build(token); - }); - - test('verify role policies exist', async () => { - const response = await rbacApiHelper.getPoliciesByRole('my-role'); - // assert on response... - }); -}); -``` - ## Error Handling `getToken` throws on HTTP errors or if the token is missing from the response body. Wrap calls in a try/catch when you need to handle failures gracefully: @@ -114,3 +78,4 @@ try { - [RbacApiHelper](/guide/helpers/rbac-api-helper) — uses tokens obtained from `AuthApiHelper` - [LoginHelper](/guide/helpers/login-helper) — authenticates the browser session before calling `getToken` - [APIHelper](/guide/helpers/api-helper) — catalog and GitHub API operations +- [Common Patterns](/overlay/reference/patterns#fetching-a-token-to-use-with-rbacapihelper) — example of using `getToken` with `RbacApiHelper` diff --git a/docs/overlay/reference/patterns.md b/docs/overlay/reference/patterns.md index 84c4f98..9435035 100644 --- a/docs/overlay/reference/patterns.md +++ b/docs/overlay/reference/patterns.md @@ -24,6 +24,40 @@ test.describe("Plugin tests", () => { }); ``` +### Fetching a Token to Use with RbacApiHelper + +A common pattern is to retrieve a Backstage identity token after login and pass it to `RbacApiHelper` (or another API helper) to make authenticated API calls: + +```typescript +import { test } from '@red-hat-developer-hub/e2e-test-utils/test'; +import { + AuthApiHelper, + RbacApiHelper, +} from '@red-hat-developer-hub/e2e-test-utils/helpers'; + +test.describe('RBAC policy management', () => { + let rbacApiHelper: RbacApiHelper; + + test.beforeAll(async ({ page, loginHelper }) => { + // Log in first so the page session is authenticated + await page.goto('/'); + await loginHelper.loginAsKeycloakUser(); + + // Retrieve the Backstage identity token + const authApiHelper = new AuthApiHelper(page); + const token = await authApiHelper.getToken(); + + // Build the RBAC helper with the token + rbacApiHelper = await RbacApiHelper.build(token); + }); + + test('verify role policies exist', async () => { + const response = await rbacApiHelper.getPoliciesByRole('my-role'); + // assert on response... + }); +}); +``` + ## Project and Spec Best Practices Each Playwright project name creates a **separate namespace**. To keep deployments fast and predictable: diff --git a/package.json b/package.json index 5e9bea1..872f81e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@red-hat-developer-hub/e2e-test-utils", - "version": "1.1.14", + "version": "1.1.15", "description": "Test utilities for RHDH E2E tests", "license": "Apache-2.0", "repository": { diff --git a/src/playwright/helpers/index.ts b/src/playwright/helpers/index.ts index 72f3a89..c594ba7 100644 --- a/src/playwright/helpers/index.ts +++ b/src/playwright/helpers/index.ts @@ -2,5 +2,5 @@ export { GITHUB_API_ENDPOINTS } from "./api-endpoints.js"; export { APIHelper } from "./api-helper.js"; export { LoginHelper, setupBrowser } from "./common.js"; export { UIhelper } from "./ui-helper.js"; -export { RbacApiHelper } from "./rbac-api-helper.js"; +export { RbacApiHelper, Policy, Response } from "./rbac-api-helper.js"; export { AuthApiHelper } from "./auth-api-helper.js"; From ceb32c1d0471aa28f888865bb1ea0403eb2fe44d Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Tue, 10 Mar 2026 22:56:36 -0400 Subject: [PATCH 5/6] feat(playwright): throw an error when policy response isn't an array Signed-off-by: Patrick Knight --- src/playwright/helpers/rbac-api-helper.ts | 33 +++++++---------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/src/playwright/helpers/rbac-api-helper.ts b/src/playwright/helpers/rbac-api-helper.ts index 590cf67..e9a5228 100644 --- a/src/playwright/helpers/rbac-api-helper.ts +++ b/src/playwright/helpers/rbac-api-helper.ts @@ -82,30 +82,17 @@ export class Response { static async removeMetadataFromResponse( response: APIResponse, ): Promise { - try { - const responseJson = await response.json(); + const responseJson = await response.json(); - // Validate that the response is an array - if (!Array.isArray(responseJson)) { - console.warn( - `Expected an array but received: ${JSON.stringify(responseJson)}`, - ); - return []; // Return an empty array as a fallback - } - - // Strip the `metadata` field before passing policies to the delete endpoint, - // which rejects payloads that contain it - const responseClean = responseJson.map((item: { metadata: unknown }) => { - if (item.metadata) { - delete item.metadata; - } - return item; - }); - - return responseClean; - } catch (error) { - console.error("Error processing API response:", error); - throw new Error("Failed to process the API response"); + if (!Array.isArray(responseJson)) { + throw new TypeError( + `Expected an array from policy response but received: ${JSON.stringify(responseJson)}`, + ); } + + return responseJson.map((item: { metadata?: unknown }) => { + delete item.metadata; + return item; + }); } } From 9fb4170743ce3de4065697554c8b855773b6fc02 Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Wed, 11 Mar 2026 00:31:18 -0400 Subject: [PATCH 6/6] feat(playwright): move plugin-rbac-common from devDependencies to dependencies Signed-off-by: Patrick Knight --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 872f81e..c9c4ab0 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "@playwright/test": "^1.57.0" }, "devDependencies": { - "@backstage-community/plugin-rbac-common": "1.23.0", "@backstage/catalog-model": "1.7.5", "@playwright/test": "^1.57.0", "@types/fs-extra": "^11.0.4", @@ -94,6 +93,7 @@ }, "dependencies": { "@axe-core/playwright": "^4.11.0", + "@backstage-community/plugin-rbac-common": "1.23.0", "@eslint/js": "^9.39.1", "@keycloak/keycloak-admin-client": "^26.0.0", "@kubernetes/client-node": "^1.4.0",