251 lines
9.2 KiB
TypeScript
251 lines
9.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* US-22 — Refonte input MITRE dans le form.
|
|||
|
|
* Covers AC-22.1 → AC-22.5.
|
|||
|
|
* Key change: no "Add technique" / "Quick search" text buttons.
|
|||
|
|
* Instead: inline autocomplete input + grid icon for matrix.
|
|||
|
|
* Chips show T-id only (name in title=).
|
|||
|
|
*/
|
|||
|
|
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 = 'us22-redteam';
|
|||
|
|
const SOC_USER = 'us22-soc';
|
|||
|
|
const PASS = 'us22-pass-strong';
|
|||
|
|
|
|||
|
|
interface Simulation { id: number; [key: string]: unknown; }
|
|||
|
|
|
|||
|
|
async function createSimulation(token: string, engagementId: number, name = 'US-22 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-22 — MITRE input redesign', () => {
|
|||
|
|
let redteamToken: string;
|
|||
|
|
let socToken: string;
|
|||
|
|
let engagementId: number;
|
|||
|
|
|
|||
|
|
test.beforeAll(async () => {
|
|||
|
|
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
|||
|
|
await ensureUser(SOC_USER, PASS, 'soc');
|
|||
|
|
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
|||
|
|
socToken = (await login(SOC_USER, PASS)).token;
|
|||
|
|
const eng = await createEngagement(redteamToken, {
|
|||
|
|
name: 'US-22 Engagement',
|
|||
|
|
start_date: '2026-01-01',
|
|||
|
|
});
|
|||
|
|
engagementId = eng.id;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test.afterAll(async () => {
|
|||
|
|
try {
|
|||
|
|
const tok = await adminToken();
|
|||
|
|
await deleteEngagement(tok, engagementId);
|
|||
|
|
for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u);
|
|||
|
|
} catch { /* noop */ }
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('AC-22.1 — layout: inline autocomplete input + matrix icon present, NO text buttons', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 layout');
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, redteamToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
// Matrix icon button present
|
|||
|
|
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
|||
|
|
|
|||
|
|
// Search input placeholder visible (inline autocomplete)
|
|||
|
|
await expect(page.getByText(/search technique/i)).toBeVisible();
|
|||
|
|
|
|||
|
|
// No old-style text buttons
|
|||
|
|
await expect(page.getByRole('button', { name: /add technique/i })).not.toBeVisible();
|
|||
|
|
await expect(page.getByRole('button', { name: /quick search/i })).not.toBeVisible();
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('AC-22.1 — inline autocomplete: click input shows combobox, type shows dropdown', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 autocomplete');
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, redteamToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
// Click the fake-input placeholder to reveal the combobox
|
|||
|
|
await page.getByText(/search technique/i).click();
|
|||
|
|
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
|||
|
|
await expect(picker).toBeVisible();
|
|||
|
|
|
|||
|
|
// Type to get results
|
|||
|
|
await picker.fill('T1059');
|
|||
|
|
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
|||
|
|
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
|||
|
|
|
|||
|
|
// Select via keyboard
|
|||
|
|
await picker.press('ArrowDown');
|
|||
|
|
await picker.press('Enter');
|
|||
|
|
|
|||
|
|
// Tag appears
|
|||
|
|
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059', { timeout: 5_000 });
|
|||
|
|
// Auto-save toast
|
|||
|
|
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('AC-22.1 — matrix icon opens MitreMatrixModal', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 matrix icon');
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, redteamToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
await page.getByLabel(/open mitre matrix/i).click();
|
|||
|
|
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
|||
|
|
await expect(page.getByRole('dialog')).toContainText(/mitre att&?ck matrix/i);
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// AC-22.2 — chips show T-id only, name in title
|
|||
|
|
test('AC-22.2 — technique chips display T-id only, name in title= attribute', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 chip format');
|
|||
|
|
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, redteamToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
const chip = page.getByTestId('mitre-technique-tag').first();
|
|||
|
|
await expect(chip).toBeVisible();
|
|||
|
|
|
|||
|
|
// Text content is T-id only
|
|||
|
|
const text = await chip.textContent();
|
|||
|
|
expect(text?.trim()).toMatch(/^T1059/);
|
|||
|
|
// Must NOT contain the full name inline
|
|||
|
|
expect(text).not.toMatch(/Command and Scripting Interpreter/);
|
|||
|
|
|
|||
|
|
// Name appears in title attribute
|
|||
|
|
const title = await chip.getAttribute('title');
|
|||
|
|
expect(title).toMatch(/T1059/);
|
|||
|
|
expect(title).toMatch(/Command and Scripting Interpreter/);
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('AC-22.2 — tactic chips display TA-id only, name in title= attribute', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 tactic chip format');
|
|||
|
|
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, redteamToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
const chip = page.getByTestId('mitre-tactic-tag').first();
|
|||
|
|
await expect(chip).toBeVisible();
|
|||
|
|
|
|||
|
|
const text = await chip.textContent();
|
|||
|
|
expect(text?.trim()).toMatch(/^TA0007/);
|
|||
|
|
expect(text).not.toMatch(/Discovery/);
|
|||
|
|
|
|||
|
|
const title = await chip.getAttribute('title');
|
|||
|
|
expect(title).toMatch(/TA0007/);
|
|||
|
|
expect(title).toMatch(/Discovery/);
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// AC-22.4 — empty state
|
|||
|
|
test('AC-22.4 — empty state: "No techniques selected" visible, input and matrix icon still shown', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.4 empty');
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, redteamToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
// Empty state message
|
|||
|
|
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
|||
|
|
|
|||
|
|
// Input and matrix icon still present in non-disabled mode
|
|||
|
|
await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible();
|
|||
|
|
await expect(page.getByText(/search technique/i)).toBeVisible();
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// AC-22.5 — read-only mode
|
|||
|
|
test('AC-22.5 — SOC on in_progress sim: chips visible (no ×), input + matrix icon hidden', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 soc readonly');
|
|||
|
|
// Add a technique and advance to review_required for SOC access
|
|||
|
|
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
|||
|
|
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, socToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
// Chip is visible
|
|||
|
|
await expect(page.getByTestId('mitre-technique-tag')).toBeVisible();
|
|||
|
|
|
|||
|
|
// No × remove button (read-only for SOC on technique chips)
|
|||
|
|
await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible();
|
|||
|
|
|
|||
|
|
// Matrix icon and input hidden in disabled mode
|
|||
|
|
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
|||
|
|
await expect(page.getByText(/search technique/i)).not.toBeVisible();
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('AC-22.5 — done sim: all chips read-only, no input', async ({
|
|||
|
|
page,
|
|||
|
|
context,
|
|||
|
|
}) => {
|
|||
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 done readonly');
|
|||
|
|
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] });
|
|||
|
|
// Drive to done
|
|||
|
|
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
|||
|
|
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' });
|
|||
|
|
|
|||
|
|
await seedTokenInStorage(context, redteamToken);
|
|||
|
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
|||
|
|
|
|||
|
|
// Chip visible
|
|||
|
|
await expect(page.getByTestId('mitre-technique-tag')).toBeVisible();
|
|||
|
|
// No × remove
|
|||
|
|
await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible();
|
|||
|
|
// No matrix icon
|
|||
|
|
await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible();
|
|||
|
|
|
|||
|
|
await deleteSimulation(redteamToken, sim.id);
|
|||
|
|
});
|
|||
|
|
});
|