diff --git a/dashboard/tests/e2e/apps/apps.spec.ts b/dashboard/tests/e2e/apps/apps.spec.ts index d58554a..5bd7df7 100644 --- a/dashboard/tests/e2e/apps/apps.spec.ts +++ b/dashboard/tests/e2e/apps/apps.spec.ts @@ -2,6 +2,36 @@ import { expect, type Page } from '@playwright/test'; import { test } from '../fixtures/ids'; import { CleanupRegistry } from '../fixtures/cleanup'; import { adminApi } from '../fixtures/api'; +import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page'; + +const MEMBER_PW = 'e2e-member-pw'; + +async function seedAppAndMember(opts: { + slug: string; + username: string; + role: 'viewer' | 'editor' | 'app_admin'; +}): Promise<{ appId: string; userId: string }> { + const api = await adminApi(); + try { + const appRes = await api.post('/api/v1/admin/apps', { + data: { slug: opts.slug, name: opts.slug } + }); + expect(appRes.ok()).toBe(true); + const appId = ((await appRes.json()) as { id: string }).id; + const userRes = await api.post('/api/v1/admin/admins', { + data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' } + }); + expect(userRes.ok()).toBe(true); + const userId = ((await userRes.json()) as { id: string }).id; + const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, { + data: { user_id: userId, role: opts.role } + }); + expect(memberRes.ok()).toBe(true); + return { appId, userId }; + } finally { + await api.dispose(); + } +} // Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the // historical-slug takeover flow and adversarial inputs. @@ -224,3 +254,82 @@ test.describe('B2 apps adversarial', () => { await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible(); }); }); + +test.describe('B2 apps role shadowing', () => { + test('viewer member sees no "New app" on the apps list', async ({ + browser, + uniqueSlug, + uniqueUsername + }) => { + const slug = uniqueSlug('vlist'); + const username = uniqueUsername('viewer'); + const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' }); + cleanup.app(slug); + cleanup.adminUser(userId); + + const token = await loginAsUserToken(username, MEMBER_PW); + const page = await pageWithUserToken(browser, token); + try { + await page.goto('/admin/apps'); + // Member can see the apps list (just the one they belong to) + // but the create-app affordance is hidden. + await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible(); + await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0); + } finally { + await page.context().close(); + } + }); + + test('viewer sees no Add domain form and no Settings tab on app detail', async ({ + browser, + uniqueSlug, + uniqueUsername + }) => { + const slug = uniqueSlug('vdom'); + const username = uniqueUsername('viewer'); + const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' }); + cleanup.app(slug); + cleanup.adminUser(userId); + + const token = await loginAsUserToken(username, MEMBER_PW); + const page = await pageWithUserToken(browser, token); + try { + await page.goto(`/admin/apps/${slug}`); + await expect( + page.getByRole('button', { name: /^Scripts \(\d+\)$/ }) + ).toBeVisible(); + // Settings tab is absent. + await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0); + // Domains tab still listable, but no Add-domain submit. + await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click(); + await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0); + } finally { + await page.context().close(); + } + }); + + test('editor sees New script but no Settings tab', async ({ + browser, + uniqueSlug, + uniqueUsername + }) => { + const slug = uniqueSlug('edit'); + const username = uniqueUsername('editor'); + const { userId } = await seedAppAndMember({ slug, username, role: 'editor' }); + cleanup.app(slug); + cleanup.adminUser(userId); + + const token = await loginAsUserToken(username, MEMBER_PW); + const page = await pageWithUserToken(browser, token); + try { + await page.goto(`/admin/apps/${slug}`); + await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0); + await expect( + page.getByRole('button', { name: /^Members \(\d+\)$/ }) + ).toHaveCount(0); + } finally { + await page.context().close(); + } + }); +}); diff --git a/dashboard/tests/e2e/fixtures/role-page.ts b/dashboard/tests/e2e/fixtures/role-page.ts new file mode 100644 index 0000000..8a5ef93 --- /dev/null +++ b/dashboard/tests/e2e/fixtures/role-page.ts @@ -0,0 +1,46 @@ +// Helpers for tests that drive the dashboard as a non-bootstrap admin +// (member with an app-membership row, custom InstanceRole, etc.). +// +// `loginAsUserToken` exchanges username/password for a bearer token +// via the admin API. `pageWithUserToken` opens a fresh browser +// context, seeds the dashboard's localStorage entry, and returns the +// page ready to navigate. Callers are responsible for closing the +// returned page's context. + +import { expect, request, type Browser, type Page } from '@playwright/test'; + +const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080'; + +export async function loginAsUserToken( + username: string, + password: string +): Promise { + const probe = await 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(); + } +} + +export 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; +} diff --git a/dashboard/tests/e2e/members/members.spec.ts b/dashboard/tests/e2e/members/members.spec.ts index 8d20af5..07c2e56 100644 --- a/dashboard/tests/e2e/members/members.spec.ts +++ b/dashboard/tests/e2e/members/members.spec.ts @@ -1,14 +1,13 @@ -import { expect, type Browser, type Page } from '@playwright/test'; +import { expect } from '@playwright/test'; import { test } from '../fixtures/ids'; import { CleanupRegistry } from '../fixtures/cleanup'; import { adminApi } from '../fixtures/api'; +import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page'; // 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(); @@ -38,36 +37,6 @@ async function createMemberUser(username: string): Promise { } } -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'); diff --git a/dashboard/tests/e2e/scripts/scripts.spec.ts b/dashboard/tests/e2e/scripts/scripts.spec.ts index 19a964a..1477abf 100644 --- a/dashboard/tests/e2e/scripts/scripts.spec.ts +++ b/dashboard/tests/e2e/scripts/scripts.spec.ts @@ -2,6 +2,41 @@ import { expect, type Page } from '@playwright/test'; import { test } from '../fixtures/ids'; import { CleanupRegistry } from '../fixtures/cleanup'; import { adminApi } from '../fixtures/api'; +import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page'; + +const MEMBER_PW = 'e2e-member-pw'; + +async function seedAppScriptAndMember(opts: { + slug: string; + username: string; + role: 'viewer' | 'editor'; +}): Promise<{ scriptId: string; userId: string }> { + const api = await adminApi(); + try { + const appRes = await api.post('/api/v1/admin/apps', { + data: { slug: opts.slug, name: opts.slug } + }); + expect(appRes.ok()).toBe(true); + const appId = ((await appRes.json()) as { id: string }).id; + const scriptRes = await api.post('/api/v1/admin/scripts', { + data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI } + }); + expect(scriptRes.ok()).toBe(true); + const scriptId = ((await scriptRes.json()) as { id: string }).id; + const userRes = await api.post('/api/v1/admin/admins', { + data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' } + }); + expect(userRes.ok()).toBe(true); + const userId = ((await userRes.json()) as { id: string }).id; + const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, { + data: { user_id: userId, role: opts.role } + }); + expect(memberRes.ok()).toBe(true); + return { scriptId, userId }; + } finally { + await api.dispose(); + } +} // Phase B3 — Scripts CRUD + Editor. The script editor lives at // /admin/scripts/{id}. Setup uses the API to create the app (and @@ -175,6 +210,105 @@ test.describe('B3 settings', () => { }); }); +test.describe('B3 scripts role shadowing', () => { + test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({ + browser, + uniqueSlug, + uniqueUsername + }) => { + const slug = uniqueSlug('vscr'); + const username = uniqueUsername('viewer'); + const { scriptId, userId } = await seedAppScriptAndMember({ + slug, + username, + role: 'viewer' + }); + cleanup.app(slug); + cleanup.adminUser(userId); + + const token = await loginAsUserToken(username, MEMBER_PW); + const page = await pageWithUserToken(browser, token); + try { + await page.goto(`/admin/scripts/${scriptId}`); + // Header Delete is hidden for non-admins. + await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0); + // Save/Format on the Edit tab are hidden for viewers. + await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0); + await expect( + page.locator('.editor-header').getByRole('button', { name: 'Format' }) + ).toHaveCount(0); + // Test invoke is still visible (everyone with read access). + await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible(); + // Routing tab loads, no +Add route. + await page.getByRole('button', { name: /Routing/ }).click(); + await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0); + // Settings tab is absent for non-admins. + await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0); + } finally { + await page.context().close(); + } + }); + + test('viewer: CodeMirror is read-only', async ({ + browser, + uniqueSlug, + uniqueUsername + }) => { + const slug = uniqueSlug('vro'); + const username = uniqueUsername('viewer'); + const { scriptId, userId } = await seedAppScriptAndMember({ + slug, + username, + role: 'viewer' + }); + cleanup.app(slug); + cleanup.adminUser(userId); + + const token = await loginAsUserToken(username, MEMBER_PW); + const page = await pageWithUserToken(browser, token); + try { + await page.goto(`/admin/scripts/${scriptId}`); + const cm = page.locator('.cm-content').first(); + await expect(cm).toBeVisible(); + // CodeMirror sets contenteditable=false when EditorView.editable.of(false) + // is in effect; that's the canonical signal for read-only mode. + await expect(cm).toHaveAttribute('contenteditable', 'false'); + } finally { + await page.context().close(); + } + }); + + test('editor: Save visible, Delete header hidden', async ({ + browser, + uniqueSlug, + uniqueUsername + }) => { + const slug = uniqueSlug('escr'); + const username = uniqueUsername('editor'); + const { scriptId, userId } = await seedAppScriptAndMember({ + slug, + username, + role: 'editor' + }); + cleanup.app(slug); + cleanup.adminUser(userId); + + const token = await loginAsUserToken(username, MEMBER_PW); + const page = await pageWithUserToken(browser, token); + try { + await page.goto(`/admin/scripts/${scriptId}`); + // Editor sees Save (disabled until the buffer changes — that's fine). + await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible(); + // Delete stays admin-only. + await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0); + // Settings stays admin-only. + await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0); + } finally { + await page.context().close(); + } + }); +}); + test.describe('B3 adversarial', () => { test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('loop');