diff --git a/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs b/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs index 34f9be53..8cff146c 100644 --- a/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-row-quick-actions.spec.mjs @@ -15,6 +15,32 @@ if (!DATABASE_URL) { } const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdMeetingIds = []; +const createdUserIds = []; + +// bcrypt hash for "TestPass123!" — same fixture used by other e2e specs +// (see notes-thread-dialog.spec.mjs) so we don't pull bcrypt into tests. +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +async function createUserWithRole(prefix, roleName) { + const username = `${prefix}_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; + const { rows } = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], + ); + const id = rows[0].id; + createdUserIds.push(id); + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = $2`, + [id, roleName], + ); + return { id, username }; +} async function loginViaUi(page, username, password) { await page.goto("/login"); @@ -105,6 +131,14 @@ test.afterAll(async () => { [createdMeetingIds], ); } + if (createdUserIds.length > 0) { + await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ + createdUserIds, + ]); + await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ + createdUserIds, + ]); + } await pool.end(); }); @@ -320,3 +354,32 @@ test("clicking row affordances (grip, time cell, row-actions) does NOT open the await page.locator(`[data-testid="em-row-${id}"]`).click({ position: { x: 5, y: 5 } }); await expect(popover).toBeVisible({ timeout: 5_000 }); }); + +test("non-mutator (executive_viewer) row click does NOT open quick-actions popover", async ({ + page, +}) => { + const id = await insertMeeting({ + date: pickFutureDate(34), + titleEn: "QA Viewer Click", + startTime: "15:00:00", + endTime: "15:30:00", + }); + // executive_viewer passes requireExecutiveAccess (so the page renders) + // but is NOT in MUTATE_ROLES — canMutate=false on the client, so the + // row's quickActionsCanMutate gate must short-circuit the click and + // refuse to mount the popover. + const viewer = await createUserWithRole("qa_viewer", "executive_viewer"); + await loginViaUi(page, viewer.username, TEST_PASSWORD); + await gotoTestDate(page, pickFutureDate(34)); + await expect(page.locator(`[data-testid="em-row-${id}"]`)).toBeVisible({ + timeout: 10_000, + }); + + await page.locator(`[data-testid="em-row-${id}"]`).click(); + // 750ms is more than enough for Radix Popover to mount; if the gate + // works, the popover element never appears in the DOM. + await page.waitForTimeout(750); + await expect( + page.locator(`[data-testid="em-row-quick-${id}"]`), + ).toHaveCount(0); +});