- 21-role-based-ui: tighten the Settings link locator. The previous
`getByRole('link', { name: /settings/i }).first().or(getByText(/.../) .first())`
chain hit a strict-mode violation once the sidebar Admin section became
default-expanded — both the section header text node and the Settings
link matched. Match the link directly with exact: true.
- 26-residential: extend smoke with two API-driven specs covering the
residential interest pipeline — create+list and detail-page render —
using preferences-string stamp + heading match for assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
182 lines
6.8 KiB
TypeScript
182 lines
6.8 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
import { login, navigateTo } from './helpers';
|
||
|
||
test.describe('Role-Based UI', () => {
|
||
test('super_admin sees admin nav items', async ({ page }) => {
|
||
await login(page, 'super_admin');
|
||
|
||
// Give React Query time to resolve permissions
|
||
await page.waitForTimeout(3_000);
|
||
|
||
// Sidebar exposes a Settings link inside the Admin section (visible by
|
||
// default for super_admin). Match the link directly — earlier OR-fallbacks
|
||
// ambiguously matched the section header label too.
|
||
const settingsLink = page.getByRole('link', { name: 'Settings', exact: true }).first();
|
||
await expect(settingsLink).toBeVisible({ timeout: 10_000 });
|
||
|
||
// "+ New" button (or equivalent CTA) should be visible
|
||
const newButton = page
|
||
.getByRole('button', { name: /new/i })
|
||
.first()
|
||
.or(page.locator('[data-testid*="new-btn"]').first());
|
||
|
||
const newButtonVisible = await newButton.isVisible({ timeout: 5_000 }).catch(() => false);
|
||
if (!newButtonVisible) {
|
||
// Navigate to a page that definitely shows the new button
|
||
await navigateTo(page, '/clients');
|
||
await page.waitForTimeout(2_000);
|
||
const clientsNewBtn = page.getByRole('button', { name: /new client/i }).first();
|
||
await expect(clientsNewBtn).toBeVisible({ timeout: 10_000 });
|
||
}
|
||
|
||
// Admin monitoring page should load without being blocked
|
||
await navigateTo(page, '/admin/monitoring');
|
||
await page.waitForTimeout(3_000);
|
||
|
||
const monitoringUrl = page.url();
|
||
// Should still be on the monitoring page (not redirected away)
|
||
const isOnMonitoring = monitoringUrl.includes('/admin/monitoring');
|
||
const hasMonitoringContent = await page
|
||
.getByText(/monitor|health|queue|postgres/i)
|
||
.isVisible({ timeout: 5_000 })
|
||
.catch(() => false);
|
||
const wasBlocked = await page
|
||
.getByText(/permission|forbidden|access denied|not authorized/i)
|
||
.isVisible({ timeout: 2_000 })
|
||
.catch(() => false);
|
||
|
||
expect((isOnMonitoring && !wasBlocked) || hasMonitoringContent).toBeTruthy();
|
||
});
|
||
|
||
test('sales_agent sees limited nav', async ({ page }) => {
|
||
await login(page, 'sales_agent');
|
||
|
||
// Give React Query time to resolve permissions
|
||
await page.waitForTimeout(3_000);
|
||
|
||
// Main navigation items should be visible for sales_agent
|
||
const mainNavItems = [
|
||
page.getByRole('link', { name: /dashboard/i }).first(),
|
||
page.getByRole('link', { name: /clients/i }).first(),
|
||
page.getByRole('link', { name: /interests/i }).first(),
|
||
];
|
||
|
||
// At least 2 of the core nav items should be visible
|
||
let foundCount = 0;
|
||
for (const navItem of mainNavItems) {
|
||
const visible = await navItem.isVisible({ timeout: 3_000 }).catch(() => false);
|
||
if (visible) foundCount++;
|
||
}
|
||
|
||
// A sales agent should see at least the dashboard or clients
|
||
expect(foundCount).toBeGreaterThanOrEqual(1);
|
||
|
||
// Admin section should either be hidden or inaccessible
|
||
await navigateTo(page, '/admin/monitoring');
|
||
await page.waitForTimeout(3_000);
|
||
|
||
const monitoringUrl = page.url();
|
||
const isStillOnMonitoring = monitoringUrl.includes('/admin/monitoring');
|
||
const hasPermError = await page
|
||
.getByText(/permission|forbidden|access denied|not authorized|unauthorized/i)
|
||
.first()
|
||
.isVisible({ timeout: 3_000 })
|
||
.catch(() => false);
|
||
const wasRedirected = !isStillOnMonitoring;
|
||
// With APIs returning 403 for non-admins, queue cards don't render — no data leak.
|
||
// Scope to <main> because sidebar has "Email"/"Documents" nav links.
|
||
const queueCardCount = await page
|
||
.locator('main')
|
||
.getByText(/^(webhooks|notifications|reports|maintenance|ai|bulk)$/i)
|
||
.count();
|
||
const dataLeaked = queueCardCount > 0;
|
||
|
||
// Accept: redirect, permission error, or simply no data leak (API-enforced)
|
||
expect(hasPermError || wasRedirected || !dataLeaked).toBeTruthy();
|
||
});
|
||
|
||
test('viewer has read-only access on clients list', async ({ page }) => {
|
||
await login(page, 'viewer');
|
||
|
||
await navigateTo(page, '/clients');
|
||
await page.waitForLoadState('networkidle');
|
||
|
||
// Allow time for permission-gated UI to resolve
|
||
await page.waitForTimeout(3_000);
|
||
|
||
// The clients list itself should load (viewer can read)
|
||
const pageContent = page.locator('main').first();
|
||
await expect(pageContent).toBeVisible({ timeout: 10_000 });
|
||
|
||
// "New Client" button should be hidden or disabled for viewer
|
||
const newClientBtn = page.getByRole('button', { name: /new client/i });
|
||
const isBtnVisible = await newClientBtn.isVisible({ timeout: 3_000 }).catch(() => false);
|
||
|
||
if (isBtnVisible) {
|
||
// If visible (PermissionGate not enforcing client-side), it should at
|
||
// minimum be disabled, or the server rejects the action
|
||
const isDisabled = await newClientBtn.isDisabled().catch(() => false);
|
||
if (!isDisabled) {
|
||
console.warn(
|
||
' ⚠️ APP BUG: New Client button not gated for viewer — server-side must enforce',
|
||
);
|
||
}
|
||
}
|
||
// Test passes regardless — this validates the UI state (server is authoritative)
|
||
expect(true).toBeTruthy();
|
||
});
|
||
|
||
test('viewer cannot edit client records', async ({ page }) => {
|
||
await login(page, 'viewer');
|
||
|
||
await navigateTo(page, '/clients');
|
||
await page.waitForLoadState('networkidle');
|
||
await page.waitForTimeout(3_000);
|
||
|
||
// If any client rows exist, click into one
|
||
const firstRow = page.locator('table tbody tr').first();
|
||
const rowVisible = await firstRow.isVisible({ timeout: 5_000 }).catch(() => false);
|
||
|
||
if (!rowVisible) {
|
||
console.log(' ℹ No client rows found — skipping edit button check');
|
||
return;
|
||
}
|
||
|
||
const link = firstRow.locator('a').first();
|
||
if (await link.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||
await link.click();
|
||
} else {
|
||
await firstRow.click();
|
||
}
|
||
|
||
await page.waitForTimeout(3_000);
|
||
|
||
const url = page.url();
|
||
if (!url.includes('/clients/')) {
|
||
console.log(' ℹ Could not navigate to client detail — skipping');
|
||
return;
|
||
}
|
||
|
||
// Edit buttons should be hidden or disabled for viewer
|
||
const editButtons = page.getByRole('button', { name: /edit|save changes|update/i });
|
||
const editCount = await editButtons.count();
|
||
|
||
if (editCount > 0) {
|
||
const firstEditBtn = editButtons.first();
|
||
const isVisible = await firstEditBtn.isVisible({ timeout: 2_000 }).catch(() => false);
|
||
const isDisabled = await firstEditBtn.isDisabled().catch(() => false);
|
||
|
||
if (isVisible && !isDisabled) {
|
||
console.warn(' ⚠️ Edit button visible/enabled for viewer — PermissionGate should hide it');
|
||
}
|
||
}
|
||
|
||
// Page should load without crashing
|
||
const body = await page
|
||
.locator('body')
|
||
.textContent()
|
||
.catch(() => '');
|
||
expect(body && body.length > 10).toBeTruthy();
|
||
});
|
||
});
|