Skip to content

Commit d81d1a7

Browse files
committed
chore(rbac): migrate RBAC plugin tests from RHDH
Signed-off-by: Patrick Knight <pknight@redhat.com>
1 parent f40eac0 commit d81d1a7

24 files changed

Lines changed: 4579 additions & 0 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Keycloak API credentials for catalog-users e2e tests.
2+
# Copy to .env and set values (e.g. from deployment secrets or CI).
3+
# KEYCLOAK_BASE_URL=https://keycloak.example.com
4+
# KEYCLOAK_REALM=your-realm
5+
# KEYCLOAK_CLIENT_ID=admin-cli
6+
# KEYCLOAK_CLIENT_SECRET=<your-secret>
7+
# RBAC_ADMIN_PASSWORD=<your-password>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
nodeLinker: node-modules
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createEslintConfig } from "@red-hat-developer-hub/e2e-test-utils/eslint";
2+
3+
export default createEslintConfig(import.meta.dirname);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "rbac-e2e-tests",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"engines": {
7+
"node": ">=22",
8+
"yarn": ">=3"
9+
},
10+
"packageManager": "yarn@3.8.7",
11+
"description": "E2E tests for RBAC",
12+
"scripts": {
13+
"test": "playwright test",
14+
"report": "playwright show-report",
15+
"test:ui": "playwright test --ui",
16+
"test:headed": "playwright test --headed",
17+
"lint:check": "eslint .",
18+
"lint:fix": "eslint . --fix",
19+
"prettier:check": "prettier --check .",
20+
"prettier:fix": "prettier --write .",
21+
"tsc:check": "tsc --noEmit",
22+
"check": "tsc --noEmit && yarn lint:check && yarn prettier:check"
23+
},
24+
"devDependencies": {
25+
"@backstage-community/plugin-rbac-common": "1.23.0",
26+
"@eslint/js": "^9.39.2",
27+
"@playwright/test": "1.57.0",
28+
"@red-hat-developer-hub/e2e-test-utils": "1.1.14",
29+
"@types/node": "^24.10.1",
30+
"dotenv": "^16.4.7",
31+
"eslint": "^9.39.2",
32+
"eslint-plugin-check-file": "^3.3.1",
33+
"eslint-plugin-playwright": "^2.4.0",
34+
"prettier": "^3.7.4",
35+
"typescript": "^5.9.3",
36+
"typescript-eslint": "^8.50.0"
37+
}
38+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from "@red-hat-developer-hub/e2e-test-utils/playwright-config";
2+
import dotenv from "dotenv";
3+
4+
dotenv.config({ path: `${import.meta.dirname}/.env` });
5+
6+
export default defineConfig({
7+
projects: [
8+
{
9+
name: "rbac",
10+
},
11+
],
12+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { APIRequestContext, APIResponse, request } from "@playwright/test";
2+
3+
export interface Policy {
4+
entityReference: string;
5+
permission: string;
6+
policy: string;
7+
effect: string;
8+
}
9+
10+
export default class RhdhRbacApi {
11+
private readonly apiUrl = process.env.RHDH_BASE_URL + "/api/permission/";
12+
private readonly authHeader: {
13+
// eslint-disable-next-line @typescript-eslint/naming-convention
14+
Accept: "application/json";
15+
// eslint-disable-next-line @typescript-eslint/naming-convention
16+
Authorization: string;
17+
};
18+
private myContext!: APIRequestContext;
19+
20+
private constructor(private readonly token: string) {
21+
this.authHeader = {
22+
Accept: "application/json",
23+
Authorization: `Bearer ${this.token}`,
24+
};
25+
}
26+
27+
public static async build(token: string): Promise<RhdhRbacApi> {
28+
const instance = new RhdhRbacApi(token);
29+
instance.myContext = await request.newContext({
30+
baseURL: instance.apiUrl,
31+
extraHTTPHeaders: instance.authHeader,
32+
});
33+
return instance;
34+
}
35+
36+
// Used during the afterAll to ensure we clean up any policies that are left over due to failing tests
37+
public async getPoliciesByRole(policy: string): Promise<APIResponse> {
38+
return await this.myContext.get(`policies/role/default/${policy}`);
39+
}
40+
41+
// Used during the afterAll to ensure we clean up any roles that are left over due to failing tests
42+
public async deleteRole(role: string): Promise<APIResponse> {
43+
return await this.myContext.delete(`roles/role/default/${role}`);
44+
}
45+
46+
// Used during the afterAll to ensure we clean up any policies that are left over due to failing tests
47+
public async deletePolicy(policy: string, policies: Policy[]) {
48+
return await this.myContext.delete(`policies/role/default/${policy}`, {
49+
data: policies,
50+
});
51+
}
52+
}
53+
54+
export class Response {
55+
static async removeMetadataFromResponse(
56+
response: APIResponse,
57+
): Promise<unknown[]> {
58+
try {
59+
const responseJson = await response.json();
60+
61+
// Validate that the response is an array
62+
if (!Array.isArray(responseJson)) {
63+
console.warn(
64+
`Expected an array but received: ${JSON.stringify(responseJson)}`,
65+
);
66+
return []; // Return an empty array as a fallback
67+
}
68+
69+
// Clean metadata from the response
70+
const responseClean = responseJson.map((item: { metadata: unknown }) => {
71+
if (item.metadata) {
72+
delete item.metadata;
73+
}
74+
return item;
75+
});
76+
77+
return responseClean;
78+
} catch (error) {
79+
console.error("Error processing API response:", error);
80+
throw new Error("Failed to process the API response");
81+
}
82+
}
83+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Page } from "@red-hat-developer-hub/e2e-test-utils/test";
2+
3+
// here, we spy on the request to get the Backstage token to use APIs
4+
export class RhdhAuthApiHack {
5+
static async getToken(
6+
page: Page,
7+
provider: "oidc" = "oidc",
8+
environment: string = "production",
9+
) {
10+
try {
11+
const response = await page.request.get(
12+
`/api/auth/${provider}/refresh?optional=&scope=&env=${environment}`,
13+
{
14+
headers: {
15+
// eslint-disable-next-line @typescript-eslint/naming-convention
16+
"x-requested-with": "XMLHttpRequest",
17+
},
18+
},
19+
);
20+
21+
if (!response.ok()) {
22+
throw new Error(`HTTP error! Status: ${response.status()}`);
23+
}
24+
25+
const body = await response.json();
26+
27+
if (typeof body?.backstageIdentity?.token === "string") {
28+
return body.backstageIdentity.token;
29+
} else {
30+
throw new TypeError("Token not found in response body");
31+
}
32+
} catch (error) {
33+
console.error("Failed to retrieve the token:", error);
34+
35+
throw error;
36+
}
37+
}
38+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export type RbacRef = {
2+
name: string;
3+
ref: string;
4+
};
5+
6+
export const RBAC_ROLES: Record<string, RbacRef> = {
7+
rbacOwnership: {
8+
name: "rbac-ownership-role",
9+
ref: "role:default/rbac-ownership-role",
10+
},
11+
rbacConditional: {
12+
name: "rbac-conditional-role",
13+
ref: "role:default/rbac-conditional-role",
14+
},
15+
conditionalResource: {
16+
name: "rbac-conditional-resource-role",
17+
ref: "role:default/rbac-conditional-resource-role",
18+
},
19+
overviewListEdit: {
20+
name: "rbac-list-edit-role",
21+
ref: "role:default/rbac-list-edit-role",
22+
},
23+
overviewMembers: {
24+
name: "rbac-overview-members-role",
25+
ref: "role:default/rbac-overview-members-role",
26+
},
27+
overviewPolicies: {
28+
name: "rbac-overview-policies-role",
29+
ref: "role:default/rbac-overview-policies-role",
30+
},
31+
rbacAdmin: {
32+
name: "rbac_admin",
33+
ref: "role:default/rbac_admin",
34+
},
35+
test2Role: {
36+
name: "test2-role",
37+
ref: "role:default/test2-role",
38+
},
39+
catalogReader: {
40+
name: "catalog_reader",
41+
ref: "role:default/catalog_reader",
42+
},
43+
};
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
export type RbacUser = {
2+
username: string;
3+
firstName: string;
4+
lastName: string;
5+
email: string;
6+
password: string;
7+
groups: string[];
8+
};
9+
10+
export type RBACGroup = {
11+
name: string;
12+
keycloak?: boolean;
13+
};
14+
15+
/**
16+
* Users created in Keycloak for RBAC e2e tests.
17+
* Each key describes the scenario the user serves.
18+
*
19+
* - rbacAdmin: RBAC plugin admin; configured as admin in app-config.
20+
* - noAccess: No permissions; verifies RBAC sidebar hidden and direct nav blocked.
21+
* - tara: Fixture member used when constructing roles via the UI.
22+
* - jonathon: Fixture member used when editing role membership via the UI.
23+
* - currentUserOwner: Member of rhdh-qe-2-team. Tests $currentUser: can unregister own
24+
* components but not group-owned ones.
25+
* - conditionalManager: Gets conditional RBAC manage permission via rbac-ownership-role
26+
* (catalog_reader in CSV). Used in the serial IsOwner suite.
27+
* - allowAllowUser: catalog_reader. Both static allow AND conditional IS_ENTITY_OWNER
28+
* allow read — tests policyDecisionPrecedence allow+allow case.
29+
* - conditionalAllowUser: Has static deny (all_resource_denier) but conditional policy allows
30+
* read — tests conditional overrides deny.
31+
* - conditionalDenyUser: Has static allow but conditional deny wins — sees empty catalog.
32+
* - conditionalDenier: all_resource_reader + conditional_denier roles — conditional deny
33+
* overrides static allow.
34+
*/
35+
export const RBAC_DESCRIPTIVE_USERS: Record<string, RbacUser> = {
36+
rbacAdmin: {
37+
username: "rbac-admin",
38+
firstName: "RBAC",
39+
lastName: "Admin",
40+
email: "rbac-admin@example.com",
41+
password:
42+
process.env.RBAC_ADMIN_PASSWORD ??
43+
crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
44+
groups: [],
45+
},
46+
noAccess: {
47+
username: "no-access",
48+
firstName: "No",
49+
lastName: "Access",
50+
email: "no-access@example.com",
51+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
52+
groups: [],
53+
},
54+
tara: {
55+
username: "tara",
56+
firstName: "Tara",
57+
lastName: "MacGovern",
58+
email: "tara@example.com",
59+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
60+
groups: [],
61+
},
62+
jonathon: {
63+
username: "jonathon",
64+
firstName: "Jonathon",
65+
lastName: "Page",
66+
email: "jonathon@example.com",
67+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
68+
groups: [],
69+
},
70+
currentUserOwner: {
71+
username: "current-user-owner",
72+
firstName: "Current",
73+
lastName: "User-Owner",
74+
email: "current-user-owner@example.com",
75+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
76+
groups: ["rhdh-qe-2-team"],
77+
},
78+
conditionalManager: {
79+
username: "conditional-manager",
80+
firstName: "Conditional",
81+
lastName: "Manager",
82+
email: "conditional-manager@example.com",
83+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
84+
groups: [],
85+
},
86+
allowAllowUser: {
87+
username: "allow-allow-user",
88+
firstName: "Allow",
89+
lastName: "Allow-User",
90+
email: "allow-allow-user@example.com",
91+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
92+
groups: [],
93+
},
94+
conditionalAllowUser: {
95+
username: "conditional-allow-user",
96+
firstName: "Conditional",
97+
lastName: "Allow-User",
98+
email: "conditional-allow-user@example.com",
99+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
100+
groups: [],
101+
},
102+
conditionalDenyUser: {
103+
username: "conditional-deny-user",
104+
firstName: "Conditional",
105+
lastName: "Deny-User",
106+
email: "conditional-deny-user@example.com",
107+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
108+
groups: [],
109+
},
110+
conditionalDenier: {
111+
username: "conditional-denier",
112+
firstName: "Conditional",
113+
lastName: "Denier",
114+
email: "conditional-denier@example.com",
115+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
116+
groups: [],
117+
},
118+
childGroupMember: {
119+
username: "child-group-member",
120+
firstName: "Child",
121+
lastName: "Group-Member",
122+
email: "child-group-member@example.com",
123+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
124+
groups: [],
125+
},
126+
subChildGroupMember: {
127+
username: "sub-child-group-member",
128+
firstName: "Sub-Child",
129+
lastName: "Group-Member",
130+
email: "sub-child-group-member@example.com",
131+
password: crypto.randomUUID().substring(0, 21).replaceAll("-", "0"),
132+
groups: [],
133+
},
134+
};
135+
136+
/**
137+
* Groups created in Keycloak for RBAC e2e tests.
138+
* The transitive parent/child/sub-child groups are not created via Keycloak
139+
* (configureForRHDH does not support sub-group membership); they are managed
140+
* separately in the catalog config.
141+
*/
142+
export const RBAC_GROUPS: Record<string, RBACGroup> = {
143+
backstage: { name: "backstage", keycloak: true },
144+
currentUserOwnerTeam: { name: "rhdh-qe-2-team", keycloak: true },
145+
rhdhParentTeam: { name: "rhdh-qe-parent-team" },
146+
rhdhChildTeam: { name: "rhdh-qe-child-team" },
147+
rhdhSubChildTeam: { name: "rhdh-qe-sub-child-team" },
148+
};
149+
150+
/**
151+
* Returns the UI display name for a user in RBAC_DESCRIPTIVE_USERS.
152+
* Used when selecting members from the role creation/edit form dropdowns.
153+
*/
154+
export const displayName = (key: keyof typeof RBAC_DESCRIPTIVE_USERS): string =>
155+
`${RBAC_DESCRIPTIVE_USERS[key].firstName} ${RBAC_DESCRIPTIVE_USERS[key].lastName}`;
156+
157+
export const userEntityRef = (user: RbacUser): string =>
158+
`user:default/${user.username}`;

0 commit comments

Comments
 (0)