diff --git a/dashboard/tests/e2e/users/users.spec.ts b/dashboard/tests/e2e/users/users.spec.ts new file mode 100644 index 0000000..29bfdbf --- /dev/null +++ b/dashboard/tests/e2e/users/users.spec.ts @@ -0,0 +1,209 @@ +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 then reactivate toggles the inactive indicator', 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(); + + await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click(); + await page.getByRole('menuitem', { name: /^Deactivate$/ }).click(); + await expect(row).toContainText(/inactive/i); + + 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); + }); +});