import { expect, type Browser, type Page } from '@playwright/test'; import { test } from '../fixtures/ids'; import { CleanupRegistry } from '../fixtures/cleanup'; import { adminApi } from '../fixtures/api'; // Phase B5 — App Members. Setup creates one or two extra admin // users via the API; tests drive the Members tab through the // dashboard like a real app admin would. 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 createApp(slug: string): Promise { const api = await adminApi(); try { const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } }); expect(res.ok()).toBe(true); return ((await res.json()) as { id: string }).id; } finally { await api.dispose(); } } async function createMemberUser(username: string): Promise { const api = await adminApi(); try { const res = await api.post('/api/v1/admin/admins', { data: { username, password: 'e2e-member-pw', instance_role: 'member' } }); expect(res.ok()).toBe(true); return ((await res.json()) as { id: string }).id; } finally { await api.dispose(); } } async function loginAsUserToken(username: string, password: string): Promise { const probe = await (await import('@playwright/test')).request.newContext({ baseURL: API_BASE }); try { const res = await probe.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 probe.dispose(); } } async function pageWithUserToken(browser: Browser, token: string): Promise { const ctx = await browser.newContext({ storageState: undefined }); const page = await ctx.newPage(); // Seed localStorage on the right origin, then navigate normally. await page.goto('/admin/login'); await page.evaluate( ([key, value]) => { localStorage.setItem(key, value); }, ['picloud.admin.token', token] ); return page; } test.describe('B5 app members', () => { test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => { const slug = uniqueSlug('mem'); const username = uniqueUsername('inv'); await createApp(slug); const userId = await createMemberUser(username); cleanup.app(slug); cleanup.adminUser(userId); await page.goto(`/admin/apps/${slug}`); await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click(); // Invite. Both selects sit in `form.create-form`; locate them // by position to avoid getByLabel ambiguity (the Svelte // markup nests both labels in a flex row, which makes their // accessible names overlap). const form = page.locator('form.create-form'); await form.locator('select').nth(0).selectOption({ label: username }); await form.locator('select').nth(1).selectOption('editor'); await page.getByRole('button', { name: /^Add member$/ }).click(); await expect(page.locator('.member-row')).toContainText(username); // Remove via action menu + confirm modal. await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click(); await page.getByRole('menuitem', { name: /^Remove from app$/ }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: /^Remove member$/ }).click(); await expect(page.locator('.member-row')).toHaveCount(0); }); test('role change via action menu updates the role chip', async ({ page, uniqueSlug, uniqueUsername }) => { const slug = uniqueSlug('mem'); const username = uniqueUsername('role'); await createApp(slug); const userId = await createMemberUser(username); cleanup.app(slug); cleanup.adminUser(userId); // Seed the membership via API to skip the invite UI. const api = await adminApi(); try { const res = await api.post(`/api/v1/admin/apps/${slug}/members`, { data: { user_id: userId, role: 'viewer' } }); expect(res.ok()).toBe(true); } finally { await api.dispose(); } await page.goto(`/admin/apps/${slug}`); await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click(); await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click(); await page.getByRole('menuitem', { name: /^Make editor$/ }).click(); const row = page.locator('.member-row', { hasText: username }); await expect(row).toContainText(/editor/i); }); test('non-app-admin viewers do not see the Members tab', async ({ browser, uniqueSlug, uniqueUsername }) => { const slug = uniqueSlug('mem'); const username = uniqueUsername('viewer'); const password = 'e2e-member-pw'; await createApp(slug); const userId = await createMemberUser(username); cleanup.app(slug); cleanup.adminUser(userId); // Grant viewer membership (not app_admin) so the user can see // the app at all. const api = await adminApi(); try { const res = await api.post(`/api/v1/admin/apps/${slug}/members`, { data: { user_id: userId, role: 'viewer' } }); expect(res.ok()).toBe(true); } finally { await api.dispose(); } const token = await loginAsUserToken(username, password); const viewerPage = await pageWithUserToken(browser, token); try { await viewerPage.goto(`/admin/apps/${slug}`); // Scripts tab loads — that's what a viewer sees. await expect( viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ }) ).toBeVisible(); // Members tab button is absent for non-app-admins. await expect( viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ }) ).toHaveCount(0); } finally { await viewerPage.context().close(); } }); }); test.describe('B5 app members adversarial', () => { test('role dropdown exposes only the documented values', async ({ page, uniqueSlug, uniqueUsername }) => { const slug = uniqueSlug('mem'); const username = uniqueUsername('rolelist'); await createApp(slug); const userId = await createMemberUser(username); cleanup.app(slug); cleanup.adminUser(userId); await page.goto(`/admin/apps/${slug}`); await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click(); const form = page.locator('form.create-form'); const roleSelect = form.locator('select').nth(1); const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) => Array.from(el.options).map((o) => o.value) ); expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']); }); });