import { expect, test, type APIRequestContext, type Page } from '@playwright/test'; /** * M4 — MITRE ATT&CK Enterprise reference catalogue + tag picker. * * The seed itself (download + parse) is exercised by pytest with a small * fixture bundle. This spec hits the live stack with the real, pinned bundle * by calling `POST /mitre/sync` once and then validating the read endpoints * + the picker UI. */ const ADMIN_EMAIL = `admin-${Math.floor(Math.random() * 1e6)}@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('M4 — MITRE ATT&CK reference', () => { 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); // Trigger a real sync against the pinned MITRE URL. Idempotent — if the // mitre_* tables were left populated by a previous run, this is a no-op // upsert. 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(), `mitre sync failed: ${await sync.text()}`).toBe(200); const result = await sync.json(); expect(result.tactics_upserted).toBeGreaterThanOrEqual(14); expect(result.techniques_upserted).toBeGreaterThanOrEqual(180); expect(result.subtechniques_upserted).toBeGreaterThanOrEqual(400); }); test('GET /mitre/tactics returns 14+ Enterprise tactics', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const r = await request.get('/api/v1/mitre/tactics', { headers: { Authorization: `Bearer ${access}` }, }); expect(r.status()).toBe(200); const body = await r.json(); expect(body.total).toBeGreaterThanOrEqual(14); const ids = body.items.map((t: { external_id: string }) => t.external_id); expect(ids).toContain('TA0001'); // Initial Access expect(ids).toContain('TA0006'); // Credential Access }); test('GET /mitre/techniques?tactic=TA0006 filters', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const r = await request.get('/api/v1/mitre/techniques?tactic=TA0006&limit=200', { headers: { Authorization: `Bearer ${access}` }, }); expect(r.status()).toBe(200); const body = await r.json(); expect(body.total).toBeGreaterThan(0); // OS Credential Dumping is the textbook TA0006 example. const ids = body.items.map((t: { external_id: string }) => t.external_id); expect(ids).toContain('T1003'); }); test('GET /mitre/subtechniques?technique=T1003 lists 8 sub-techniques', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const r = await request.get('/api/v1/mitre/subtechniques?technique=T1003', { headers: { Authorization: `Bearer ${access}` }, }); expect(r.status()).toBe(200); const ids = (await r.json()).items.map((t: { external_id: string }) => t.external_id); expect(ids).toContain('T1003.001'); // LSASS Memory expect(ids.length).toBeGreaterThanOrEqual(5); }); test('GET /mitre/status returns version + last_sync', async ({ request }) => { const access = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const r = await request.get('/api/v1/mitre/status', { headers: { Authorization: `Bearer ${access}` }, }); expect(r.status()).toBe(200); const body = await r.json(); expect(body.last_sync).toBeTruthy(); expect(body.default_url).toContain('mitre-attack'); expect(body.default_version).toBeTruthy(); }); test('SPA MITRE matrix renders + click cells to select technique + sub-technique', async ({ page }) => { await loginViaSpa(page, ADMIN_EMAIL, ADMIN_PASSWORD); await page.goto('/mitre'); // Status card shows a non-null last_sync. await expect(page.getByTestId('mitre-last-sync')).not.toHaveText('never'); const picker = page.getByTestId('mitre-tag-picker'); await expect(picker).toBeVisible(); // The matrix has a column per tactic. await expect(picker.getByTestId('mitre-column-TA0006')).toBeVisible(); // 1. Click the T1003 cell (Credential Dumping under TA0006) → technique selected. const t1003 = picker.getByTestId('mitre-technique-T1003').first(); await t1003.scrollIntoViewIfNeeded(); await t1003.click(); await expect(page.getByTestId('mitre-selected')).toContainText('T1003'); await expect(t1003).toHaveAttribute('aria-pressed', 'true'); // 2. Expand T1003's sub-techniques inline via the +N chevron. await picker.getByTestId('mitre-expand-T1003').first().click(); const sub = picker.getByTestId('mitre-subtechnique-T1003.001').first(); await expect(sub).toBeVisible(); // 3. Click the sub-technique → chip + JSON preview both update. await sub.click(); await expect(page.getByTestId('mitre-selected')).toContainText('T1003.001'); await expect(page.getByTestId('mitre-selected-json')).toContainText('"T1003.001"'); // 4. Filter the matrix on "valid" → TA0006/T1003 are hidden but TA0001/T1078 visible. await picker.getByLabel(/^filter$/i).fill('valid'); await expect(picker.getByTestId('mitre-technique-T1078').first()).toBeVisible(); }); test('Non-admin cannot trigger /mitre/sync', async ({ page, request }) => { // Invite a no-perm user via the admin. const adminAccess = await loginAndGetAccess(request, ADMIN_EMAIL, ADMIN_PASSWORD); const eveEmail = `eve-${Math.floor(Math.random() * 1e6)}@metamorph.local`; const inv = await request.post('/api/v1/invitations', { headers: { Authorization: `Bearer ${adminAccess}` }, data: { email_hint: eveEmail }, }); const token = (await inv.json()).token; await request.post(`/api/v1/invitations/accept/${token}`, { data: { email: eveEmail, password: 'EvePass1234!' }, }); const eveAccess = await loginAndGetAccess(request, eveEmail, 'EvePass1234!'); const r = await request.post('/api/v1/mitre/sync', { headers: { Authorization: `Bearer ${eveAccess}` }, }); expect(r.status()).toBe(403); // The MITRE page is reachable in read-only mode for any logged-in user, // but the Sync card is hidden for non-admins. await loginViaSpa(page, eveEmail, 'EvePass1234!'); await page.goto('/mitre'); await expect(page.getByTestId('mitre-tag-picker')).toBeVisible(); await expect(page.getByTestId('mitre-sync')).toHaveCount(0); }); });