From 2d56e426993bdd819cc5a594011948bf8c3f16f3 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 28 May 2026 07:23:01 +0200 Subject: [PATCH] test(dashboard): add e2e app members spec (B5) Four tests covering the Members tab: invite + remove (action-menu + phrase modal), role change, the non-app-admin viewer who never sees the Members tab at all (cross-context via a second admin login), and an adversarial that the role dropdown only exposes the documented set of values. Co-Authored-By: Claude Opus 4.7 (1M context) --- dashboard/tests/e2e/members/members.spec.ts | 200 ++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 dashboard/tests/e2e/members/members.spec.ts diff --git a/dashboard/tests/e2e/members/members.spec.ts b/dashboard/tests/e2e/members/members.spec.ts new file mode 100644 index 0000000..5d244eb --- /dev/null +++ b/dashboard/tests/e2e/members/members.spec.ts @@ -0,0 +1,200 @@ +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'); + const appId = 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'); + const appId = 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, + page: _adminPage, + uniqueSlug, + uniqueUsername + }) => { + const slug = uniqueSlug('mem'); + const username = uniqueUsername('viewer'); + const password = 'e2e-member-pw'; + const appId = 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'); + const appId = 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']); + }); +});