Four new spec files covering the MITRE multi-technique feature: - us13: API contract (techniques array, dedup, unknown ID → 400, SOC 403, auto-transition) - us14: tag UI (empty state, add/remove auto-save, SimulationList column, order, styling) - us15: matrix modal (tactic tree, layout, select/expand/search, Apply/Cancel/Escape/backdrop, a11y) - us16: sprint 2 regression (workflow, badge, SOC RBAC, picker still works) Updated sprint 2 specs (us8, us10) to use technique_ids array and Quick search button instead of deprecated scalar mitre_technique_id/name fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
/**
|
|
* US-13 — redteam selects multiple MITRE techniques per simulation.
|
|
* Covers AC-13.1 → AC-13.5 (API / data contract focus).
|
|
*/
|
|
import { test, expect } from '@playwright/test';
|
|
import {
|
|
adminToken,
|
|
createEngagement,
|
|
deleteEngagement,
|
|
deleteUserByUsername,
|
|
ensureUser,
|
|
login,
|
|
makeClient,
|
|
} from '../fixtures/api';
|
|
|
|
const REDTEAM_USER = 'us13-redteam';
|
|
const SOC_USER = 'us13-soc';
|
|
const PASS = 'us13-pass-strong';
|
|
|
|
interface Simulation {
|
|
id: number;
|
|
status: string;
|
|
techniques: { id: string; name: string; tactics: string[] }[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
async function createSimulation(
|
|
token: string,
|
|
engagementId: number,
|
|
name = 'US-13 sim',
|
|
): Promise<Simulation> {
|
|
const client = makeClient(token);
|
|
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
|
|
if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`);
|
|
return r.data as Simulation;
|
|
}
|
|
|
|
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
|
await makeClient(token).delete(`/simulations/${simId}`);
|
|
}
|
|
|
|
test.describe('US-13 — multi-technique simulations', () => {
|
|
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-13 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 */
|
|
}
|
|
});
|
|
|
|
test('AC-13.1 — simulation serialisation has techniques array, not scalar MITRE fields', async () => {
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.1 sim');
|
|
|
|
expect(Array.isArray(sim.techniques)).toBe(true);
|
|
expect(sim.techniques).toHaveLength(0);
|
|
expect(sim).not.toHaveProperty('mitre_technique_id');
|
|
expect(sim).not.toHaveProperty('mitre_technique_name');
|
|
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
|
|
test('AC-13.2 — migration: new simulations start with techniques = []', async () => {
|
|
// Migration is tested implicitly: every new simulation created via POST must
|
|
// return techniques: [] (no scalar columns present).
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.2 migration sim');
|
|
expect(sim.techniques).toEqual([]);
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
|
|
test('AC-13.3 — serialisation enriches each technique entry with tactics from bundle', async () => {
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.3 sim');
|
|
const client = makeClient(redteamToken);
|
|
|
|
const r = await client.patch(`/simulations/${sim.id}`, {
|
|
technique_ids: ['T1059', 'T1078'],
|
|
});
|
|
expect(r.status).toBe(200);
|
|
|
|
const techniques: { id: string; name: string; tactics: string[] }[] = r.data.techniques;
|
|
expect(techniques).toHaveLength(2);
|
|
|
|
// Each entry has id, name, and tactics (derived from bundle at serialize time)
|
|
for (const t of techniques) {
|
|
expect(t).toHaveProperty('id');
|
|
expect(t).toHaveProperty('name');
|
|
expect(Array.isArray(t.tactics)).toBe(true);
|
|
expect(t.tactics.length).toBeGreaterThan(0);
|
|
}
|
|
|
|
const t1059 = techniques.find((t) => t.id === 'T1059');
|
|
expect(t1059).toBeTruthy();
|
|
expect(t1059!.name).toBe('Command and Scripting Interpreter');
|
|
expect(t1059!.tactics).toContain('execution');
|
|
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
|
|
test('AC-13.4 — PATCH technique_ids: valid IDs stored, unknown ID → 400', async () => {
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.4 sim');
|
|
const client = makeClient(redteamToken);
|
|
|
|
// Valid IDs
|
|
const rOk = await client.patch(`/simulations/${sim.id}`, {
|
|
technique_ids: ['T1059', 'T1078', 'T1566'],
|
|
});
|
|
expect(rOk.status).toBe(200);
|
|
const ids = (rOk.data.techniques as { id: string }[]).map((t) => t.id);
|
|
expect(ids).toContain('T1059');
|
|
expect(ids).toContain('T1078');
|
|
expect(ids).toContain('T1566');
|
|
|
|
// Unknown ID → 400
|
|
const rBad = await client.patch(`/simulations/${sim.id}`, {
|
|
technique_ids: ['T9999'],
|
|
});
|
|
expect(rBad.status).toBe(400);
|
|
expect(rBad.data.error).toMatch(/unknown technique id.*T9999/i);
|
|
|
|
// Dedup: sending T1059 twice keeps only one entry in order
|
|
const rDedup = await client.patch(`/simulations/${sim.id}`, {
|
|
technique_ids: ['T1059', 'T1078', 'T1059'],
|
|
});
|
|
expect(rDedup.status).toBe(200);
|
|
const dedupIds = (rDedup.data.techniques as { id: string }[]).map((t) => t.id);
|
|
expect(dedupIds).toEqual(['T1059', 'T1078']);
|
|
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
|
|
test('AC-13.4 — SOC PATCH technique_ids → 403', async () => {
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.4 soc block');
|
|
// Advance to review_required so SOC can attempt a patch
|
|
const rtClient = makeClient(redteamToken);
|
|
await rtClient.patch(`/simulations/${sim.id}`, { name: 'trigger' });
|
|
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
|
|
|
const socClient = makeClient(socToken);
|
|
const r = await socClient.patch(`/simulations/${sim.id}`, {
|
|
technique_ids: ['T1059'],
|
|
});
|
|
expect(r.status).toBe(403);
|
|
expect(r.data.error).toMatch(/soc cannot edit redteam fields/i);
|
|
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
|
|
test('AC-13.5 — auto-transition pending→in_progress triggered by non-empty technique_ids', async () => {
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.5 auto-transition');
|
|
expect(sim.status).toBe('pending');
|
|
const client = makeClient(redteamToken);
|
|
|
|
// Non-empty technique_ids → triggers auto-transition
|
|
const r = await client.patch(`/simulations/${sim.id}`, {
|
|
technique_ids: ['T1059'],
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.data.status).toBe('in_progress');
|
|
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
|
|
test('AC-13.5 — empty technique_ids does NOT trigger auto-transition', async () => {
|
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.5 no-trigger');
|
|
expect(sim.status).toBe('pending');
|
|
const client = makeClient(redteamToken);
|
|
|
|
const r = await client.patch(`/simulations/${sim.id}`, {
|
|
technique_ids: [],
|
|
});
|
|
expect(r.status).toBe(200);
|
|
expect(r.data.status).toBe('pending');
|
|
|
|
await deleteSimulation(redteamToken, sim.id);
|
|
});
|
|
});
|