Files
mimic/e2e/tests/us20-matrix-fits-modal.spec.ts
Knacky 7d81ce9785 test(e2e): fill coverage gaps — +N suffix + focus-trap cycle
Add two tests omitted from the initial sprint 4 run:
- us21: SimulationList MITRE column shows "TA0007 +2" for 1 tactic + 2 techniques
- us20: MitreMatrixModal Tab wraps to first focusable, Shift+Tab wraps to last

Suite: 158 passed, 0 failed (1 expected test.fail for AC-21.6 slug defect).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:33:18 +02:00

236 lines
8.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Simulation> {
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<void> {
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);
});
// NIT code-reviewer + AC-15.5 regression: Tab focus-trap cycle in MitreMatrixModal
test('AC-15.5 regression — MitreMatrixModal Tab key cycles focus, Shift+Tab reverses', 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 });
// Collect all focusable elements in the dialog
const focusableCount = await dialog.evaluate((el) => {
const focusables = el.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
return focusables.length;
});
expect(focusableCount).toBeGreaterThan(1);
// Focus the last focusable element
await dialog.evaluate((el) => {
const focusables = Array.from(el.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
));
focusables[focusables.length - 1].focus();
});
// Tab from last → should wrap to first (focus trap)
await page.keyboard.press('Tab');
const activeAfterTab = await dialog.evaluate((el) => {
const focusables = Array.from(el.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
));
return focusables.indexOf(document.activeElement as HTMLElement);
});
// After Tab from last, focus should be on first (index 0)
expect(activeAfterTab).toBe(0);
// Shift+Tab from first → should wrap to last
await page.keyboard.press('Shift+Tab');
const activeAfterShiftTab = await dialog.evaluate((el) => {
const focusables = Array.from(el.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
));
return focusables.indexOf(document.activeElement as HTMLElement);
});
// After Shift+Tab from first, focus should be on last
expect(activeAfterShiftTab).toBe(focusableCount - 1);
await deleteSimulation(redteamToken, sim.id);
});
});