test(iam): cover the All Modules column select-all, and unblock two tests it needs (#15240)

This commit is contained in:
Neha P
2026-10-09 12:11:31 +00:00
committed by GitHub
parent db9d308d44
commit 72ec9ea64f
4 changed files with 304 additions and 11 deletions
@@ -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}"]`);
}
@@ -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/<org><path>` rather than `/api/<org><path>`.
*
* 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,
@@ -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));
});
});
@@ -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', {