diff --git a/integration-tests/README.md b/integration-tests/README.md index f109a90..74099c2 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -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 diff --git a/integration-tests/playwright/tests/rbac-group-membership.spec.ts b/integration-tests/playwright/tests/rbac-group-membership.spec.ts new file mode 100644 index 0000000..7b41238 --- /dev/null +++ b/integration-tests/playwright/tests/rbac-group-membership.spec.ts @@ -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 = (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}` } }); + } +});