/** * Role × viewport access matrix. * * A LEAN, crash-safe alternative to running the full 162-test smoke suite * (which OOM-crashes `next dev` locally). For each of the 5 core roles it: * - logs in (UI), * - probes a fixed set of API endpoints in the authenticated session and * records the HTTP status (the read/permission matrix), * - records which sidebar nav sections are visible, * - screenshots the dashboard at desktop / tablet / mobile viewports. * * Few route compilations per run, so the dev server stays up. Users are * pre-seeded (admin/director/sales/viewer/residential_partner); no global * setup dependency. */ import { test, expect } from '@playwright/test'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; const PORT = 'port-nimara'; const OUT = join(process.cwd(), '.audit', 'matrix'); const ROLES = [ { key: 'super_admin', email: 'admin@portnimara.test', pw: 'SuperAdmin12345!' }, { key: 'director', email: 'director@portnimara.test', pw: 'DirectorUser12345!' }, { key: 'sales', email: 'mpciaccio13@verizon.net', pw: 'SallySales12345!' }, { key: 'viewer', email: 'viewer@portnimara.test', pw: 'ViewerUser12345!' }, { key: 'residential_partner', email: 'respartner@portnimara.test', pw: 'ResPartner12345!' }, ] as const; const VIEWPORTS = [ { name: 'desktop', width: 1440, height: 900 }, { name: 'tablet', width: 820, height: 1180 }, { name: 'mobile', width: 390, height: 844 }, ] as const; // GET probes — expected status varies by role; we just record what we get. const PROBES: { label: string; path: string }[] = [ { label: 'clients.view', path: '/api/v1/clients?limit=1' }, { label: 'interests.view', path: '/api/v1/interests?limit=1' }, { label: 'yachts.view', path: '/api/v1/yachts?limit=1' }, { label: 'reports.financial', path: '/api/v1/reports/financial' }, { label: 'alerts(interests.view)', path: '/api/v1/alerts?status=open' }, { label: 'residential.clients', path: '/api/v1/residential/clients?limit=1' }, { label: 'admin.users', path: '/api/v1/admin/users' }, { label: 'admin.audit', path: '/api/v1/admin/audit?limit=1' }, { label: 'admin.onboarding', path: '/api/v1/admin/onboarding/status' }, ]; async function login(page: import('@playwright/test').Page, email: string, pw: string) { // Authenticate via the API (better-auth sign-in) rather than the UI: the // dev-mode login page hydrates slowly and a pre-hydration click submits the // form as a native GET. page.request shares the cookie jar with the page // context, so after this the page's navigations + fetches are authenticated. const res = await page.request.post('/api/auth/sign-in/email', { data: { email, password: pw }, headers: { 'content-type': 'application/json' }, }); if (!res.ok()) { throw new Error(`API login failed for ${email}: ${res.status()} ${await res.text()}`); } } test.describe('Role × viewport access matrix', () => { // Independent tests — a flake in one role must not skip the others. for (const role of ROLES) { test(`${role.key} — access matrix + nav + viewport renders`, async ({ page }) => { mkdirSync(OUT, { recursive: true }); test.setTimeout(120_000); await login(page, role.email, role.pw); // Land on the app (authenticated via the shared cookie) so in-page // fetch() has the right origin + the sidebar nav is present. await page.goto(`/${PORT}/dashboard`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1500); // 1. API access matrix (authenticated fetch in-page). Non-super-admins // need the X-Port-Id header (apiFetch adds it) or every route 400s on // "Port context required" — resolve it via /me/ports first. const matrix = await page.evaluate(async (probes) => { let portId = ''; try { const pr = await fetch('/api/v1/me/ports', { headers: { accept: 'application/json' } }); const pj = (await pr.json()) as { data?: { id: string; slug: string }[] }; portId = (pj.data ?? []).find((p) => p.slug === 'port-nimara')?.id ?? (pj.data ?? [])[0]?.id ?? ''; } catch { /* leave empty */ } const out: Record = { _port: portId ? 'ok' : 'MISSING' }; for (const p of probes) { try { const r = await fetch(p.path, { headers: { accept: 'application/json', 'X-Port-Id': portId }, }); out[p.label] = r.status; } catch { out[p.label] = -1; } } return out; }, PROBES); // 2. Visible nav sections const nav = await page.evaluate(() => [...document.querySelectorAll('nav a')].map((a) => a.getAttribute('href')).filter(Boolean), ); const hasAdminNav = nav.some((h) => h?.includes('/admin')); const hasResidentialNav = nav.some((h) => h?.includes('/residential')); console.log(`\n=== ROLE: ${role.key} ===`); console.log(' access:', JSON.stringify(matrix)); console.log( ` nav: adminSection=${hasAdminNav} residentialSection=${hasResidentialNav} count=${nav.length}`, ); // 3. Viewport renders — dashboard + clients at each size for (const vp of VIEWPORTS) { await page.setViewportSize({ width: vp.width, height: vp.height }); for (const path of [`/${PORT}/dashboard`, `/${PORT}/clients`]) { const slug = path.split('/').pop(); const resp = await page.goto(path, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(800); await page .screenshot({ path: join(OUT, `${role.key}-${vp.name}-${slug}.png`), fullPage: false }) .catch(() => {}); console.log(` render ${vp.name} ${slug}: http=${resp?.status()}`); } } // Sanity: every role can at least reach its landing without a hard error. expect(matrix['clients.view'] === 200 || matrix['residential.clients'] === 200).toBeTruthy(); }); } });