/** * US-15 — redteam explores and selects techniques via the MITRE ATT&CK matrix modal. * Covers AC-15.1 → AC-15.5. */ 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 = 'us15-redteam'; const PASS = 'us15-pass-strong'; interface Simulation { id: number; [key: string]: unknown; } async function createSimulation( token: string, engagementId: number, name = 'US-15 sim', ): Promise { const client = makeClient(token); const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`); return r.data as Simulation; } async function deleteSimulation(token: string, simId: number): Promise { await makeClient(token).delete(`/simulations/${simId}`); } test.describe('US-15 — MITRE matrix modal', () => { 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-15 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-15.1 — GET /api/mitre/matrix returns tactic tree with correct structure', async () => { const client = makeClient(redteamToken); const r = await client.get('/mitre/matrix'); expect(r.status).toBe(200); expect(Array.isArray(r.data)).toBe(true); // At least the 12 canonical MITRE Enterprise tactics expect(r.data.length).toBeGreaterThanOrEqual(12); const first = r.data[0]; expect(first).toHaveProperty('tactic_id'); expect(first).toHaveProperty('tactic_name'); expect(Array.isArray(first.techniques)).toBe(true); // First tactic must be "Initial Access" (canonical order) expect(first.tactic_name).toBe('Initial Access'); // Each technique has id, name, subtechniques array const tech = first.techniques[0]; expect(tech).toHaveProperty('id'); expect(tech).toHaveProperty('name'); expect(Array.isArray(tech.subtechniques)).toBe(true); // A technique with known sub-techniques: T1059 is in Execution const execTactic = (r.data as { tactic_name: string; techniques: { id: string; subtechniques: { id: string; name: string }[] }[] }[]).find( (t) => t.tactic_name === 'Execution', ); expect(execTactic).toBeTruthy(); const t1059 = execTactic!.techniques.find((t) => t.id === 'T1059'); expect(t1059).toBeTruthy(); expect(t1059!.subtechniques.length).toBeGreaterThan(0); // T1059.001 should be a known sub-technique const sub = t1059!.subtechniques.find((s) => s.id === 'T1059.001'); expect(sub).toBeTruthy(); expect(sub!.name).toBeTruthy(); }); test('AC-15.1 — tactic canonical order is correct (Initial Access first, Impact last)', async () => { const client = makeClient(redteamToken); const r = await client.get('/mitre/matrix'); expect(r.status).toBe(200); const tacticNames = (r.data as { tactic_name: string }[]).map((t) => t.tactic_name); expect(tacticNames[0]).toBe('Initial Access'); expect(tacticNames[tacticNames.length - 1]).toBe('Impact'); // Verify Exfiltration appears before Impact const exfilIdx = tacticNames.indexOf('Exfiltration'); const impactIdx = tacticNames.indexOf('Impact'); expect(exfilIdx).toBeGreaterThan(-1); expect(exfilIdx).toBeLessThan(impactIdx); }); test('AC-15.2 — modal layout: columns per tactic, tactic header, technique cells', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 layout'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); // Open the matrix modal via "Add technique" await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Modal title await expect(dialog.getByRole('heading', { name: /mitre att&?ck matrix/i })).toBeVisible(); // Search / filter input present and focused const searchInput = dialog.getByLabel(/filter techniques/i); await expect(searchInput).toBeVisible(); // At least one tactic column visible — check for "Initial Access" and "Execution" await expect(dialog).toContainText('Initial Access'); await expect(dialog).toContainText('Execution'); // T1059 technique cell visible in Execution column await expect(dialog).toContainText('T1059'); // Cancel button present await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.2 — selecting technique updates Apply button counter', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 select'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Use search to isolate T1059 so there's only the label button visible // The chevron has aria-label "Expand T1059"; we use filter to get the label button const searchInput = dialog.getByLabel(/filter techniques/i); await searchInput.fill('T1059'); // Wait for filter to apply — only T1059 and its subtechniques should be visible await expect(dialog).toContainText('Command and Scripting Interpreter'); // The label button (selection) is the one containing the technique name text // Filter explicitly excludes the chevron (aria-label="Expand T1059") const techLabelBtn = dialog .getByRole('button', { name: /command and scripting interpreter/i }) .first(); await techLabelBtn.click(); // Apply button should now show count = 1 await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); // Click again to deselect await techLabelBtn.click(); // When 0 selected and no initial selection: footer shows disabled "Clear all" await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible(); await expect(dialog.getByRole('button', { name: /apply \d+ item/i })).not.toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.2 — subtechnique expand/collapse via chevron', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 expand'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Expand T1059 via chevron button (▸ Expand T1059) const expandBtn = dialog.getByRole('button', { name: /expand T1059/i }); await expect(expandBtn).toBeVisible(); await expandBtn.click(); // Sub-technique T1059.001 should now be visible await expect(dialog).toContainText('T1059.001'); // Collapse it const collapseBtn = dialog.getByRole('button', { name: /collapse T1059/i }); await collapseBtn.click(); await expect(dialog).not.toContainText('T1059.001'); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.2 — search filters techniques, auto-expands parent when sub matches', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 search'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); const searchInput = dialog.getByLabel(/filter techniques/i); // Search by sub-technique ID — parent should auto-expand await searchInput.fill('T1059.001'); await expect(dialog).toContainText('T1059.001'); // Search by name (case-insensitive) await searchInput.fill('powershell'); await expect(dialog).toContainText('PowerShell'); // Clear search — techniques come back await searchInput.fill(''); await expect(dialog).toContainText('T1059'); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.2 — tactic header shows selected count when techniques selected', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 counter'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Initially no "selected" counter visible // Sprint 4: tactic header shows truncated "N sel." due to tight column width await expect(dialog).not.toContainText('1 sel'); // Use search to isolate T1059 so we can click the label button, not the chevron const searchInput = dialog.getByLabel(/filter techniques/i); await searchInput.fill('T1059'); await expect(dialog).toContainText('Command and Scripting Interpreter'); // The label button contains the technique name; the chevron has aria-label="Expand T1059" await dialog .getByRole('button', { name: /command and scripting interpreter/i }) .first() .click(); // Sprint 4: tactic header shows "1 sel." (truncated) due to tight column width await expect(dialog).toContainText('1 sel'); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.3 — Apply auto-saves techniques and closes modal', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 apply'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); const searchInput = dialog.getByLabel(/filter techniques/i); // Select T1059 via label button (not chevron) — filter to isolate await searchInput.fill('T1059'); await expect(dialog).toContainText('Command and Scripting Interpreter'); await dialog .getByRole('button', { name: /command and scripting interpreter/i }) .first() .click(); // Select T1566 (Phishing) — no subtechniques, so only one button await searchInput.fill('T1566'); await expect(dialog).toContainText('T1566'); await dialog.getByRole('button', { name: /phishing/i }).first().click(); // Apply (2 techniques selected) const applyBtn = dialog.getByRole('button', { name: /apply \d+ item/i }); await expect(applyBtn).toBeVisible(); await applyBtn.click(); // Modal closes await expect(dialog).not.toBeVisible({ timeout: 5_000 }); // Auto-save toast appears await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 }); // Tags appear in the tag list const tagList = page.getByTestId('techniques-tag-list'); await expect(tagList).toContainText('T1059'); await expect(tagList).toContainText('T1566'); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.3 — modal receives current selection as initial state', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 initial'); // Seed T1059 via API before opening the UI await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'], }); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Apply button should already show 1 technique (from initial selection) await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); // Cancel to discard await dialog.getByRole('button', { name: /^cancel$/i }).click(); await expect(dialog).not.toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.3 — Cancel discards local changes (no PATCH fired)', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 cancel'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Select a technique via label button (filter to avoid hitting chevron) const searchInput = dialog.getByLabel(/filter techniques/i); await searchInput.fill('T1059'); await expect(dialog).toContainText('Command and Scripting Interpreter'); await dialog .getByRole('button', { name: /command and scripting interpreter/i }) .first() .click(); await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); // Cancel instead of Apply await dialog.getByRole('button', { name: /^cancel$/i }).click(); await expect(dialog).not.toBeVisible(); // No toast, no PATCH fired — empty state message still visible (0 techniques) await expect(page.getByText(/techniques updated/i)).not.toBeVisible(); await expect(page.getByText(/no techniques selected/i)).toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.4 — Escape key closes modal (Cancel behaviour)', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 escape'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Select something to confirm Cancel semantics on Escape const searchInput = dialog.getByLabel(/filter techniques/i); await searchInput.fill('T1059'); await expect(dialog).toContainText('Command and Scripting Interpreter'); await dialog .getByRole('button', { name: /command and scripting interpreter/i }) .first() .click(); await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible({ timeout: 3_000 }); // No PATCH fired — empty state message still visible (no techniques added) await expect(page.getByText(/no techniques selected/i)).toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.4 — backdrop click closes modal (Cancel behaviour)', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 backdrop'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Click outside the modal container (top-left corner of viewport, which is the backdrop) await page.mouse.click(5, 5); await expect(dialog).not.toBeVisible({ timeout: 3_000 }); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.5 — a11y: role=dialog + aria-labelledby, search input focused on open', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 a11y'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // role="dialog" is set (getByRole('dialog') already asserts this) // aria-modal attribute const ariaModal = await dialog.getAttribute('aria-modal'); expect(ariaModal).toBe('true'); // aria-labelledby points to the modal title const labelledBy = await dialog.getAttribute('aria-labelledby'); expect(labelledBy).toBeTruthy(); const titleEl = page.locator(`#${labelledBy}`); await expect(titleEl).toContainText(/mitre att&?ck matrix/i); // Search input is focused immediately after open const searchInput = dialog.getByLabel(/filter techniques/i); await expect(searchInput).toBeFocused({ timeout: 2_000 }); await deleteSimulation(redteamToken, sim.id); }); test('AC-15.5 — a11y: Tab wraps within modal (focus trap)', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 focus-trap'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Tab through enough elements to hit the wrap point // (we don't know exact count, but Shift+Tab from the first focused element // should stay inside the modal — not land outside) const searchInput = dialog.getByLabel(/filter techniques/i); await expect(searchInput).toBeFocused({ timeout: 2_000 }); // Shift+Tab from the first element (search) should wrap to Cancel or Apply await page.keyboard.press('Shift+Tab'); // The focused element must still be inside the dialog const focusedOutsideDialog = await page.evaluate(() => { const dialog = document.querySelector('[role="dialog"]'); return dialog ? !dialog.contains(document.activeElement) : true; }); expect(focusedOutsideDialog).toBe(false); await deleteSimulation(redteamToken, sim.id); }); });