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>
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|