Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions integration-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,34 @@ about to tag.
(`expect(...).toPass(...)`, same as the pre-flight check above) rather
than searching once -- see `tests/dfiq-facet.spec.ts` and
`tests/dfiq-scenario-inline-question.spec.ts`.
- **`getByLabel(X)` can fuzzy-match a field's own clear/show-password
append icon, not just the field itself.** Vuetify auto-generates an
`aria-label="X appended action"` for any `@click:append` icon (the
clear button on "Reference"/"Step name", the eye icon on "Password",
...), and that contains the field's label as a substring, so
`getByLabel` resolves to both and throws a strict-mode violation.
Use `getByRole("textbox", { name: X })` instead, which only matches the
actual input/textarea -- see `tests/rbac-group-membership.spec.ts` or
`tests/dfiq-question-approaches.spec.ts`.
- **A `v-combobox`/`v-select`'s dropdown menu teleports to the shared
overlay root, same as a nested dialog's content (see the gotcha above
about `v-window-item--active`/neighbor tables for the general pattern)
-- it is not nested under its logical container in the real DOM**, only
in the accessibility-tree snapshot Playwright's error output shows,
which can be misleading. A locator scoped to the container (e.g. a
dialog) silently finds nothing for `role="option"`; query it unscoped
via `page.getByRole("option", ...)` instead -- see
`tests/rbac-group-membership.spec.ts`.
- **A multi-select `v-combobox`'s dropdown doesn't close itself after
picking one option** (more could still be picked), so it can sit on top
of and intercept clicks on whatever's below it until explicitly
dismissed (e.g. `page.keyboard.press("Escape")`) -- see
`tests/rbac-group-membership.spec.ts`.
- **The "v-select doesn't reliably `.click()`" gotcha above isn't
specific to "Diamond model"** -- it applies to Vuetify `v-select`s
generally in this suite (confirmed again on ACLEdit's "Role" field):
focus + keyboard (`ArrowDown`, then click the option by role), not
`.click()`.

## Adding a spec

Expand Down
106 changes: 106 additions & 0 deletions integration-tests/playwright/tests/rbac-group-membership.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { expect, test } from "@playwright/test";
import { login, TEST_PASSWORD, TEST_USERNAME } from "./helpers";

/**
* RBAC (users, groups, ACLEdit) has zero coverage in this suite despite being
* a whole feature area, and the frontend already carries a known workaround
* for a backend/OpenAPI schema mismatch on the `Permission` IntFlag (see
* services/rbac.ts and services/users.ts) -- this exercises exactly that
* code path. Creates a user and a group through the admin UI, grants the
* user a Writer role on the group via ACLEdit, and confirms it both in the
* UI and via a direct API check of the persisted ACL edge.
*/
test("create a user and a group, then grant the user a Writer role on the group", async ({ page }) => {
const username = `integration-test-user-${Date.now()}`;
const password = "Integration-Test-Password-1!";
const groupName = `integration-test-group-${Date.now()}`;

await login(page);

// --- Create a user ---
await page.goto("/system/users");
await page.getByLabel("Username").fill(username);
// getByLabel("Password") also fuzzy-matches the show/hide-password
// append icon's own "Password appended action" aria-label -- scope to
// the actual input via role, same fix as elsewhere in this suite.
await page.getByRole("textbox", { name: "Password" }).fill(password);
await page.getByRole("button", { name: "Add user" }).click();
await expect(page.getByRole("link", { name: username })).toBeVisible();

// --- Create a group ---
await page.goto("/system/groups");
await page.getByRole("button", { name: "Create group" }).click();
const newGroupDialog = page.getByRole("dialog");
await expect(newGroupDialog.getByText("New group")).toBeVisible();
await newGroupDialog.getByLabel("Name").fill(groupName);
await newGroupDialog.getByRole("button", { name: "Create group" }).click();
await expect(newGroupDialog).toBeHidden();

const groupRow = page.locator("tbody tr").filter({ hasText: groupName });
await expect(groupRow).toBeVisible();

// --- Grant the user a Writer role on the group, via ACLEdit ---
await groupRow.locator("button", { has: page.locator(".mdi-account-multiple-plus-outline") }).click();
const aclDialog = page.getByRole("dialog");
await expect(aclDialog.getByText(`ACL for`)).toBeVisible();

const identitiesField = aclDialog.getByLabel("Select identities");
await identitiesField.fill(username);
// The combobox's dropdown menu teleports to the shared overlay root, not
// nested under the dialog's own DOM subtree -- same underlying Vuetify
// behavior as the nested-dialog gotcha documented in the README, just
// for a combobox menu instead. Query unscoped.
await page.getByRole("option", { name: username }).click();
// The identities combobox is multi-select, so picking an option doesn't
// close its dropdown (more could be picked) -- close it, or it sits on
// top of the Role select below.
await page.keyboard.press("Escape");

// The Role v-select doesn't reliably respond to .click() (see
// tests/indicator-lifecycle.spec.ts / the README) -- focus and drive it
// with the keyboard instead.
await aclDialog.getByLabel("Role").focus();
await page.keyboard.press("ArrowDown");
await page.getByRole("option", { name: "Writer" }).click();

const updateResponse = page.waitForResponse(
res => res.url().includes("/update-members") && res.request().method() === "POST"
);
await aclDialog.getByRole("button", { name: "Update memberships" }).click();
await updateResponse;

await expect(aclDialog.locator("tbody tr").filter({ hasText: username })).toContainText("Writer");
await aclDialog.getByRole("button", { name: "Close" }).click();

// --- Confirm it persisted server-side, as the exact IntFlag value (3) ---
const tokenResponse = await page.request.post("/api/v2/auth/token", {
form: { username: TEST_USERNAME, password: TEST_PASSWORD }
});
const { access_token: accessToken } = await tokenResponse.json();
const groupsResponse = await page.request.post("/api/v2/groups/search", {
headers: { Authorization: `Bearer ${accessToken}` },
data: { name: groupName }
});
const { groups } = await groupsResponse.json();
expect(groups).toHaveLength(1);
const groupId = groups[0].id;

const aclResponse = await page.request.get(`/api/v2/rbac/rbacgroup/${groupId}`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
const acls: Record<string, { role: number }> = (await aclResponse.json()).acls;
const membership = Object.entries(acls).find(([name]) => name === username);
expect(membership).toBeDefined();
expect(membership?.[1].role).toBe(3);

// --- Cleanup ---
await page.request.delete(`/api/v2/groups/${groupId}`, { headers: { Authorization: `Bearer ${accessToken}` } });
const usersResponse = await page.request.post("/api/v2/users/search", {
headers: { Authorization: `Bearer ${accessToken}` },
data: { username }
});
const { users } = await usersResponse.json();
for (const user of users) {
await page.request.delete(`/api/v2/users/${user.id}`, { headers: { Authorization: `Bearer ${accessToken}` } });
}
});
Loading