Files
mimic/e2e/tests/us21-tactic-selection.spec.ts
Knacky 43ab7073f1 test(e2e): un-skip AC-21.6 — backend matrix fix landed
Remove test.fail annotation from AC-21.6 "Apply from modal includes
tactic in result". GET /api/mitre/matrix now returns tactic_id in TA-format
("TA0007") so the PATCH succeeds and the tactic chip appears.

Update button selector in both AC-21.6 tests from title*="discovery"
to title*="TA0007" to match the fixed matrix response format.

Suite: 158 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:38:17 +02:00

292 lines
12 KiB
TypeScript
Raw Permalink 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 — 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);
});
});