From 72ec9ea64fc901e05cc114bf82bc8d81244fb556 Mon Sep 17 00:00:00 2001 From: Neha P <144308844+neha00290@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:11:31 +0000 Subject: [PATCH] test(iam): cover the All Modules column select-all, and unblock two tests it needs (#15240) --- tests/ui-testing/pages/iamPages/rolesPage.js | 15 ++ .../playwright-tests/IAM/iam-fixtures.js | 21 +- .../IAM/iam-roles-enforcement.spec.js | 99 +++++++++- .../IAM/iam-roles-grants.spec.js | 180 +++++++++++++++++- 4 files changed, 304 insertions(+), 11 deletions(-) diff --git a/tests/ui-testing/pages/iamPages/rolesPage.js b/tests/ui-testing/pages/iamPages/rolesPage.js index e0631707bb..929ba35104 100644 --- a/tests/ui-testing/pages/iamPages/rolesPage.js +++ b/tests/ui-testing/pages/iamPages/rolesPage.js @@ -17,6 +17,9 @@ export const ACTIONS = ['AllowAll', 'AllowList', 'AllowGet', 'AllowPost', 'Allow // own slug, `edit-role-module-rail-overview` (ModuleRail.vue). const RAIL_ITEM = 'edit-role-module-rail-item-'; +// The open-this-row control a pane row carries; its suffix is the row's resource key. +const PANE_OPEN = 'edit-role-module-pane-open-'; + export class RolesPage { constructor(page) { this.page = page; @@ -175,6 +178,18 @@ export class RolesPage { return [...new Set(slugs.map((s) => s.replace(RAIL_ITEM, '')))]; } + /** + * Resource keys of the rows the pane is CURRENTLY showing — after any search or + * filter. On the All Modules view a row is a whole module, so this is the set one + * column-header tick acts on. + */ + async paneRowKeys() { + const slugs = await this.page + .locator(`[data-test^="${PANE_OPEN}"]`) + .evaluateAll((els) => els.map((e) => e.getAttribute('data-test'))); + return slugs.map((slug) => slug.replace(PANE_OPEN, '')); + } + scopeRow(key) { return this.page.locator(`[data-test="edit-role-module-pane-scope-row-${key}"]`); } diff --git a/tests/ui-testing/playwright-tests/IAM/iam-fixtures.js b/tests/ui-testing/playwright-tests/IAM/iam-fixtures.js index 5bfd97aa4a..20df822700 100644 --- a/tests/ui-testing/playwright-tests/IAM/iam-fixtures.js +++ b/tests/ui-testing/playwright-tests/IAM/iam-fixtures.js @@ -77,6 +77,25 @@ const reqAs = async (page, method, path, data) => { return { status: resp.status(), body: await resp.json().catch(() => ({})) }; }; +/** + * The v2 twin of reqAs(): same session-cookie identity, but the v2 route shape + * `/api/v2/` rather than `/api/`. + * + * Needed because the v1 routes for alerts and reports are deprecated AND resolve to a + * different OpenFGA object than their v2 replacements — v1 /reports checks the report + * item, v2 checks the folder. Probing v1 therefore measures a dead route rather than + * the grant under test, so any enforcement assertion about those modules must come + * through here. + */ +const reqAsV2 = async (page, method, path, data) => { + const resp = await page.request.fetch(`${api()}/v2/${org()}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + ...(data ? { data } : {}), + }); + return { status: resp.status(), body: await resp.json().catch(() => ({})) }; +}; + /** Did this request succeed? Anything >= 400 counts as denied. */ const allowed = (res) => res.status < 400; @@ -415,7 +434,7 @@ const rbacEnabled = async (page) => { module.exports = { loginAs, MEMBER_PASSWORD, createMember, sweepUsers, rbacEnabled, - PREFIX, ns, uniq, org, api, req, reqAs, allowed, + PREFIX, ns, uniq, org, api, req, reqAs, reqAsV2, allowed, listRoles, listGroups, getGroup, listUsers, getPerms, createRole, setRolePerms, clearRolePerms, setGroup, createGroupApi, sweepRoles, makeTracker, createDashboardFolder, createDashboardIn, listDashboardFolders, sweepDashboardFolders, diff --git a/tests/ui-testing/playwright-tests/IAM/iam-roles-enforcement.spec.js b/tests/ui-testing/playwright-tests/IAM/iam-roles-enforcement.spec.js index 7d11da52ad..42b1c86dcb 100644 --- a/tests/ui-testing/playwright-tests/IAM/iam-roles-enforcement.spec.js +++ b/tests/ui-testing/playwright-tests/IAM/iam-roles-enforcement.spec.js @@ -1,4 +1,4 @@ -// IAM → Edit Role · enforcement, end to end (E-01 .. E-06) +// IAM → Edit Role · enforcement, end to end (E-01 .. E-07) // // Plan: .claude/commands/nvpworkflow/iam-roles-redesign-tests.md (Wave 2, flows 1-6) // @@ -28,7 +28,7 @@ const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures const PageManager = require('../../pages/page-manager.js'); const testLogger = require('../utils/test-logger.js'); const { - ns, req, reqAs, allowed, listRoles, listUsers, createRole, setRolePerms, clearRolePerms, + ns, req, reqAs, reqAsV2, allowed, listRoles, listUsers, createRole, setRolePerms, clearRolePerms, makeTracker, loginAs, MEMBER_PASSWORD, org, rbacEnabled, createDashboardFolder, createDashboardIn, } = require('./iam-fixtures.js'); @@ -55,12 +55,14 @@ const U_PAIR = `${NS}_e_pair@example.com`; const U_BASE = `${NS}_e_base@example.com`; // nothing, ever — the baseline const U_FOLDER = `${NS}_e_folder@example.com`; const U_ITEM = `${NS}_e_item@example.com`; +const U_BULK = `${NS}_e_bulk@example.com`; const R_STREAM = `${NS}_e_role_stream`; const R_TYPE = `${NS}_e_role_type`; const R_PAIR = `${NS}_e_role_pair`; const R_FOLDER = `${NS}_e_role_folder`; const R_ITEM = `${NS}_e_role_item`; +const R_BULK = `${NS}_e_role_bulk`; // One folder with two dashboards, plus a dashboard in a second folder. The neighbour // proves a grant does not widen inside a folder; the other folder proves it does not @@ -107,6 +109,23 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { await pm.rolesPage.waitForGrantsSettled(held); }; + /** + * Opens the logs module and brings ONE stream's row into view. + * + * The pane pages at 25 and sorts by name, so a fixture stream is only on page 1 + * while the org holds few streams. pentest holds 29 logs streams and + * `ui_auto_enf_e_s_granted` sorts to #26, which put it on page 2 and failed every + * tick here with "waiting for ...-col-AllowGet-checkbox to be visible" — a pass + * that depended on how cluttered the env happened to be. Filter to the row instead + * of trusting its position. + */ + const openStreamRow = async (name) => { + await pm.rolesPage.openStreamType('logs'); + await pm.rolesPage.paneSearch.fill(name); + await pm.rolesPage.entityCheckbox(name, 'AllowGet') + .waitFor({ state: 'visible', timeout: 15000 }); + }; + test.beforeAll(async ({ browser }) => { const page = await browser.newPage(); try { @@ -142,7 +161,7 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { // Base role `user` carries nothing, so the custom role is the whole of each // account's access. `admin` would make every assertion pass for the wrong reason. - for (const email of [U_STREAM, U_TYPE, U_PAIR, U_BASE, U_FOLDER, U_ITEM]) { + for (const email of [U_STREAM, U_TYPE, U_PAIR, U_BASE, U_FOLDER, U_ITEM, U_BULK]) { made.user(email); await req(page, 'POST', '/users', { email, password: MEMBER_PASSWORD, @@ -151,7 +170,7 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { } for (const [role, user] of [ [R_STREAM, U_STREAM], [R_TYPE, U_TYPE], [R_PAIR, U_PAIR], - [R_FOLDER, U_FOLDER], [R_ITEM, U_ITEM], + [R_FOLDER, U_FOLDER], [R_ITEM, U_ITEM], [R_BULK, U_BULK], ]) { made.role(role); await createRole(page, role); @@ -180,7 +199,7 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { // grant was still on R_STREAM when E-06 opened it expecting none — test-scope // instance of the same isolation bug the per-file namespaces fix at file scope. test.beforeEach(async ({ page }) => { - for (const role of [R_STREAM, R_TYPE, R_PAIR, R_FOLDER, R_ITEM]) { + for (const role of [R_STREAM, R_TYPE, R_PAIR, R_FOLDER, R_ITEM, R_BULK]) { await clearRolePerms(page, role); } }); @@ -219,7 +238,7 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { // Tick ONE stream's Get in the editor and save. await openRole(R_STREAM); - await pm.rolesPage.openStreamType('logs'); + await openStreamRow(S_GRANTED); await pm.rolesPage.grantEntity(S_GRANTED, 'AllowGet'); const payload = await pm.rolesPage.saveAndCapture(); expect(payload?.add, 'the editor sent no grant').toEqual([ @@ -242,7 +261,7 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { const base = await baseline(browser); await openRole(R_STREAM); - await pm.rolesPage.openStreamType('logs'); + await openStreamRow(S_GRANTED); await pm.rolesPage.grantEntity(S_GRANTED, 'AllowGet'); await pm.rolesPage.saveAndCapture(); @@ -251,7 +270,7 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { // Untick the same box in the editor — the removal path, not an API delete. await openRole(R_STREAM, 1); - await pm.rolesPage.openStreamType('logs'); + await openStreamRow(S_GRANTED); await pm.rolesPage.setCheckbox(pm.rolesPage.entityCheckbox(S_GRANTED, 'AllowGet'), false); const payload = await pm.rolesPage.saveAndCapture(); expect(payload?.remove).toEqual([{ object: `logs:${S_GRANTED}`, permission: 'AllowGet' }]); @@ -292,7 +311,7 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { await openRole(R_PAIR); await pm.rolesPage.openModule('function'); await pm.rolesPage.grantScope('function', 'AllowList'); - await pm.rolesPage.openStreamType('logs'); + await openStreamRow(S_GRANTED); await pm.rolesPage.grantEntity(S_GRANTED, 'AllowGet'); const saved = await pm.rolesPage.saveAndCapture(); expect(saved?.add, 'the editor did not send both grants in one save').toHaveLength(2); @@ -396,4 +415,66 @@ test.describe('IAM · Edit Role · enforcement', { tag: '@enterprise' }, () => { 'a single-dashboard grant allowed listing the whole folder', ).toBe(false); }); + + // ---------------- All Modules column select-all ---------------- + + test('E-07 · a role granted ONLY from the All Modules headers really opens every module', { + tag: ['@iam', '@iamRolesEnforcement', '@P0', '@all'] + }, async ({ page, browser }) => { + test.setTimeout(240000); + + // Six endpoints in six different modules. The point of the spread is that the + // All Modules rows are the FOLDER parents for dashboards, alerts and reports — + // the editor never writes `dashboard:`/`alert:`/`report:` tuples from here, so + // these three only pass if the folder grant genuinely cascades to the items. + const modules = { + streams: (p) => reqAs(p, 'GET', '/streams?type=logs').then(allowed), + dashboards: (p) => reqAs(p, 'GET', '/dashboards').then(allowed), + // v2 wherever a v2 route exists — see the note on reqAsV2: the v1 alert and + // report routes are deprecated AND check a different object, so probing them + // would measure a dead route instead of this grant. + alerts: (p) => reqAsV2(p, 'GET', '/alerts').then(allowed), + reports: (p) => reqAsV2(p, 'GET', '/reports').then(allowed), + functions: (p) => reqAs(p, 'GET', '/functions').then(allowed), + pipelines: (p) => reqAs(p, 'GET', '/pipelines').then(allowed), + }; + // Concurrent, not sequential. Six serial round trips make one poll attempt cost + // 6-12s under load, which left only a handful of attempts inside the budget + // below and failed this test once in a full-file run while passing it alone. + const reach = async (p) => { + const names = Object.keys(modules); + const got = await Promise.all(names.map((name) => modules[name](p))); + return Object.fromEntries(names.map((name, i) => [name, got[i]])); + }; + const allOf = (value) => Object.fromEntries(Object.keys(modules).map((k) => [k, value])); + + const basePage = await signIn(browser, U_BASE); + expect(await reach(basePage), 'an ungranted user could already reach a module — the fixture is wrong') + .toEqual(allOf(false)); + + // The grant is made ONLY by ticking the six column headers. Seeding it over the + // API would test OpenFGA, which the ENT pytest suite already covers; only this + // path tests the one click the feature actually ships. + await openRole(R_BULK); + await pm.rolesPage.openAllModules(); + for (const action of ['AllowAll', 'AllowList', 'AllowGet', 'AllowPost', 'AllowPut', 'AllowDelete']) { + await pm.rolesPage.setCheckbox(pm.rolesPage.bulkCheckbox(action), true); + } + const payload = await pm.rolesPage.saveAndCapture(); + expect(payload?.add?.length, 'the headers staged nothing').toBeGreaterThan(0); + // Proves the cascade claim above rather than assuming it: no item-level tuple + // for the three folder-backed modules was ever written. + for (const item of ['dashboard', 'alert', 'report']) { + expect( + payload.add.some((p) => p.object.startsWith(`${item}:`)), + `the headers wrote a ${item}: tuple, so this no longer tests the folder cascade`, + ).toBe(false); + } + + const user = await signIn(browser, U_BULK); + // 90s, not the 30s the single-grant tests above use: this one writes 315 tuples + // in one save and OpenFGA has to settle all of them before the last module opens. + // On failure the poll prints the per-module map, naming which one stayed shut. + await expect.poll(async () => await reach(user), { timeout: 90000 }).toEqual(allOf(true)); + }); }); diff --git a/tests/ui-testing/playwright-tests/IAM/iam-roles-grants.spec.js b/tests/ui-testing/playwright-tests/IAM/iam-roles-grants.spec.js index 6a028a0f44..891afd95de 100644 --- a/tests/ui-testing/playwright-tests/IAM/iam-roles-grants.spec.js +++ b/tests/ui-testing/playwright-tests/IAM/iam-roles-grants.spec.js @@ -1,4 +1,4 @@ -// IAM → Edit Role · grant semantics (G-01 .. G-16) +// IAM → Edit Role · grant semantics (G-01 .. G-18) // // Plan: .claude/commands/nvpworkflow/iam-roles-redesign-tests.md // @@ -27,6 +27,12 @@ const made = makeTracker(); const obj = (resource) => `${resource}:_all_${org()}`; +/** The six action columns, in the order the pane renders them. */ +const ACTIONS = ['AllowAll', 'AllowList', 'AllowGet', 'AllowPost', 'AllowPut', 'AllowDelete']; + +/** A permission set as comparable strings, so a PUT body and the API answer can be equated. */ +const tuples = (perms) => perms.map((p) => `${p.object}|${p.permission}`).sort(); + test.describe('IAM · Edit Role · grant semantics', { tag: '@enterprise' }, () => { // Serial, NOT parallel. every test in this file creates roles under one namespace and afterAll sweeps that // namespace, and beforeAll/afterAll run once PER WORKER — not per file. Under @@ -53,6 +59,37 @@ test.describe('IAM · Edit Role · grant semantics', { tag: '@enterprise' }, () return name; }; + /** The number on the "N Permissions" badge, or null while it has not painted. */ + const permissionCount = async () => { + const n = (await pm.rolesPage.permissionsCount.innerText().catch(() => '')).match(/\d+/); + return n ? Number(n[0]) : null; + }; + + /** Staged-change count from the unsaved badge; 0 when nothing is staged. */ + const staged = async () => { + const n = (await pm.rolesPage.unsavedCount.innerText().catch(() => '')).match(/\d+/); + return n ? Number(n[0]) : 0; + }; + + /** + * A column header once it reflects the SAVED grants, not the loading state. + * + * The pane paints before the grants land, and in that window every header reads + * unchecked AND disabled even on a fully granted role. Reading one straight after + * a reload therefore measures the spinner: it reported "not granted" on a role + * holding 315 tuples, and a tick sent into that window is swallowed by the + * disabled box. Settle on the hydrated pair before touching or asserting it. + */ + const hydratedHeader = async (action, checked) => { + const box = pm.rolesPage.bulkCheckbox(action); + await box.waitFor({ state: 'visible', timeout: 15000 }); + await expect + .poll(async () => `${await box.getAttribute('aria-checked')}/${await box.isDisabled().catch(() => 'err')}`, + { timeout: 30000 }) + .toBe(`${checked}/false`); + return box; + }; + test.beforeAll(async ({ browser }) => { const page = await browser.newPage(); try { @@ -331,6 +368,13 @@ test.describe('IAM · Edit Role · grant semantics', { tag: '@enterprise' }, () await pm.rolesPage.gotoRoles(); await pm.rolesPage.createRole(name, { startFrom: 'readonly' }); await pm.rolesPage.waitForGrantsSettled(); + // The preset seeds ASYNCHRONOUSLY: EditRole awaits the resource fetches before it + // stages anything, and waitForGrantsSettled() only waits for the count to paint. + // Saving straight after it races the seed and stores an EMPTY role — measured + // twice in a row on pentest, so this is a reproducible race, not a slow run. + await expect + .poll(async () => staged(), { timeout: 30000 }) + .toBeGreaterThan(0); await pm.rolesPage.save(); // AddRole's start-from seeds once the user lands on EditRole; a role created @@ -368,6 +412,140 @@ test.describe('IAM · Edit Role · grant semantics', { tag: '@enterprise' }, () .toEqual([...objects].sort()); }); + test('G-15 · ticking every action header grants all six on every module that offers them', { + tag: ['@iam', '@iamRolesGrants', '@P0', '@all'] + }, async ({ page }) => { + const name = await openFresh(page, 'bulkall'); + await pm.rolesPage.openAllModules(); + + // setCheckbox throws on a disabled box, so this also asserts no header locks + // another: a module-wide AllowAll does not lock its own row's narrower columns. + for (const action of ACTIONS) { + await pm.rolesPage.setCheckbox(pm.rolesPage.bulkCheckbox(action), true); + } + + const payload = await pm.rolesPage.saveAndCapture(); + expect(payload, 'save fired no PUT').toBeTruthy(); + expect(payload.remove).toEqual([]); + // Module-wide objects only: the All Modules rows ARE the modules, so nothing + // scoped to a single entity may be staged from here. + expect(payload.add.every((p) => p.object.endsWith(`:_all_${org()}`))).toBe(true); + + const byResource = {}; + for (const p of payload.add) (byResource[p.object.split(':')[0]] ||= []).push(p.permission); + // A plain module takes all six; one tick per column must reach every one of them. + expect(byResource.stream?.sort()).toEqual([...ACTIONS].sort()); + expect(byResource.function?.sort()).toEqual([...ACTIONS].sort()); + // ...and a module that hides actions must take only what its row can show, rather + // than the header staging a grant the row never offered. settings has Get+Put only; + // OpenFGA's rum_analytics type has no GET or LIST (same shape G-12 pins). + expect(byResource.settings?.sort()).toEqual(['AllowAll', 'AllowGet', 'AllowPut']); + expect(byResource.rum_analytics?.sort()) + .toEqual(['AllowAll', 'AllowDelete', 'AllowPost', 'AllowPut']); + + // OpenFGA must accept every tuple the six ticks sent — the body alone proves nothing. + await expect + .poll(async () => (await getPerms(page, name)).length, { timeout: 20000 }) + .toBe(payload.add.length); + expect(tuples(await getPerms(page, name)), 'the stored set differs from the PUT body') + .toEqual(tuples(payload.add)); + // Polled, not read once: the badge repaints after the save settles, so a bare + // read here races it and fails on null rather than on a real disagreement. + await expect + .poll(async () => await permissionCount(), { timeout: 20000 }) + .toBe(payload.add.length); + + // A reload is the only thing that proves the editor can READ BACK what it wrote: + // the headers are derived from the grants, so a decode bug shows up here and + // nowhere in the payload assertions above. + await page.reload(); + await pm.rolesPage.waitForGrantsSettled(); + await pm.rolesPage.openAllModules(); + for (const action of ACTIONS) await hydratedHeader(action, true); + await expect + .poll(async () => await permissionCount(), { timeout: 20000 }) + .toBe(payload.add.length); + }); + + test('G-16 · unticking the All header strips the AllowAll tuples and leaves the rest', { + tag: ['@iam', '@iamRolesGrants', '@P1', '@all'] + }, async ({ page }) => { + const name = await openFresh(page, 'bulkundo'); + await pm.rolesPage.openAllModules(); + for (const action of ACTIONS) { + await pm.rolesPage.setCheckbox(pm.rolesPage.bulkCheckbox(action), true); + } + expect(await pm.rolesPage.saveAndCapture(), 'the setup save fired no PUT').toBeTruthy(); + const full = await getPerms(page, name); + + await page.reload(); + await pm.rolesPage.waitForGrantsSettled(); + await pm.rolesPage.openAllModules(); + const allBox = await hydratedHeader('AllowAll', true); + await pm.rolesPage.setCheckbox(allBox, false); + + const payload = await pm.rolesPage.saveAndCapture(); + expect(payload, 'the untick fired no PUT').toBeTruthy(); + expect(payload.add).toEqual([]); + // Undo means undo exactly one column. Widening it to the narrower grants would + // silently revoke access the admin never touched. + const collateral = payload.remove.filter((p) => p.permission !== 'AllowAll'); + expect(collateral, `the All column took other grants with it: ${JSON.stringify(collateral.slice(0, 5))}`) + .toEqual([]); + + const left = await getPerms(page, name); + expect(left.length).toBe(full.length - payload.remove.length); + expect(left.some((p) => p.permission === 'AllowAll'), 'AllowAll survived the untick').toBe(false); + expect(left.length, 'the narrower grants went with it').toBeGreaterThan(0); + }); + + test('G-17 · a pane search narrows what one header tick reaches', { + tag: ['@iam', '@iamRolesGrants', '@P1', '@all'] + }, async ({ page }) => { + const name = await openFresh(page, 'bulkfilt'); + await pm.rolesPage.openAllModules(); + await pm.rolesPage.paneSearch.fill('alert'); + // The filter drives the header, so settle on the filtered rows before ticking. + await expect.poll(async () => (await pm.rolesPage.paneRowKeys()).length, { timeout: 15000 }) + .toBeGreaterThan(0); + const visible = await pm.rolesPage.paneRowKeys(); + expect(visible, 'the search matched every module, so it narrows nothing') + .not.toContain('stream'); + + await pm.rolesPage.setCheckbox(pm.rolesPage.bulkCheckbox('AllowGet'), true); + const payload = await pm.rolesPage.saveAndCapture(); + expect(payload, 'save fired no PUT').toBeTruthy(); + // Blast radius is the FILTER, not the page: every matched row, and nothing else. + expect(payload.add.map((p) => p.object).sort()) + .toEqual(visible.map((key) => obj(key)).sort()); + expect(payload.add.every((p) => p.permission === 'AllowGet')).toBe(true); + expect(tuples(await getPerms(page, name))).toEqual(tuples(payload.add)); + }); + + test('G-18 · reopening a fully granted role and saving sends nothing', { + tag: ['@iam', '@iamRolesGrants', '@P1', '@all'] + }, async ({ page }) => { + const name = await openFresh(page, 'bulkidem'); + await pm.rolesPage.openAllModules(); + for (const action of ACTIONS) { + await pm.rolesPage.setCheckbox(pm.rolesPage.bulkCheckbox(action), true); + } + expect(await pm.rolesPage.saveAndCapture(), 'the setup save fired no PUT').toBeTruthy(); + const held = (await getPerms(page, name)).length; + + await page.reload(); + await pm.rolesPage.waitForGrantsSettled(held); + await pm.rolesPage.openAllModules(); + for (const action of ACTIONS) await hydratedHeader(action, true); + + // G-12 pins the cost of getting this wrong: OpenFGA answers 500 on re-writing a + // tuple already held, so a header that re-stages what it just read breaks the + // next save rather than merely wasting a request. + const payload = await pm.rolesPage.saveAndCapture({ expectRequest: false }); + expect(payload, `a no-op save re-sent ${payload?.add?.length} grants`).toBeNull(); + expect((await getPerms(page, name)).length).toBe(held); + }); + // ---------------- negative / rejection ---------------- test('G-N1 · a blank role name is refused', {