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>
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);
|
||
});
|
||
});
|