This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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 ([email protected]) 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: '[email protected]', password: 'AdminPass123!' };
|
||||
// Seeded personas (own *@adminmgmt.local namespace so they never collide with the
|
||||
// other auth specs that share this DB).
|
||||
const MEMBER = { email: '[email protected]', password: 'MemberPass123!', name: 'AdminMgmtMember' };
|
||||
const PENDING = { email: '[email protected]', 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: '[email protected]', 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([]);
|
||||
});
|
||||
Reference in New Issue
Block a user