Files
mimic/e2e/tests/us20-matrix-fits-modal.spec.ts
Knacky 5aa839d105 test(e2e): sprint 4 acceptance tests — US-17 to US-23
Add new spec files for US-17 (UI polish), US-18 (done read-only + reopen),
US-19 (engagement auto-status), US-20 (matrix fits modal), US-21 (tactic
selection), US-22 (MITRE input redesign), US-23 (dark mode).

Adapt sprint 2/3 specs for sprint 4 UI renames: matrix icon button replaces
text buttons, inline search replaces Quick Search, Save replaces Save Red Team,
New replaces New Engagement, topbar uses bg-slab tokens, Apply N item(s) replaces
Apply N technique(s), done→review_required transition now valid (Reopen flow).

Mark AC-21.6 Apply-from-modal as test.fail: known defect where /api/mitre/matrix
returns slug tactic IDs but PATCH /simulations/:id expects TA-format IDs.

Final result: 156 passed, 0 failed (1 expected failure via test.fail).

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

180 lines
6.4 KiB
TypeScript
Raw 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);
});
});