/** * US-10 — MITRE ATT&CK autocomplete. * Covers AC-10.1 → AC-10.5. * * AC-10.1 (make update-mitre CLI target) is not exercised from Playwright; * the bundle is assumed present in the container image. */ import { test, expect } from '@playwright/test'; import { adminToken, createEngagement, deleteEngagement, deleteUserByUsername, ensureUser, login, makeClient, } from '../fixtures/api'; import { seedTokenInStorage } from '../fixtures/auth'; const REDTEAM_USER = 'us10-redteam'; const PASS = 'us10-pass-strong'; interface Simulation { id: number; [key: string]: unknown; } async function createSimulation( token: string, engagementId: number, name = 'US-10 sim', ): Promise { const client = makeClient(token); const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); if (r.status !== 201) { throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`); } return r.data as Simulation; } async function deleteSimulation(token: string, simId: number): Promise { const client = makeClient(token); await client.delete(`/simulations/${simId}`); } test.describe('US-10 — MITRE autocomplete', () => { let redteamToken: string; let engagementId: number; test.beforeAll(async () => { await ensureUser(REDTEAM_USER, PASS, 'redteam'); redteamToken = (await login(REDTEAM_USER, PASS)).token; const eng = await createEngagement(redteamToken, { name: 'US-10 Test Engagement', start_date: '2026-01-01', }); engagementId = eng.id; }); test.afterAll(async () => { try { const tok = await adminToken(); await deleteEngagement(tok, engagementId); await deleteUserByUsername(tok, REDTEAM_USER); } catch { /* noop */ } }); test('AC-10.1 — bundle present in container (skipped: CLI-only, bundle assumed committed)', async () => { // AC-10.1 is a Makefile target test. We verify the bundle is loaded // indirectly by checking the API returns results (not 503). const client = makeClient(redteamToken); const r = await client.get('/mitre/techniques?q=T1059'); // If bundle not loaded, we'd get 503 — this confirms it loaded OK expect(r.status).not.toBe(503); }); test('AC-10.2 — GET /api/mitre/techniques?q= returns max 20 results with id/name/tactics', async () => { const client = makeClient(redteamToken); // Search by id prefix const rId = await client.get('/mitre/techniques?q=T1059'); expect(rId.status).toBe(200); expect(Array.isArray(rId.data)).toBe(true); expect(rId.data.length).toBeGreaterThan(0); expect(rId.data.length).toBeLessThanOrEqual(20); const first = rId.data[0]; expect(first).toHaveProperty('id'); expect(first).toHaveProperty('name'); expect(first).toHaveProperty('tactics'); expect(Array.isArray(first.tactics)).toBe(true); // Exact id match comes first const exactMatch = rId.data.find((t: { id: string }) => t.id === 'T1059'); expect(exactMatch).toBeTruthy(); expect(rId.data[0].id).toBe('T1059'); // Search by name (case-insensitive) const rName = await client.get( '/mitre/techniques?q=command%20and%20scripting%20interpreter', ); expect(rName.status).toBe(200); expect(rName.data.length).toBeGreaterThan(0); const nameMatch = rName.data.find((t: { name: string }) => t.name.toLowerCase().includes('command and scripting'), ); expect(nameMatch).toBeTruthy(); }); test('AC-10.3 — 503 if bundle not loaded (verified by absence: bundle IS loaded)', async () => { const client = makeClient(redteamToken); // We can only test the happy path from e2e; 503 requires a container // without the bundle. We verify the endpoint does NOT return 503, // confirming the bundle is loaded. const r = await client.get('/mitre/techniques?q=T1'); expect(r.status).toBe(200); }); test('AC-10.4 — sub-techniques (T1059.001) included in search results', async () => { const client = makeClient(redteamToken); const r = await client.get('/mitre/techniques?q=T1059.001'); expect(r.status).toBe(200); expect(r.data.length).toBeGreaterThan(0); const subtech = r.data.find((t: { id: string }) => t.id === 'T1059.001'); expect(subtech).toBeTruthy(); expect(subtech.name).toBeTruthy(); }); test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection appends tag', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-10.5 sim'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); // Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search" await page.getByRole('button', { name: /quick search/i }).click(); const picker = page.getByRole('combobox', { name: /mitre technique/i }); await expect(picker).toBeVisible(); // Type a query — after debounce (200ms) the dropdown opens with results await picker.fill('T1059'); const listbox = page.getByRole('listbox', { name: /mitre techniques/i }); await expect(listbox).toBeVisible({ timeout: 5_000 }); // Options visible in expected format: "T1059 — Command and Scripting Interpreter (...)" const options = listbox.getByRole('option'); await expect(options.first()).toBeVisible(); const firstText = await options.first().textContent(); expect(firstText).toMatch(/T1059/); expect(firstText).toMatch(/—/); // Keyboard navigation: ArrowDown selects item, Enter confirms await picker.press('ArrowDown'); await picker.press('Enter'); // Sprint 3: after selection the picker resets (one-shot append mode). // The tag T1059 should appear in the techniques field. await expect(listbox).not.toBeVisible(); await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 }); await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059'); // Escape closes the dropdown (re-open picker to test Escape) await page.getByRole('button', { name: /quick search/i }).click(); await picker.fill('T1'); await expect(listbox).toBeVisible({ timeout: 5_000 }); await picker.press('Escape'); await expect(listbox).not.toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); });