import { expect, test } from '@playwright/test'; import type { BrowserContext } from '@playwright/test'; import { contextForUser } from './helpers'; import { fixtureManifest, HUNG_SOURCE, MALICIOUS_SOURCE, pluginZip, WELL_BEHAVED_SOURCE, } from './plugin-fixtures'; /** * Sandbox security pack (issue #73, ADR 0008): drives the admin plugin * preview page — the same sandbox runtime pages embed — with a well-behaved, * a malicious, and a hung fixture plugin. The malicious plugin is the * permanent security regression asset: every probe it runs must stay * "blocked" forever. */ const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; async function installPlugin( context: BrowserContext, id: string, name: string, permissions: string[], source: string, ): Promise { // Idempotent per run: remove any previous installation of the fixture id. await context.request.delete(`/api/v1/admin/plugins/${id}`); const response = await context.request.post('/api/v1/admin/plugins', { multipart: { file: { name: `${id}.zip`, mimeType: 'application/zip', buffer: pluginZip(fixtureManifest(id, name, permissions), source), }, }, }); expect(response.status(), await response.text()).toBe(201); } test('a well-behaved plugin renders and resizes its own frame', async ({ browser }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); await installPlugin(context, 'e2e-behaved', 'Behaved', ['ui'], WELL_BEHAVED_SOURCE); const page = await context.newPage(); await page.goto('/admin/plugins/e2e-behaved/preview'); const host = page.locator('.plugin-frame-host'); await expect(host).toHaveAttribute('data-state', 'ready', { timeout: 10000 }); const frame = page.frameLocator('.plugin-frame'); await expect(frame.locator('body')).toHaveText('plugin-ok'); // `ui.resize` was declared → the built-in handler applied the height. await expect(page.locator('.plugin-frame')).toHaveCSS('height', '321px'); }); test('a malicious plugin cannot escape the sandbox (security regression asset)', async ({ browser, }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); await installPlugin(context, 'e2e-malicious', 'Malicious', [], MALICIOUS_SOURCE); const page = await context.newPage(); await page.goto('/admin/plugins/e2e-malicious/preview'); const frame = page.frameLocator('.plugin-frame'); // The final probe reports last — once it is attached, all verdicts are in. // (The marker divs are empty and zero-size, so assert attachment, not // visibility.) await expect(frame.locator('[data-probe="done"]')).toBeAttached({ timeout: 15000 }); for (const probe of [ 'parentDom', 'cookie', 'localStorage', 'fetchSameOrigin', 'fetchExternal', 'undeclaredCapability', ]) { await expect(frame.locator(`[data-probe="${probe}"]`), probe).toHaveAttribute( 'data-result', 'blocked', ); } }); test('a hung plugin collapses to the timeout placeholder; the page stays responsive', async ({ browser, }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-admin'); await installPlugin(context, 'e2e-hung', 'Hung', [], HUNG_SOURCE); const page = await context.newPage(); await page.goto('/admin/plugins/e2e-hung/preview'); const host = page.locator('.plugin-frame-host'); // The placeholder must appear within the 5 s deadline (plus render slack). await expect(host).toHaveAttribute('data-state', 'failed', { timeout: 7000 }); await expect(page.locator('.plugin-frame-host__status--failed')).toBeVisible(); await expect(page.locator('.plugin-frame')).toHaveCount(0); // The app around the dead plugin still responds to input. await expect(page.locator('.plugin-preview__permissions, h1').first()).toBeVisible(); await page.getByRole('heading', { level: 1 }).click(); });