Files
mimic/e2e/tests/us21-tactic-selection.spec.ts
Knacky 5aa839d105 test(e2e): sprint 4 acceptance tests — US-17 to US-23
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>
2026-05-27 21:27:12 +02:00

275 lines
11 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);
});
});