import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; /** * M5 — Test + Scenario template catalogue. * * Verifies CRUD on /test-templates and /scenario-templates plus the admin SPA * pages. We do NOT seed the full MITRE bundle here — M4 already covers that * suite. This spec only needs ONE technique resolvable from a STIX-like * shape (we ride on the same `/diag/reset` then re-seed MITRE so tag refs * resolve). */ const ADMIN_EMAIL = `admin-${crypto.randomUUID().slice(0, 8)}@metamorph.local`; const ADMIN_PASSWORD = 'AdminPass1234!'; async function resetAndMintToken(request: APIRequestContext): Promise { const r = await request.post('/api/v1/diag/reset'); expect(r.status()).toBe(200); return (await r.json()).install_token as string; } async function loginAndGetAccess( request: APIRequestContext, email: string, password: string, ): Promise { const r = await request.post('/api/v1/auth/login', { data: { email, password } }); expect(r.status()).toBe(200); return (await r.json()).access_token as string; } async function loginViaSpa(page: Page, email: string, password: string) { await page.goto('/login'); await page.getByLabel(/email/i).fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole('button', { name: /sign in/i }).click(); await expect(page.getByTestId('me-email')).toHaveText(email); } test.describe.configure({ mode: 'serial' }); test.describe('M5 — Template catalogue', () => { test.beforeAll(async ({ request }) => { const installToken = await resetAndMintToken(request); const setup = await request.post('/api/v1/setup', { data: { install_token: installToken, email: ADMIN_EMAIL, password: ADMIN_PASSWORD }, }); expect(setup.status()).toBe(201); // MITRE re-sync — picker + tag refs rely on the canonical bundle. const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const sync = await request.post('/api/v1/mitre/sync', { headers: { Authorization: `Bearer ${access}` }, }); expect(sync.status()).toBe(200); }); test.afterAll(async ({ request }) => { // Restore the stable admin (cf. memory feedback_metamorph_test_admin): // any wipe should leave admin@metamorph.local / AdminPass1234! usable. const installToken = await resetAndMintToken(request); await request.post('/api/v1/setup', { data: { install_token: installToken, email: 'admin@metamorph.local', password: 'AdminPass1234!', }, }); // Re-seed MITRE so subsequent manual sessions don't see an empty matrix. const access = await loginAndGetAccess(request, 'admin@metamorph.local', 'AdminPass1234!'); await request.post('/api/v1/mitre/sync', { headers: { Authorization: `Bearer ${access}` }, }); }); // === API smoke ============================================================ test('CRUD test-templates via API', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const auth = { Authorization: `Bearer ${access}` }; // Create const r1 = await request.post('/api/v1/test-templates', { headers: auth, data: { name: 'phish-link', description: 'send a phishing email with tracked link', objective: 'land a click', procedure_md: '1. craft mail\n2. send\n3. await click', opsec_level: 'low', tags: ['phish', 'initial-access'], expected_iocs: ['phish@example.com'], mitre_tags: [ { kind: 'tactic', external_id: 'TA0001' }, { kind: 'technique', external_id: 'T1566' }, ], }, }); expect(r1.status(), await r1.text()).toBe(201); const created = await r1.json(); expect(created.name).toBe('phish-link'); expect(created.mitre_tags.length).toBe(2); expect(created.tags).toContain('phish'); // Update — partial: change opsec only const r2 = await request.put(`/api/v1/test-templates/${created.id}`, { headers: auth, data: { opsec_level: 'high' }, }); expect(r2.status()).toBe(200); const updated = await r2.json(); expect(updated.opsec_level).toBe('high'); expect(updated.name).toBe('phish-link'); // untouched // List + filter by tactic const r3 = await request.get('/api/v1/test-templates?tactic=TA0001', { headers: auth, }); expect(r3.status()).toBe(200); const list = await r3.json(); expect(list.items.map((it: { name: string }) => it.name)).toContain('phish-link'); // Reject unknown MITRE const r4 = await request.post('/api/v1/test-templates', { headers: auth, data: { name: 'bad', mitre_tags: [{ kind: 'technique', external_id: 'T9999' }], }, }); expect(r4.status()).toBe(400); expect((await r4.json()).error).toBe('unknown_mitre_tag'); // Soft-delete const r5 = await request.delete(`/api/v1/test-templates/${created.id}`, { headers: auth, }); expect(r5.status()).toBe(200); const r6 = await request.get('/api/v1/test-templates', { headers: auth }); expect( (await r6.json()).items.map((it: { name: string }) => it.name), ).not.toContain('phish-link'); }); test('Scenario template: create + reorder + soft-delete', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const auth = { Authorization: `Bearer ${access}` }; async function mkTest(name: string): Promise { const r = await request.post('/api/v1/test-templates', { headers: auth, data: { name }, }); expect(r.status()).toBe(201); return (await r.json()).id as string; } const a = await mkTest('scn-step-a'); const b = await mkTest('scn-step-b'); const c = await mkTest('scn-step-c'); // Create with [a, b, c] const r1 = await request.post('/api/v1/scenario-templates', { headers: auth, data: { name: 'ordered-scenario', test_template_ids: [a, b, c] }, }); expect(r1.status()).toBe(201); const sc = await r1.json(); expect(sc.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([ 'scn-step-a', 'scn-step-b', 'scn-step-c', ]); // Reorder → [c, a, b] const r2 = await request.put(`/api/v1/scenario-templates/${sc.id}/tests`, { headers: auth, data: { test_template_ids: [c, a, b] }, }); expect(r2.status()).toBe(200); const after = await r2.json(); expect(after.tests.map((t: { test_template_name: string }) => t.test_template_name)).toEqual([ 'scn-step-c', 'scn-step-a', 'scn-step-b', ]); // Soft-delete the scenario. const r3 = await request.delete(`/api/v1/scenario-templates/${sc.id}`, { headers: auth }); expect(r3.status()).toBe(200); const list = await (await request.get('/api/v1/scenario-templates', { headers: auth })).json(); expect(list.items.map((it: { name: string }) => it.name)).not.toContain('ordered-scenario'); }); // === SPA smoke ============================================================ test('SPA — admin sees the test catalogue and can filter', async ({ page, request }) => { // Seed two tests up front via the API — exercise the SPA list + filter // pipeline without fighting the heavy create-modal (covered by API tests). const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const auth = { Authorization: `Bearer ${access}` }; await request.post('/api/v1/test-templates', { headers: auth, data: { name: 'spa-list-fast', opsec_level: 'low', tags: ['fast'] }, }); await request.post('/api/v1/test-templates', { headers: auth, data: { name: 'spa-list-slow', opsec_level: 'high' }, }); await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto('/admin/tests'); await expect(page.getByText('spa-list-fast')).toBeVisible(); await expect(page.getByText('spa-list-slow')).toBeVisible(); await page.getByTestId('filter-opsec').selectOption('high'); await expect(page.getByText('spa-list-slow')).toBeVisible(); await expect(page.getByText('spa-list-fast')).toBeHidden(); }); test('SPA — scenario list shows ordered tests with their position', async ({ page, request }) => { // Seed a 3-test scenario via the API; the SPA must render the order as // saved. Pointer-event drag is flaky in CI, and the API-level reorder // test already covers the persistence pipeline. const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const auth = { Authorization: `Bearer ${access}` }; const ids: string[] = []; for (const name of ['drag-1', 'drag-2', 'drag-3']) { const r = await request.post('/api/v1/test-templates', { headers: auth, data: { name }, }); ids.push((await r.json()).id); } const scResp = await request.post('/api/v1/scenario-templates', { headers: auth, data: { name: 'spa-rendered-scenario', test_template_ids: [ids[2], ids[0], ids[1]], }, }); const scId = (await scResp.json()).id; await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto('/admin/scenarios'); const card = page.locator(`[data-testid="scenario-row-${scId}"]`); await expect(card).toBeVisible(); await expect(card.getByText('1. drag-3')).toBeVisible(); await expect(card.getByText('2. drag-1')).toBeVisible(); await expect(card.getByText('3. drag-2')).toBeVisible(); }); });