import { test, expect, type Page } from '@playwright/test'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { dirname, resolve, join } from 'node:path'; import { tmpdir } from 'node:os'; // ── Local-auth ADMIN USER MANAGEMENT E2E ────────────────────────────────────── // // REQUIRES `npm run test:e2e:auth` + a running server (ui/playwright.auth.config.ts // boots the real orchestrator with LOCAL AUTH on via ui/e2e-auth/config.e2e-auth.yaml). // Do NOT expect this to run in the sandbox — there is no live server here. // // Admin user management is admin-only AND auth-only: the Users tab (page=users) // renders only when `isAdmin && authEnabled` (App.tsx), and /api/admin/users is // guarded by requireAdmin. So this can only be exercised under the local-auth // harness, logged in as an admin. The bootstrap admin (admin@test.local) is // seeded by the server at startup; a regular member + a pending user are seeded // in beforeAll into the SAME deterministic temp DB the webServer opens (the exact // pattern used by sharing-scope.auth.spec.ts). // // Happy path: admin opens Users, the seeded users are listed, admin promotes the // regular user to admin and approves the pending user — verified via the // /api/admin/users API in the admin's authed browser context (the same dual // UI+API proof sharing-scope uses). Negative/visibility: a regular member who // logs in gets NO Users tab and is denied /api/admin/users (401/403). const __dirname = dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); const repoRoot = resolve(__dirname, '..', '..'); // Same deterministic DB the webServer opens (see playwright.auth.config.ts). const e2eTmp = join(tmpdir(), 'maestro-e2e-auth'); const DB_PATH = join(e2eTmp, 'e2e-auth.db'); // The bootstrap admin from config.e2e-auth.yaml. const ADMIN = { email: 'admin@test.local', password: 'AdminPass123!' }; // Seeded personas (own *@adminmgmt.local namespace so they never collide with the // other auth specs that share this DB). const MEMBER = { email: 'member@adminmgmt.local', password: 'MemberPass123!', name: 'AdminMgmtMember' }; const PENDING = { email: 'pending@adminmgmt.local', password: 'PendingPass123!', name: 'AdminMgmtPending' }; // A regular user that is NEVER promoted, reserved for the denial test (the // promotion test mutates MEMBER → admin in the shared DB, so MEMBER cannot prove // the non-admin denial afterwards). const OUTSIDER = { email: 'outsider@adminmgmt.local', password: 'OutsiderPass123!', name: 'AdminMgmtOutsider' }; let memberId = ''; let pendingId = ''; let outsiderId = ''; const BENIGN_PATHS = new Set([ '/api/users/me/orgs', '/api/mcp/connections', '/api/mcp/servers', '/api/mcp/user-servers', '/api/ssh/connections', ]); const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i; function trackFatalErrors(page: Page): string[] { const fatalErrors: string[] = []; page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`)); page.on('response', (res) => { if (res.status() >= 400) { const { pathname } = new URL(res.url()); if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) { fatalErrors.push(`HTTP ${res.status()} ${res.url()}`); } } }); return fatalErrors; } async function login(page: Page, email: string, password: string) { await page.goto('/auth/login'); await page.fill('input[name="email"]', email); await page.fill('input[name="password"]', password); await Promise.all([ page.waitForURL(/\/ui(\/|$)/, { timeout: 15_000 }), page.click('button[type="submit"]'), ]); expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login'); } test.beforeAll(async () => { // webServer is already up (schema + migrations + bootstrap admin exist). Seed a // regular active member and a PENDING user into the SAME deterministic DB. const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as { Repository: new (dbPath: string) => { getUserByEmail: (email: string) => { id: string } | null; createLocalUser: (p: { email: string; password: string; name?: string; role: string; status: string; }) => { id: string }; close?: () => void; }; }; const repo = new Repository(DB_PATH); try { const ensure = (u: typeof MEMBER, role: string, status: string) => { const existing = repo.getUserByEmail(u.email); if (existing) return existing.id; return repo.createLocalUser({ email: u.email, password: u.password, name: u.name, role, status, }).id; }; memberId = ensure(MEMBER, 'user', 'active'); pendingId = ensure(PENDING, 'user', 'pending'); outsiderId = ensure(OUTSIDER, 'user', 'active'); } finally { repo.close?.(); } }); test('seed sanity: the personas exist with the expected roles/status', async ({ page }) => { expect(memberId, 'member seeded').toBeTruthy(); expect(pendingId, 'pending user seeded').toBeTruthy(); await login(page, ADMIN.email, ADMIN.password); const res = await page.request.get('/api/admin/users'); expect(res.status(), 'admin lists users').toBe(200); const users = (await res.json()) as Array<{ id: string; role: string; status: string }>; const member = users.find((u) => u.id === memberId); const pending = users.find((u) => u.id === pendingId); expect(member?.role).toBe('user'); expect(pending?.status).toBe('pending'); }); // Happy path: admin opens the Users page, the seeded users render in the list, // and admin role-promotes the member + approves the pending user. The mutations // fire PATCH /api/admin/users/:id; we assert the resulting state via the API in // the same authed context (UI list rendering + API state = the dual proof used // across the auth suite). test('admin can list users, promote a member to admin, and approve a pending user', async ({ page }) => { const fatalErrors = trackFatalErrors(page); await login(page, ADMIN.email, ADMIN.password); await page.goto('/ui?page=users'); // The Users tab is admin+auth gated; with an admin logged in it renders. await expect(page.getByTestId('nav-users')).toBeVisible(); await expect(page).toHaveURL(/[?&]page=users(&|$)/); // The seeded users appear in the list (the list renders by email/name text). await expect(page.getByText(MEMBER.email, { exact: false })).toBeVisible({ timeout: 15_000 }); await expect(page.getByText(PENDING.email, { exact: false })).toBeVisible(); // Drive the mutations through the API in the admin's authed browser context. // (The Users page controls are i18n-text driven without stable test-ids; the // page.request path exercises the SAME endpoints the buttons call — PATCH // /api/admin/users/:id — without coupling to translation strings.) const promote = await page.request.patch(`/api/admin/users/${memberId}`, { data: { role: 'admin' }, }); expect(promote.ok(), 'promote member to admin').toBeTruthy(); const approve = await page.request.patch(`/api/admin/users/${pendingId}`, { data: { status: 'active' }, }); expect(approve.ok(), 'approve pending user').toBeTruthy(); // Verify the resulting state via the list endpoint. const after = await page.request.get('/api/admin/users'); expect(after.status()).toBe(200); const users = (await after.json()) as Array<{ id: string; role: string; status: string }>; expect(users.find((u) => u.id === memberId)?.role).toBe('admin'); expect(users.find((u) => u.id === pendingId)?.status).toBe('active'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); // Negative/visibility: a regular member gets NO Users tab and is denied the // admin API. The 403/401 is fetched via page.request (NOT navigation), so the // navigation-scoped fatal tracker never observes the expected denial. test('a non-admin member sees no Users tab and is denied /api/admin/users', async ({ page }) => { const fatalErrors = trackFatalErrors(page); // OUTSIDER is a plain 'user' that no test promotes, so the non-admin denial // holds regardless of test order in the shared DB. await login(page, OUTSIDER.email, OUTSIDER.password); await page.goto('/ui'); // The Users tab is admin+auth gated; a regular user never sees it. await expect(page.getByTestId('nav-users')).toHaveCount(0); // Admin-only API is denied for a regular user (401 or 403 depending on guard). const denied = await page.request.get('/api/admin/users'); expect([401, 403]).toContain(denied.status()); expect(fatalErrors, `fatal errors (DOM navigation only):\n${fatalErrors.join('\n')}`).toEqual([]); });