Files
mimic/e2e/tests/us21-tactic-selection.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

298 lines
12 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-21 — Tactic selection (TA-id tags).
* Covers AC-21.4 → AC-21.7 (API + UI).
* AC-21.1/2/3 (model + migration + serialization) tested via API assertions.
*/
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 = 'us21-redteam';
const SOC_USER = 'us21-soc';
const PASS = 'us21-pass-strong';
interface Simulation { id: number; status: string; tactics: { id: string; name: string }[]; [key: string]: unknown; }
async function createSimulation(token: string, engagementId: number, name = 'US-21 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-21 — tactic selection', () => {
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-21 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 */ }
});
// AC-21.1/2/3 — model + serialization
test('AC-21.3 — new simulation has tactics=[] in serialisation', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.3 empty');
expect(Array.isArray(sim.tactics)).toBe(true);
expect(sim.tactics).toHaveLength(0);
await deleteSimulation(redteamToken, sim.id);
});
// AC-21.4 — PATCH tactic_ids validation
test('AC-21.4 — PATCH tactic_ids: valid TA-id stored, enriched name in response', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 valid');
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
tactic_ids: ['TA0007', 'TA0001'],
});
expect(r.status).toBe(200);
expect(Array.isArray(r.data.tactics)).toBe(true);
expect(r.data.tactics).toHaveLength(2);
const disc = r.data.tactics.find((t: { id: string }) => t.id === 'TA0007');
expect(disc).toBeTruthy();
expect(disc.name).toBe('Discovery');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-21.4 — PATCH tactic_ids: unknown TA-id → 400', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 unknown');
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
tactic_ids: ['TA9999'],
});
expect(r.status).toBe(400);
expect(r.data.error).toMatch(/unknown tactic id.*TA9999/i);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-21.4 — PATCH tactic_ids: dedup preserves order', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 dedup');
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
tactic_ids: ['TA0007', 'TA0001', 'TA0007'],
});
expect(r.status).toBe(200);
const ids = r.data.tactics.map((t: { id: string }) => t.id);
expect(ids).toEqual(['TA0007', 'TA0001']);
await deleteSimulation(redteamToken, sim.id);
});
// AC-21.5 — SOC gate + auto-transition
test('AC-21.5 — SOC cannot PATCH tactic_ids → 403', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 soc block');
// Advance to review_required so SOC has access
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' });
await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
expect(r.status).toBe(403);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-21.5 — non-empty tactic_ids triggers auto-transition pending→in_progress', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 auto-transition');
expect(sim.status).toBe('pending');
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
expect(r.status).toBe(200);
expect(r.data.status).toBe('in_progress');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-21.5 — empty tactic_ids does NOT trigger auto-transition', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 no-trigger');
expect(sim.status).toBe('pending');
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: [] });
expect(r.status).toBe(200);
expect(r.data.status).toBe('pending');
await deleteSimulation(redteamToken, sim.id);
});
// AC-21.6 — matrix modal tactic header clickable
test('AC-21.6 — clicking tactic header in modal toggles tactic selection', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 tactic click');
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 });
// Wait for matrix to load — matrix returns slug IDs, tactic header title contains "discovery"
const discoveryHeader = dialog.locator('button[title*="discovery"]').first();
await expect(discoveryHeader).toBeVisible({ timeout: 10_000 });
await discoveryHeader.click();
// Apply button shows at least 1 selection (the tactic)
await expect(dialog.getByRole('button', { name: /apply \d+/i })).toBeVisible();
// Click again to deselect
await discoveryHeader.click();
await deleteSimulation(redteamToken, sim.id);
});
test.fail('AC-21.6 — Apply from modal includes tactic in result (auto-save)', async ({
page,
context,
}) => {
// KNOWN DEFECT: /api/mitre/matrix returns tactic_id as slug ("discovery") but
// PATCH /api/simulations/:id expects TA-format ("TA0007"). When a tactic is
// selected via the matrix modal and Apply is clicked, the PATCH body contains
// tactic_ids: ["discovery"] which the backend rejects with "unknown tactic id".
// Fix owner: backend-builder (matrix endpoint must return TA-format tactic IDs)
// OR frontend-builder (modal must map slug → TA format before saving).
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 apply tactic');
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 });
// Wait for matrix to load — tactic header title uses slug: "Discovery (discovery)..."
const discoveryBtn = dialog.locator('button[title*="discovery"]').first();
await expect(discoveryBtn).toBeVisible({ timeout: 10_000 });
await discoveryBtn.click();
const applyBtn = dialog.getByRole('button', { name: /apply \d+/i });
await expect(applyBtn).toBeVisible();
await applyBtn.click();
// Modal closes
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
// This assertion fails because PATCH with slug ID returns 400
await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible({ timeout: 8_000 });
await expect(page.getByTestId('techniques-tag-list')).toContainText('TA0007');
await deleteSimulation(redteamToken, sim.id);
});
// AC-21.7 — tactic chips in MitreTechniquesField
test('AC-21.7 — tactic chips display TA-id and have × for removal', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 tactic chip');
// Seed via API
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
const tacticTag = page.getByTestId('mitre-tactic-tag');
await expect(tacticTag).toBeVisible();
await expect(tacticTag).toContainText('TA0007');
// Title attribute has id — name
const title = await tacticTag.getAttribute('title');
expect(title).toMatch(/TA0007/);
expect(title).toMatch(/Discovery/);
// × button for removal
await expect(page.getByRole('button', { name: /remove TA0007/i })).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-21.7 — removing tactic chip auto-saves', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 remove tactic');
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible();
await page.getByRole('button', { name: /remove TA0007/i }).click();
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId('mitre-tactic-tag')).not.toBeVisible({ timeout: 3_000 });
await deleteSimulation(redteamToken, sim.id);
});
test('AC-21.7 — tactic chips visually distinct from technique chips (different class)', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 style');
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
tactic_ids: ['TA0007'],
technique_ids: ['T1059'],
});
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
const tacticTag = page.getByTestId('mitre-tactic-tag');
const techTag = page.getByTestId('mitre-technique-tag');
await expect(tacticTag).toBeVisible();
await expect(techTag).toBeVisible();
// Tactic: bg-primary (filled) vs technique: bg-primary-soft
const tacticCls = await tacticTag.getAttribute('class');
const techCls = await techTag.getAttribute('class');
expect(tacticCls).toMatch(/bg-primary/);
// Technique should NOT have the solid bg-primary (just bg-primary-soft)
expect(techCls).toMatch(/bg-primary-soft/);
// They should differ visually
expect(tacticCls).not.toBe(techCls);
await deleteSimulation(redteamToken, sim.id);
});
// NIT code-reviewer: +N suffix in SimulationList MITRE column
test('AC-21.7 — SimulationList MITRE column shows first id + "+N" for mixed tactics+techniques', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 +N suffix');
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
tactic_ids: ['TA0007'],
technique_ids: ['T1059', 'T1078'],
});
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// The row for this simulation should show "TA0007 +2" in the MITRE column
// (1 tactic TA0007 is first, then +2 for T1059 and T1078)
const simRow = page.getByRole('row').filter({ hasText: 'AC-21.7 +N suffix' });
await expect(simRow).toBeVisible({ timeout: 5_000 });
await expect(simRow).toContainText('TA0007 +2');
await deleteSimulation(redteamToken, sim.id);
});
});