Files
pn-new-crm/tests/e2e/matrix/role-access.spec.ts
Matt 459c68a2c3
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m0s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
feat(rbac): residential-partner route lockdown + role-aware mobile nav
UAT (residential partners must have zero access to anything non-residential;
no marina dashboard). Server-side their permission map already 403s every
marina domain — this locks the client surface to match:

- AppShell: a residential-only user (residential_clients.view && !clients.view,
  non-super-admin) is redirected off ANY non-residential route to
  /residential/clients. Blocks the marina dashboard + every marina page in one
  place; personal surfaces (settings, inbox) stay reachable. (Fixes F4 — they
  no longer land on a marina dashboard of 403-ing empty widgets.)
- Mobile bottom tabs were hardcoded Dashboard/Clients/Berths regardless of role;
  now role-aware — residential-only users get Residential Clients/Interests
  instead of marina tabs they 403 on. (Fixes F5.)
- e2e: stale `#email` login selector → `#identifier` (smoke helper) — a real
  reason the smoke auth specs fail independent of the dev-server OOM.
- New crash-safe `matrix` Playwright project (role×viewport access matrix +
  responsive overflow sweep) — lean alternative to the full suite which
  OOM-crashes next dev locally.

Verified: matrix run shows residential_partner redirected to residential +
residential-scoped mobile tabs; 403s unchanged; tsc + eslint + 42 permission
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:22 +02:00

139 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, number | string> = { _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();
});
}
});