import { expect, type Browser, type Page, request } from '@playwright/test'; import { test } from '../fixtures/ids'; import { CleanupRegistry } from '../fixtures/cleanup'; import { adminApi } from '../fixtures/api'; // Phase B6 — Instance Users (/admin/users). Covers the bootstrap // admin's view of the user directory: invite, edit, deactivate, // search, delete, plus the member-role redirect and adversarial // inputs to the invite form. const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080'; const cleanup = new CleanupRegistry(); test.afterEach(async () => { await cleanup.run(); }); async function createMember(username: string, password = 'e2e-member-pw'): Promise { const api = await adminApi(); try { const res = await api.post('/api/v1/admin/admins', { data: { username, password, instance_role: 'member' } }); expect(res.ok()).toBe(true); return ((await res.json()) as { id: string }).id; } finally { await api.dispose(); } } async function loginToken(username: string, password: string): Promise { const ctx = await request.newContext({ baseURL: API_BASE }); try { const res = await ctx.post('/api/v1/admin/auth/login', { data: { username, password }, headers: { 'content-type': 'application/json' } }); expect(res.ok()).toBe(true); return ((await res.json()) as { token: string }).token; } finally { await ctx.dispose(); } } async function pageWithToken(browser: Browser, token: string): Promise { const ctx = await browser.newContext({ storageState: undefined }); const page = await ctx.newPage(); await page.goto('/admin/login'); await page.evaluate( ([key, value]) => { localStorage.setItem(key, value); }, ['picloud.admin.token', token] ); return page; } test.describe('B6 instance users', () => { test('invite happy path: form → reveal modal → user in list', async ({ page, uniqueUsername }) => { const username = uniqueUsername('inv'); await page.goto('/admin/users'); await page.getByRole('button', { name: '+ Invite user' }).click(); const modal = page.locator('form.modal'); await modal.getByLabel('Username').fill(username); await modal.getByRole('radio', { name: /^Member/ }).check(); await modal.getByRole('button', { name: /^Create user$/ }).click(); // Reveal modal shows the one-time password. const reveal = page.locator('.reveal-modal'); await expect(reveal).toBeVisible(); await expect(reveal).toContainText(/User created — /); await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled(); await reveal.getByRole('checkbox', { name: /shared this/i }).check(); await reveal.getByRole('button', { name: /^Done$/ }).click(); // Now in the table. await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toBeVisible(); // API cleanup — we don't have the user id from the UI alone. const api = await adminApi(); try { const list = await api.get('/api/v1/admin/admins'); const all = (await list.json()) as Array<{ id: string; username: string }>; const u = all.find((x) => x.username === username); if (u) cleanup.adminUser(u.id); } finally { await api.dispose(); } }); test('username live validation: bad chars → submit disabled', async ({ page }) => { await page.goto('/admin/users'); await page.getByRole('button', { name: '+ Invite user' }).click(); const modal = page.locator('form.modal'); await modal.getByLabel('Username').fill('UPPER_CASE_invalid'); await expect(modal.locator('small.invalid')).toContainText(/allowed pattern/i); await modal.getByRole('radio', { name: /^Member/ }).check(); await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled(); }); test('search filters the table by username', async ({ page, uniqueUsername }) => { const target = uniqueUsername('hit'); const decoy = uniqueUsername('miss'); const ids = await Promise.all([createMember(target), createMember(decoy)]); ids.forEach((id) => cleanup.adminUser(id)); await page.goto('/admin/users'); await page.getByPlaceholder(/Search by username/).fill(target); await expect(page.locator('.row', { hasText: target })).toBeVisible(); await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0); }); test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({ page, uniqueUsername }) => { const username = uniqueUsername('toggle'); const userId = await createMember(username); cleanup.adminUser(userId); await page.goto('/admin/users'); await page.getByPlaceholder(/Search by username/).fill(username); const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username }); await expect(row).toBeVisible(); // Deactivate opens the confirm modal. await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click(); await page.getByRole('menuitem', { name: /^Deactivate$/ }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await expect(dialog).toContainText(username); // Cancel leaves the user active. await dialog.getByRole('button', { name: /^Cancel$/ }).click(); await expect(dialog).toHaveCount(0); await expect(row).not.toContainText(/inactive/i); // Open again and confirm — user becomes inactive. await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click(); await page.getByRole('menuitem', { name: /^Deactivate$/ }).click(); await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click(); await expect(row).toContainText(/inactive/i); // Reactivate is still one-click (non-destructive — no modal). await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click(); await page.getByRole('menuitem', { name: /^Reactivate$/ }).click(); await expect(row).not.toContainText(/inactive/i); }); test('delete: wrong phrase keeps disabled, right phrase removes the user', async ({ page, uniqueUsername }) => { const username = uniqueUsername('del'); const userId = await createMember(username); cleanup.adminUser(userId); await page.goto('/admin/users'); await page.getByPlaceholder(/Search by username/).fill(username); const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username }); await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click(); await page.getByRole('menuitem', { name: /^Delete$/ }).click(); const dialog = page.getByRole('dialog'); const confirm = dialog.getByRole('button', { name: /^Delete user$/ }); await expect(confirm).toBeDisabled(); await dialog.getByRole('textbox').fill('not-the-username'); await expect(confirm).toBeDisabled(); await dialog.getByRole('textbox').fill(username); await expect(confirm).toBeEnabled(); await confirm.click(); await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toHaveCount(0); }); test('member-role user visiting /admin/users is bounced to profile with denied banner', async ({ browser, uniqueUsername }) => { const username = uniqueUsername('memvw'); const password = 'e2e-member-pw'; const userId = await createMember(username, password); cleanup.adminUser(userId); const token = await loginToken(username, password); const memberPage = await pageWithToken(browser, token); try { await memberPage.goto('/admin/users'); await expect(memberPage).toHaveURL(/\/admin\/profile\?denied=users$/); await expect(memberPage.getByText(/don.?t have access to the Users page/i)).toBeVisible(); } finally { await memberPage.context().close(); } }); }); test.describe('B6 instance users adversarial', () => { test('username too short: live invalid + submit disabled', async ({ page }) => { await page.goto('/admin/users'); await page.getByRole('button', { name: '+ Invite user' }).click(); const modal = page.locator('form.modal'); await modal.getByLabel('Username').fill('a'); // 1 char — minimum is 2 await expect(modal.locator('small.invalid')).toBeVisible(); await modal.getByRole('radio', { name: /^Member/ }).check(); await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled(); }); test('email with script tag fails validation, never executes', async ({ page }) => { page.on('dialog', async (d) => { await d.dismiss(); throw new Error(`Unexpected dialog: ${d.message()}`); }); await page.goto('/admin/users'); await page.getByRole('button', { name: '+ Invite user' }).click(); const modal = page.locator('form.modal'); await modal.getByLabel(/Email/).fill('@x'); await expect(modal.locator('small.invalid')).toContainText(/email/i); }); });