/** * 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 { 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 { 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 — tactic header title: "Discovery (TA0007) — click to tag this tactic" const discoveryHeader = dialog.locator('button[title*="TA0007"]'); 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('AC-21.6 — Apply from modal includes tactic in result (auto-save)', async ({ page, context, }) => { 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 }); // Tactic header title: "Discovery (TA0007) — click to tag this tactic" const discoveryBtn = dialog.locator('button[title*="TA0007"]'); 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 }); // Tactic chip appears after auto-save 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); }); });