import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; /** * M3 — RBAC, group management, user assignment. * * Flow: * 1. Reset + bootstrap a fresh admin. * 2. Admin visits /admin/groups and creates a custom group `pentest-red` with * only `mission.read` + `mission.write_red_fields`. * 3. Admin issues an invitation pre-assigned to that custom group. * 4. Invitee accepts, logs in, hits the API: mission.read OK, but admin-only * group.create returns 403 — proving the union-of-perms decorator works. * 5. Admin attempts to demote himself → server returns 409 last_admin_protected. */ const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@metamorph.local`; const ADMIN_PASSWORD = 'AdminPass1234!'; const BOB_EMAIL = `bob-${Math.floor(Math.random() * 1e6)}@metamorph.local`; const BOB_PASSWORD = 'BobPass1234!'; const GROUP_NAME = `pentest-red-${Math.floor(Math.random() * 1e6)}`; interface ResetPayload { install_token: string; } async function resetAndMintToken(request: APIRequestContext): Promise { const r = await request.post('/api/v1/diag/reset'); expect(r.status()).toBe(200); const body = (await r.json()) as ResetPayload; return body.install_token; } async function loginAndGetAccess( request: APIRequestContext, email: string, password: string, ): Promise { const r = await request.post('/api/v1/auth/login', { data: { email, password }, }); expect(r.status()).toBe(200); return (await r.json()).access_token as string; } /** Authenticate the page session via the SPA login form. */ async function loginViaSpa(page: Page, email: string, password: string) { await page.goto('/login'); await page.getByLabel(/email/i).fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(email); } test.describe.configure({ mode: 'serial' }); test.describe('M3 — RBAC management', () => { let installToken: string; let customGroupId: string; test.beforeAll(async ({ request }) => { installToken = await resetAndMintToken(request); // Bootstrap the first admin via API to keep the e2e focused on RBAC. const setup = await request.post('/api/v1/setup', { data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD, display_name: 'Admin', }, }); expect(setup.status()).toBe(201); }); test('admin sees Admin nav links after login', async ({ page }) => { await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); // Nav now shows the admin links. await expect(page.getByRole('link', { name: /^users$/i })).toBeVisible(); await expect(page.getByRole('link', { name: /^groups$/i })).toBeVisible(); await expect(page.getByRole('link', { name: /^invitations$/i })).toBeVisible(); }); test('catalogue page lists the seeded permissions', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const r = await request.get('/api/v1/permissions', { headers: { Authorization: `Bearer ${access}` }, }); expect(r.status()).toBe(200); const body = (await r.json()) as { items: Array<{ code: string }> }; const codes = body.items.map((p) => p.code); // Smoke-check several families. expect(codes).toEqual(expect.arrayContaining([ 'user.read', 'group.create', 'invitation.create', 'mission.write_red_fields', 'mission.write_blue_fields', 'mitre.sync', ])); }); test('admin creates a custom group with only red-write perms via the SPA', async ({ page }) => { await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto('/admin/groups'); await page.getByTestId('create-group').click(); const modal = page.getByTestId('group-create-modal'); await expect(modal).toBeVisible(); await modal.getByLabel(/^name$/i).fill(GROUP_NAME); await modal.getByTestId('perm-mission.read').check(); await modal.getByTestId('perm-mission.write_red_fields').check(); await modal.getByTestId('group-create-save').click(); // The new card is visible in the listing. await expect(modal).not.toBeVisible(); await expect(page.getByText(GROUP_NAME)).toBeVisible(); }); test('admin invites Bob pre-assigned to the custom group', async ({ page, request }) => { // Fetch the group id (needed for the invitation API). const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const groups = await request.get('/api/v1/groups', { headers: { Authorization: `Bearer ${access}` }, }); const items = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items; customGroupId = items.find((g) => g.name === GROUP_NAME)!.id; expect(customGroupId).toBeTruthy(); // Issue invitation via API (creating an invitation through the UI is covered in M2). const created = await request.post('/api/v1/invitations', { headers: { Authorization: `Bearer ${access}` }, data: { email_hint: BOB_EMAIL, group_ids: [customGroupId] }, }); expect(created.status()).toBe(201); const token = (await created.json()).token as string; // Bob completes registration. await page.goto(`/register?token=${encodeURIComponent(token)}`); await page.getByLabel(/email/i).fill(BOB_EMAIL); await page.getByLabel('Password', { exact: true }).fill(BOB_PASSWORD); await page.getByLabel(/confirm password/i).fill(BOB_PASSWORD); await page.getByRole('button', { name: /create account/i }).click(); await expect(page).toHaveURL(/\/login$/, { timeout: 5000 }); }); test('Bob can read missions list but is forbidden from admin endpoints', async ({ request }) => { const access = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD); // Inspect /auth/me to confirm his perms. const me = await request.get('/api/v1/auth/me', { headers: { Authorization: `Bearer ${access}` }, }); const body = (await me.json()) as { is_admin: boolean; permissions: string[]; groups: string[]; }; expect(body.is_admin).toBe(false); expect(body.groups).toContain(GROUP_NAME); expect(body.permissions).toEqual( expect.arrayContaining(['mission.read', 'mission.write_red_fields']), ); expect(body.permissions).not.toContain('mission.write_blue_fields'); // Bob does NOT have user.read → /users returns 403. const usersList = await request.get('/api/v1/users', { headers: { Authorization: `Bearer ${access}` }, }); expect(usersList.status()).toBe(403); // Bob does NOT have group.create → POST /groups returns 403. const groupCreate = await request.post('/api/v1/groups', { headers: { Authorization: `Bearer ${access}` }, data: { name: 'wont-happen', description: null }, }); expect(groupCreate.status()).toBe(403); }); test('non-admin SPA visitor cannot reach /admin/* routes', async ({ page }) => { await loginViaSpa(page, BOB_EMAIL, BOB_PASSWORD); // Direct nav to /admin/users — RequireAdmin redirects to /. await page.goto('/admin/users'); await expect(page).toHaveURL(/\/$/); // The nav also hides the admin links. await expect(page.getByRole('link', { name: /^users$/i })).toHaveCount(0); }); test('last-admin protection prevents the bootstrap admin from being deleted', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const me = await request.get('/api/v1/auth/me', { headers: { Authorization: `Bearer ${access}` }, }); const adminId = (await me.json()).id as string; const del = await request.delete(`/api/v1/users/${adminId}`, { headers: { Authorization: `Bearer ${access}` }, }); expect(del.status()).toBe(409); expect((await del.json()).error).toBe('last_admin_protected'); }); test('admin promotes Bob and the new perms take effect', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); // Find Bob. const list = await request.get(`/api/v1/users?q=${encodeURIComponent(BOB_EMAIL)}`, { headers: { Authorization: `Bearer ${access}` }, }); const bob = ((await list.json()) as { items: Array<{ id: string; email: string }> }).items.find( (u) => u.email === BOB_EMAIL, )!; // Find admin group id. const groups = await request.get('/api/v1/groups', { headers: { Authorization: `Bearer ${access}` }, }); const adminGroup = ((await groups.json()) as { items: Array<{ id: string; name: string }> }).items.find( (g) => g.name === 'admin', )!; const r = await request.put(`/api/v1/users/${bob.id}/groups`, { headers: { Authorization: `Bearer ${access}` }, data: { group_ids: [customGroupId, adminGroup.id] }, }); expect(r.status()).toBe(200); // Bob now has admin rights via group membership. const bobAccess = await loginAndGetAccess(request, BOB_EMAIL, BOB_PASSWORD); const groupsAsBob = await request.get('/api/v1/groups', { headers: { Authorization: `Bearer ${bobAccess}` }, }); expect(groupsAsBob.status()).toBe(200); }); });