/** * US-20 — MITRE matrix: attack.mitre.org look + no horizontal scroll. * Covers AC-20.1 (max-w-[98vw]) and AC-20.4 (no horizontal scroll via boundingBox). * AC-20.5 (sub-technique expand preserved) spot-checked. */ 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 = 'us20-redteam'; const PASS = 'us20-pass-strong'; interface Simulation { id: number; [key: string]: unknown; } async function createSimulation(token: string, engagementId: number, name = 'US-20 sim'): Promise { const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name }); if (r.status !== 201) throw new Error(`create sim: ${r.status}`); return r.data as Simulation; } async function deleteSimulation(token: string, simId: number): Promise { await makeClient(token).delete(`/simulations/${simId}`); } test.describe('US-20 — MITRE matrix fits 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-20 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-20.1 — modal max-width is 98vw (fits within viewport)', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-20.1 width'); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); // Open matrix via the grid icon button await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); const dialogBox = await dialog.boundingBox(); const viewportWidth = page.viewportSize()!.width; expect(dialogBox).toBeTruthy(); // Modal must not exceed viewport width (98vw) expect(dialogBox!.width).toBeLessThanOrEqual(viewportWidth * 0.99); // Modal must be visible (has meaningful width) expect(dialogBox!.width).toBeGreaterThan(600); await deleteSimulation(redteamToken, sim.id); }); test('AC-20.4 — matrix body has NO horizontal scroll at 1280px viewport', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 no scroll'); // Force 1280×720 viewport (default in playwright.config.ts) await page.setViewportSize({ width: 1280, height: 720 }); 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 }); // The matrix body container must not overflow horizontally. // We check scrollWidth <= clientWidth on the overflow body element. const hasHorizontalScroll = await page.evaluate(() => { // Find the element with overflow-y-auto / overflow-x-hidden const dialogs = document.querySelectorAll('[role="dialog"]'); for (const d of dialogs) { // The body is the flex-1 scrollable div inside the dialog const body = d.querySelector('.overflow-y-auto, .overflow-x-hidden'); if (body) { return body.scrollWidth > body.clientWidth + 2; // 2px tolerance } } return false; }); expect(hasHorizontalScroll).toBe(false); await deleteSimulation(redteamToken, sim.id); }); test('AC-20.4 — all 12 tactic columns visible without scrolling at 1280px', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 tactics visible'); await page.setViewportSize({ width: 1280, height: 720 }); 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 }); // All 12 canonical tactics must be in the DOM (use first() to avoid strict mode violation // when tactic name appears in multiple technique titles, e.g. "Execution" appears in // technique sub-names). const expectedTactics = [ 'Initial Access', 'Execution', 'Persistence', 'Privilege Escalation', 'Defense Evasion', 'Credential Access', 'Discovery', 'Lateral Movement', 'Collection', 'Command and Control', 'Exfiltration', 'Impact', ]; for (const tactic of expectedTactics) { await expect(dialog.getByText(tactic, { exact: false }).first()).toBeVisible(); } // The dialog itself must not have a scrollbar (overflow-x-hidden) const dialogBox = await dialog.boundingBox(); const viewportWidth = page.viewportSize()!.width; expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth + 2); await deleteSimulation(redteamToken, sim.id); }); test('AC-20.5 — sub-technique expand/collapse still works after layout overhaul', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-20.5 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 const expandBtn = dialog.getByRole('button', { name: /expand T1059/i }); await expect(expandBtn).toBeVisible(); await expandBtn.click(); // T1059.001 visible after expand await expect(dialog).toContainText('T1059.001'); // Collapse await dialog.getByRole('button', { name: /collapse T1059/i }).click(); await expect(dialog).not.toContainText('T1059.001'); await deleteSimulation(redteamToken, sim.id); }); });