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