feat: sprint 3 — multi-technique simulations + MITRE matrix modal #6
@@ -128,7 +128,7 @@ test.describe('US-10 — MITRE autocomplete', () => {
|
|||||||
expect(subtech.name).toBeTruthy();
|
expect(subtech.name).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection fills both fields', async ({
|
test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection appends tag', async ({
|
||||||
page,
|
page,
|
||||||
context,
|
context,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -137,12 +137,14 @@ test.describe('US-10 — MITRE autocomplete', () => {
|
|||||||
await seedTokenInStorage(context, redteamToken);
|
await seedTokenInStorage(context, redteamToken);
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
// Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search"
|
||||||
|
await page.getByRole('button', { name: /quick search/i }).click();
|
||||||
|
|
||||||
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||||
await expect(picker).toBeVisible();
|
await expect(picker).toBeVisible();
|
||||||
|
|
||||||
// Type a query — after debounce (200ms) the dropdown opens with results
|
// Type a query — after debounce (200ms) the dropdown opens with results
|
||||||
await picker.fill('T1059');
|
await picker.fill('T1059');
|
||||||
// Wait for dropdown to appear (debounce + network)
|
|
||||||
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
||||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
@@ -157,13 +159,14 @@ test.describe('US-10 — MITRE autocomplete', () => {
|
|||||||
await picker.press('ArrowDown');
|
await picker.press('ArrowDown');
|
||||||
await picker.press('Enter');
|
await picker.press('Enter');
|
||||||
|
|
||||||
// After selection the dropdown closes and input shows the selected value
|
// Sprint 3: after selection the picker resets (one-shot append mode).
|
||||||
|
// The tag T1059 should appear in the techniques field.
|
||||||
await expect(listbox).not.toBeVisible();
|
await expect(listbox).not.toBeVisible();
|
||||||
const inputValue = await picker.inputValue();
|
await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 });
|
||||||
expect(inputValue).toMatch(/T1059/);
|
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059');
|
||||||
expect(inputValue).toMatch(/—/);
|
|
||||||
|
|
||||||
// Escape closes the dropdown
|
// Escape closes the dropdown (re-open picker to test Escape)
|
||||||
|
await page.getByRole('button', { name: /quick search/i }).click();
|
||||||
await picker.fill('T1');
|
await picker.fill('T1');
|
||||||
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||||
await picker.press('Escape');
|
await picker.press('Escape');
|
||||||
|
|||||||
195
e2e/tests/us13-multi-techniques.spec.ts
Normal file
195
e2e/tests/us13-multi-techniques.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
260
e2e/tests/us14-techniques-tags.spec.ts
Normal file
260
e2e/tests/us14-techniques-tags.spec.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
/**
|
||||||
|
* US-14 — redteam views and removes techniques as tags.
|
||||||
|
* Covers AC-14.1 → AC-14.5 (UI tags + auto-save).
|
||||||
|
*/
|
||||||
|
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 = 'us14-redteam';
|
||||||
|
const SOC_USER = 'us14-soc';
|
||||||
|
const PASS = 'us14-pass-strong';
|
||||||
|
|
||||||
|
interface Simulation {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
techniques: { id: string; name: string; tactics: string[] }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSimulation(
|
||||||
|
token: string,
|
||||||
|
engagementId: number,
|
||||||
|
name = 'US-14 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}`);
|
||||||
|
return r.data as Simulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchTechniques(
|
||||||
|
token: string,
|
||||||
|
simId: number,
|
||||||
|
techniqueIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const client = makeClient(token);
|
||||||
|
const r = await client.patch(`/simulations/${simId}`, { technique_ids: techniqueIds });
|
||||||
|
if (r.status !== 200) throw new Error(`patch techniques: ${r.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSimulation(token: string, simId: number): Promise<void> {
|
||||||
|
await makeClient(token).delete(`/simulations/${simId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-14 — technique tags UI', () => {
|
||||||
|
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-14 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-14.1 — MitreTechniquesField shows tags, Add technique + Quick search buttons, empty state', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.1 empty');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
// Empty state message visible when no techniques
|
||||||
|
await expect(
|
||||||
|
page.getByText(/no techniques selected.*matrix.*quick search/i),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Add technique and Quick search buttons present
|
||||||
|
await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-14.1 — tags show id + name + × button', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.1 tags');
|
||||||
|
await patchTechniques(redteamToken, sim.id, ['T1059', 'T1078']);
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
const tagList = page.getByTestId('techniques-tag-list');
|
||||||
|
await expect(tagList).toBeVisible();
|
||||||
|
|
||||||
|
// Both techniques appear as tags
|
||||||
|
const tags = tagList.getByTestId('mitre-technique-tag');
|
||||||
|
await expect(tags).toHaveCount(2);
|
||||||
|
|
||||||
|
// First tag contains T1059
|
||||||
|
await expect(tagList).toContainText('T1059');
|
||||||
|
await expect(tagList).toContainText('T1078');
|
||||||
|
|
||||||
|
// × buttons present for redteam
|
||||||
|
await expect(page.getByRole('button', { name: /remove T1059/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /remove T1078/i })).toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-14.2 — removing a tag triggers auto-save PATCH (toast + tag disappears)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.2 remove tag');
|
||||||
|
await patchTechniques(redteamToken, sim.id, ['T1059', 'T1078']);
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
// Wait for tags to render
|
||||||
|
await expect(page.getByTestId('techniques-tag-list')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /remove T1059/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Click × on T1059
|
||||||
|
await page.getByRole('button', { name: /remove T1059/i }).click();
|
||||||
|
|
||||||
|
// Toast "Techniques updated" should appear
|
||||||
|
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// T1059 tag is gone, T1078 remains
|
||||||
|
await expect(page.getByTestId('techniques-tag-list')).not.toContainText('T1059');
|
||||||
|
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1078');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-14.2 — Quick search: selecting technique appends as tag + auto-save', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.2 quick search');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /quick search/i }).click();
|
||||||
|
|
||||||
|
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||||
|
await picker.fill('T1059');
|
||||||
|
|
||||||
|
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
||||||
|
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await picker.press('ArrowDown');
|
||||||
|
await picker.press('Enter');
|
||||||
|
|
||||||
|
// Tag appears and auto-save toast
|
||||||
|
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-14.3 — SimulationList MITRE column shows first id + +N counter', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const simEmpty = await createSimulation(redteamToken, engagementId, 'AC-14.3 empty');
|
||||||
|
const simOne = await createSimulation(redteamToken, engagementId, 'AC-14.3 one');
|
||||||
|
await patchTechniques(redteamToken, simOne.id, ['T1059']);
|
||||||
|
const simThree = await createSimulation(redteamToken, engagementId, 'AC-14.3 three');
|
||||||
|
await patchTechniques(redteamToken, simThree.id, ['T1059', 'T1078', 'T1566']);
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}`);
|
||||||
|
|
||||||
|
// Empty → shows "—"
|
||||||
|
const rowEmpty = page.getByRole('row', { name: /AC-14.3 empty/i });
|
||||||
|
await expect(rowEmpty).toContainText('—');
|
||||||
|
|
||||||
|
// One technique → shows id only
|
||||||
|
const rowOne = page.getByRole('row', { name: /AC-14.3 one/i });
|
||||||
|
await expect(rowOne).toContainText('T1059');
|
||||||
|
|
||||||
|
// Three techniques → shows "T1059 +2"
|
||||||
|
const rowThree = page.getByRole('row', { name: /AC-14.3 three/i });
|
||||||
|
await expect(rowThree).toContainText('T1059');
|
||||||
|
await expect(rowThree).toContainText('+2');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, simEmpty.id);
|
||||||
|
await deleteSimulation(redteamToken, simOne.id);
|
||||||
|
await deleteSimulation(redteamToken, simThree.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-14.4 — order of tags preserved between read and write', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.4 order');
|
||||||
|
// Patch with specific order T1566 → T1059 → T1078
|
||||||
|
await patchTechniques(redteamToken, sim.id, ['T1566', 'T1059', 'T1078']);
|
||||||
|
|
||||||
|
// Verify via API
|
||||||
|
const r = await makeClient(redteamToken).get(`/simulations/${sim.id}`);
|
||||||
|
const ids = (r.data.techniques as { id: string }[]).map((t) => t.id);
|
||||||
|
expect(ids).toEqual(['T1566', 'T1059', 'T1078']);
|
||||||
|
|
||||||
|
// Verify via UI — tags appear in insertion order
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
const tags = page.getByTestId('mitre-technique-tag');
|
||||||
|
await expect(tags).toHaveCount(3);
|
||||||
|
await expect(tags.nth(0)).toContainText('T1566');
|
||||||
|
await expect(tags.nth(1)).toContainText('T1059');
|
||||||
|
await expect(tags.nth(2)).toContainText('T1078');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-14.5 — tags styled with DESIGN.md tokens (bg-primary-soft, rounded-full)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.5 style');
|
||||||
|
await patchTechniques(redteamToken, sim.id, ['T1059']);
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
const tag = page.getByTestId('mitre-technique-tag').first();
|
||||||
|
await expect(tag).toBeVisible();
|
||||||
|
|
||||||
|
// Verify styling classes are present on the tag element
|
||||||
|
const cls = await tag.getAttribute('class');
|
||||||
|
expect(cls).toMatch(/bg-primary-soft/);
|
||||||
|
expect(cls).toMatch(/rounded-full/);
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
506
e2e/tests/us15-mitre-matrix-modal.spec.ts
Normal file
506
e2e/tests/us15-mitre-matrix-modal.spec.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
/**
|
||||||
|
* US-15 — redteam explores and selects techniques via the MITRE ATT&CK matrix modal.
|
||||||
|
* Covers AC-15.1 → AC-15.5.
|
||||||
|
*/
|
||||||
|
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 = 'us15-redteam';
|
||||||
|
const PASS = 'us15-pass-strong';
|
||||||
|
|
||||||
|
interface Simulation {
|
||||||
|
id: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSimulation(
|
||||||
|
token: string,
|
||||||
|
engagementId: number,
|
||||||
|
name = 'US-15 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-15 — MITRE matrix modal', () => {
|
||||||
|
let redteamToken: string;
|
||||||
|
let engagementId: number;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await ensureUser(REDTEAM_USER, PASS, 'redteam');
|
||||||
|
redteamToken = (await login(REDTEAM_USER, PASS)).token;
|
||||||
|
const eng = await createEngagement(redteamToken, {
|
||||||
|
name: 'US-15 Engagement',
|
||||||
|
start_date: '2026-01-01',
|
||||||
|
});
|
||||||
|
engagementId = eng.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
const tok = await adminToken();
|
||||||
|
await deleteEngagement(tok, engagementId);
|
||||||
|
await deleteUserByUsername(tok, REDTEAM_USER);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.1 — GET /api/mitre/matrix returns tactic tree with correct structure', async () => {
|
||||||
|
const client = makeClient(redteamToken);
|
||||||
|
const r = await client.get('/mitre/matrix');
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(Array.isArray(r.data)).toBe(true);
|
||||||
|
|
||||||
|
// At least the 12 canonical MITRE Enterprise tactics
|
||||||
|
expect(r.data.length).toBeGreaterThanOrEqual(12);
|
||||||
|
|
||||||
|
const first = r.data[0];
|
||||||
|
expect(first).toHaveProperty('tactic_id');
|
||||||
|
expect(first).toHaveProperty('tactic_name');
|
||||||
|
expect(Array.isArray(first.techniques)).toBe(true);
|
||||||
|
|
||||||
|
// First tactic must be "Initial Access" (canonical order)
|
||||||
|
expect(first.tactic_name).toBe('Initial Access');
|
||||||
|
|
||||||
|
// Each technique has id, name, subtechniques array
|
||||||
|
const tech = first.techniques[0];
|
||||||
|
expect(tech).toHaveProperty('id');
|
||||||
|
expect(tech).toHaveProperty('name');
|
||||||
|
expect(Array.isArray(tech.subtechniques)).toBe(true);
|
||||||
|
|
||||||
|
// A technique with known sub-techniques: T1059 is in Execution
|
||||||
|
const execTactic = (r.data as { tactic_name: string; techniques: { id: string; subtechniques: { id: string; name: string }[] }[] }[]).find(
|
||||||
|
(t) => t.tactic_name === 'Execution',
|
||||||
|
);
|
||||||
|
expect(execTactic).toBeTruthy();
|
||||||
|
const t1059 = execTactic!.techniques.find((t) => t.id === 'T1059');
|
||||||
|
expect(t1059).toBeTruthy();
|
||||||
|
expect(t1059!.subtechniques.length).toBeGreaterThan(0);
|
||||||
|
// T1059.001 should be a known sub-technique
|
||||||
|
const sub = t1059!.subtechniques.find((s) => s.id === 'T1059.001');
|
||||||
|
expect(sub).toBeTruthy();
|
||||||
|
expect(sub!.name).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.1 — tactic canonical order is correct (Initial Access first, Impact last)', async () => {
|
||||||
|
const client = makeClient(redteamToken);
|
||||||
|
const r = await client.get('/mitre/matrix');
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
|
||||||
|
const tacticNames = (r.data as { tactic_name: string }[]).map((t) => t.tactic_name);
|
||||||
|
expect(tacticNames[0]).toBe('Initial Access');
|
||||||
|
expect(tacticNames[tacticNames.length - 1]).toBe('Impact');
|
||||||
|
|
||||||
|
// Verify Exfiltration appears before Impact
|
||||||
|
const exfilIdx = tacticNames.indexOf('Exfiltration');
|
||||||
|
const impactIdx = tacticNames.indexOf('Impact');
|
||||||
|
expect(exfilIdx).toBeGreaterThan(-1);
|
||||||
|
expect(exfilIdx).toBeLessThan(impactIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.2 — modal layout: columns per tactic, tactic header, technique cells', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 layout');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
// Open the matrix modal via "Add technique"
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Modal title
|
||||||
|
await expect(dialog.getByRole('heading', { name: /mitre att&?ck matrix/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Search / filter input present and focused
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
await expect(searchInput).toBeVisible();
|
||||||
|
|
||||||
|
// At least one tactic column visible — check for "Initial Access" and "Execution"
|
||||||
|
await expect(dialog).toContainText('Initial Access');
|
||||||
|
await expect(dialog).toContainText('Execution');
|
||||||
|
|
||||||
|
// T1059 technique cell visible in Execution column
|
||||||
|
await expect(dialog).toContainText('T1059');
|
||||||
|
|
||||||
|
// Cancel button present
|
||||||
|
await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.2 — selecting technique updates Apply button counter', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 select');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Use search to isolate T1059 so there's only the label button visible
|
||||||
|
// The chevron has aria-label "Expand T1059"; we use filter to get the label button
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
await searchInput.fill('T1059');
|
||||||
|
// Wait for filter to apply — only T1059 and its subtechniques should be visible
|
||||||
|
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||||
|
|
||||||
|
// The label button (selection) is the one containing the technique name text
|
||||||
|
// Filter explicitly excludes the chevron (aria-label="Expand T1059")
|
||||||
|
const techLabelBtn = dialog
|
||||||
|
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||||
|
.first();
|
||||||
|
await techLabelBtn.click();
|
||||||
|
|
||||||
|
// Apply button should now show count = 1
|
||||||
|
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Click again to deselect
|
||||||
|
await techLabelBtn.click();
|
||||||
|
|
||||||
|
// When 0 selected and no initial selection: footer shows disabled "Clear all"
|
||||||
|
await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible();
|
||||||
|
await expect(dialog.getByRole('button', { name: /apply \d+ technique/i })).not.toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.2 — subtechnique expand/collapse via chevron', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 expand');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Expand T1059 via chevron button (▸ Expand T1059)
|
||||||
|
const expandBtn = dialog.getByRole('button', { name: /expand T1059/i });
|
||||||
|
await expect(expandBtn).toBeVisible();
|
||||||
|
await expandBtn.click();
|
||||||
|
|
||||||
|
// Sub-technique T1059.001 should now be visible
|
||||||
|
await expect(dialog).toContainText('T1059.001');
|
||||||
|
|
||||||
|
// Collapse it
|
||||||
|
const collapseBtn = dialog.getByRole('button', { name: /collapse T1059/i });
|
||||||
|
await collapseBtn.click();
|
||||||
|
await expect(dialog).not.toContainText('T1059.001');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.2 — search filters techniques, auto-expands parent when sub matches', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 search');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
|
||||||
|
// Search by sub-technique ID — parent should auto-expand
|
||||||
|
await searchInput.fill('T1059.001');
|
||||||
|
await expect(dialog).toContainText('T1059.001');
|
||||||
|
|
||||||
|
// Search by name (case-insensitive)
|
||||||
|
await searchInput.fill('powershell');
|
||||||
|
await expect(dialog).toContainText('PowerShell');
|
||||||
|
|
||||||
|
// Clear search — techniques come back
|
||||||
|
await searchInput.fill('');
|
||||||
|
await expect(dialog).toContainText('T1059');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.2 — tactic header shows selected count when techniques selected', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 counter');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Initially no "selected" counter visible
|
||||||
|
await expect(dialog).not.toContainText('1 selected');
|
||||||
|
|
||||||
|
// Use search to isolate T1059 so we can click the label button, not the chevron
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
await searchInput.fill('T1059');
|
||||||
|
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||||
|
|
||||||
|
// The label button contains the technique name; the chevron has aria-label="Expand T1059"
|
||||||
|
await dialog
|
||||||
|
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Tactic header for Execution should now show "1 selected"
|
||||||
|
await expect(dialog).toContainText('1 selected');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.3 — Apply auto-saves techniques and closes modal', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 apply');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
|
||||||
|
// Select T1059 via label button (not chevron) — filter to isolate
|
||||||
|
await searchInput.fill('T1059');
|
||||||
|
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||||
|
await dialog
|
||||||
|
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Select T1566 (Phishing) — no subtechniques, so only one button
|
||||||
|
await searchInput.fill('T1566');
|
||||||
|
await expect(dialog).toContainText('T1566');
|
||||||
|
await dialog.getByRole('button', { name: /phishing/i }).first().click();
|
||||||
|
|
||||||
|
// Apply (2 techniques selected)
|
||||||
|
const applyBtn = dialog.getByRole('button', { name: /apply \d+ technique/i });
|
||||||
|
await expect(applyBtn).toBeVisible();
|
||||||
|
await applyBtn.click();
|
||||||
|
|
||||||
|
// Modal closes
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Auto-save toast appears
|
||||||
|
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Tags appear in the tag list
|
||||||
|
const tagList = page.getByTestId('techniques-tag-list');
|
||||||
|
await expect(tagList).toContainText('T1059');
|
||||||
|
await expect(tagList).toContainText('T1566');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.3 — modal receives current selection as initial state', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 initial');
|
||||||
|
|
||||||
|
// Seed T1059 via API before opening the UI
|
||||||
|
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||||
|
technique_ids: ['T1059'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Apply button should already show 1 technique (from initial selection)
|
||||||
|
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Cancel to discard
|
||||||
|
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.3 — Cancel discards local changes (no PATCH fired)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 cancel');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Select a technique via label button (filter to avoid hitting chevron)
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
await searchInput.fill('T1059');
|
||||||
|
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||||
|
await dialog
|
||||||
|
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Cancel instead of Apply
|
||||||
|
await dialog.getByRole('button', { name: /^cancel$/i }).click();
|
||||||
|
await expect(dialog).not.toBeVisible();
|
||||||
|
|
||||||
|
// No toast, no PATCH fired — empty state message still visible (0 techniques)
|
||||||
|
await expect(page.getByText(/techniques updated/i)).not.toBeVisible();
|
||||||
|
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.4 — Escape key closes modal (Cancel behaviour)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 escape');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Select something to confirm Cancel semantics on Escape
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
await searchInput.fill('T1059');
|
||||||
|
await expect(dialog).toContainText('Command and Scripting Interpreter');
|
||||||
|
await dialog
|
||||||
|
.getByRole('button', { name: /command and scripting interpreter/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
// No PATCH fired — empty state message still visible (no techniques added)
|
||||||
|
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.4 — backdrop click closes modal (Cancel behaviour)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 backdrop');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Click outside the modal container (top-left corner of viewport, which is the backdrop)
|
||||||
|
await page.mouse.click(5, 5);
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.5 — a11y: role=dialog + aria-labelledby, search input focused on open', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 a11y');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// role="dialog" is set (getByRole('dialog') already asserts this)
|
||||||
|
// aria-modal attribute
|
||||||
|
const ariaModal = await dialog.getAttribute('aria-modal');
|
||||||
|
expect(ariaModal).toBe('true');
|
||||||
|
|
||||||
|
// aria-labelledby points to the modal title
|
||||||
|
const labelledBy = await dialog.getAttribute('aria-labelledby');
|
||||||
|
expect(labelledBy).toBeTruthy();
|
||||||
|
const titleEl = page.locator(`#${labelledBy}`);
|
||||||
|
await expect(titleEl).toContainText(/mitre att&?ck matrix/i);
|
||||||
|
|
||||||
|
// Search input is focused immediately after open
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
await expect(searchInput).toBeFocused({ timeout: 2_000 });
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-15.5 — a11y: Tab wraps within modal (focus trap)', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 focus-trap');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /add technique/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Tab through enough elements to hit the wrap point
|
||||||
|
// (we don't know exact count, but Shift+Tab from the first focused element
|
||||||
|
// should stay inside the modal — not land outside)
|
||||||
|
const searchInput = dialog.getByLabel(/filter techniques/i);
|
||||||
|
await expect(searchInput).toBeFocused({ timeout: 2_000 });
|
||||||
|
|
||||||
|
// Shift+Tab from the first element (search) should wrap to Cancel or Apply
|
||||||
|
await page.keyboard.press('Shift+Tab');
|
||||||
|
// The focused element must still be inside the dialog
|
||||||
|
const focusedOutsideDialog = await page.evaluate(() => {
|
||||||
|
const dialog = document.querySelector('[role="dialog"]');
|
||||||
|
return dialog ? !dialog.contains(document.activeElement) : true;
|
||||||
|
});
|
||||||
|
expect(focusedOutsideDialog).toBe(false);
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
282
e2e/tests/us16-regression-sprint2.spec.ts
Normal file
282
e2e/tests/us16-regression-sprint2.spec.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* US-16 — regression: sprint 2 features still work under the sprint 3 model.
|
||||||
|
* Covers AC-16.1 → AC-16.3.
|
||||||
|
*
|
||||||
|
* This file re-exercises critical sprint 2 ACs that are most likely to break
|
||||||
|
* due to the scalar→array MITRE migration:
|
||||||
|
* - Auto-transition pending→in_progress (AC-8.2 / AC-13.5)
|
||||||
|
* - Manual workflow transitions + badge update (AC-11.x)
|
||||||
|
* - SOC field-level RBAC (AC-9.x)
|
||||||
|
* - MitreTechniquePicker still accessible via Quick Search (AC-16.2)
|
||||||
|
*/
|
||||||
|
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 = 'us16-redteam';
|
||||||
|
const SOC_USER = 'us16-soc';
|
||||||
|
const PASS = 'us16-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-16 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-16 — sprint 2 regression', () => {
|
||||||
|
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-16 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-16.1 — workflow sprint 2: auto-transition, manual transitions, SOC RBAC
|
||||||
|
|
||||||
|
test('AC-16.1 — auto-transition pending→in_progress triggered by PATCH with non-empty technique_ids', async () => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-transition');
|
||||||
|
expect(sim.status).toBe('pending');
|
||||||
|
|
||||||
|
const r = await makeClient(redteamToken).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-16.1 — auto-transition triggered by non-technique redteam PATCH (name)', async () => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-name');
|
||||||
|
expect(sim.status).toBe('pending');
|
||||||
|
|
||||||
|
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||||
|
name: 'trigger by name',
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.data.status).toBe('in_progress');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-16.1 — manual transition in_progress→review_required→closed', async () => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 workflow');
|
||||||
|
const rtClient = makeClient(redteamToken);
|
||||||
|
|
||||||
|
// Trigger in_progress
|
||||||
|
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 workflow' });
|
||||||
|
|
||||||
|
// in_progress → review_required
|
||||||
|
const r1 = await rtClient.post(`/simulations/${sim.id}/transition`, {
|
||||||
|
to: 'review_required',
|
||||||
|
});
|
||||||
|
expect(r1.status).toBe(200);
|
||||||
|
expect(r1.data.status).toBe('review_required');
|
||||||
|
|
||||||
|
// review_required → done
|
||||||
|
const r2 = await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'done' });
|
||||||
|
expect(r2.status).toBe(200);
|
||||||
|
expect(r2.data.status).toBe('done');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-16.1 — SOC cannot PATCH technique_ids (403)', async () => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc block');
|
||||||
|
const rtClient = makeClient(redteamToken);
|
||||||
|
|
||||||
|
// Advance to review_required so SOC has access
|
||||||
|
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc block' });
|
||||||
|
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||||
|
|
||||||
|
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, {
|
||||||
|
technique_ids: ['T1059'],
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(403);
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-16.1 — SOC can PATCH soc_comment without affecting status', async () => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc comment');
|
||||||
|
const rtClient = makeClient(redteamToken);
|
||||||
|
|
||||||
|
// Advance to review_required
|
||||||
|
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc comment' });
|
||||||
|
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
|
||||||
|
|
||||||
|
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, {
|
||||||
|
soc_comment: 'Looks good, close it.',
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.data.status).toBe('review_required');
|
||||||
|
expect(r.data.soc_comment).toBe('Looks good, close it.');
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-16.1 — SOC cannot transition pending simulation', async () => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc transition');
|
||||||
|
|
||||||
|
const r = await makeClient(socToken).post(`/simulations/${sim.id}/transition`, {
|
||||||
|
to: 'review_required',
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(403);
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-16.1 — workflow badge updates in UI without page reload', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 badge');
|
||||||
|
const rtClient = makeClient(redteamToken);
|
||||||
|
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 badge' });
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
const badge = page.getByTestId('simulation-status-badge');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toHaveAttribute('data-status', 'in_progress');
|
||||||
|
|
||||||
|
// Trigger transition via button
|
||||||
|
await page.getByRole('button', { name: /mark for review/i }).click();
|
||||||
|
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-16.2 — MitreTechniquePicker still accessible via Quick Search (clean rewrite onSelect)
|
||||||
|
|
||||||
|
test('AC-16.2 — MitreTechniquePicker accessible via Quick Search, appends tag on selection', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.2 picker');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
// Quick Search button reveals the picker
|
||||||
|
await page.getByRole('button', { name: /quick search/i }).click();
|
||||||
|
const picker = page.getByRole('combobox', { name: /mitre technique/i });
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
|
||||||
|
// Type to search
|
||||||
|
await picker.fill('T1078');
|
||||||
|
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
|
||||||
|
await expect(listbox).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Keyboard select
|
||||||
|
await picker.press('ArrowDown');
|
||||||
|
await picker.press('Enter');
|
||||||
|
|
||||||
|
// Tag appears (onSelect one-shot mode — appends to list)
|
||||||
|
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1078', {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-save toast
|
||||||
|
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-16.3 — no sprint 1/2 e2e broken: spot-check key assertions with new model
|
||||||
|
|
||||||
|
test('AC-16.3 — simulation serialisation has techniques array (not scalar MITRE fields)', async () => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 schema');
|
||||||
|
|
||||||
|
// New schema: techniques array
|
||||||
|
expect(Array.isArray(sim.techniques)).toBe(true);
|
||||||
|
expect(sim).not.toHaveProperty('mitre_technique_id');
|
||||||
|
expect(sim).not.toHaveProperty('mitre_technique_name');
|
||||||
|
|
||||||
|
// PATCH technique_ids → techniques array in response
|
||||||
|
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
|
||||||
|
technique_ids: ['T1059', 'T1078'],
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(Array.isArray(r.data.techniques)).toBe(true);
|
||||||
|
expect(r.data.techniques).toHaveLength(2);
|
||||||
|
expect(r.data.techniques[0].id).toBe('T1059');
|
||||||
|
expect(r.data.techniques[0].name).toBeTruthy();
|
||||||
|
expect(Array.isArray(r.data.techniques[0].tactics)).toBe(true);
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-16.3 — edit form Red Team section still has name, description, commands fields', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 form fields');
|
||||||
|
|
||||||
|
await seedTokenInStorage(context, redteamToken);
|
||||||
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
// Red Team section
|
||||||
|
await expect(page.getByRole('heading', { name: /red team/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Core fields still present
|
||||||
|
await expect(page.locator('#sim-name')).toBeVisible();
|
||||||
|
await expect(page.locator('#sim-description')).toBeVisible();
|
||||||
|
await expect(page.locator('#sim-commands')).toBeVisible();
|
||||||
|
|
||||||
|
// Save Red Team button still present
|
||||||
|
await expect(page.getByRole('button', { name: /save red team/i })).toBeVisible();
|
||||||
|
|
||||||
|
// MitreTechniquesField buttons present
|
||||||
|
await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible();
|
||||||
|
|
||||||
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,8 +79,7 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
|||||||
|
|
||||||
const patch = {
|
const patch = {
|
||||||
name: 'Updated name',
|
name: 'Updated name',
|
||||||
mitre_technique_id: 'T1059',
|
technique_ids: ['T1059'],
|
||||||
mitre_technique_name: 'Command and Scripting Interpreter',
|
|
||||||
description: 'Some description',
|
description: 'Some description',
|
||||||
commands: 'cmd /c whoami\ncmd /c ipconfig',
|
commands: 'cmd /c whoami\ncmd /c ipconfig',
|
||||||
prerequisites: 'Admin shell',
|
prerequisites: 'Admin shell',
|
||||||
@@ -90,8 +89,10 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
|||||||
const r = await client.patch(`/simulations/${sim.id}`, patch);
|
const r = await client.patch(`/simulations/${sim.id}`, patch);
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
expect(r.data.name).toBe('Updated name');
|
expect(r.data.name).toBe('Updated name');
|
||||||
expect(r.data.mitre_technique_id).toBe('T1059');
|
// sprint 3: techniques array replaces scalar scalars
|
||||||
expect(r.data.mitre_technique_name).toBe('Command and Scripting Interpreter');
|
expect(Array.isArray(r.data.techniques)).toBe(true);
|
||||||
|
expect(r.data.techniques[0].id).toBe('T1059');
|
||||||
|
expect(r.data.techniques[0].name).toBe('Command and Scripting Interpreter');
|
||||||
expect(r.data.description).toBe('Some description');
|
expect(r.data.description).toBe('Some description');
|
||||||
expect(r.data.commands).toBe('cmd /c whoami\ncmd /c ipconfig');
|
expect(r.data.commands).toBe('cmd /c whoami\ncmd /c ipconfig');
|
||||||
expect(r.data.prerequisites).toBe('Admin shell');
|
expect(r.data.prerequisites).toBe('Admin shell');
|
||||||
@@ -199,7 +200,7 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
|||||||
await deleteSimulation(redteamToken, sim.id);
|
await deleteSimulation(redteamToken, sim.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AC-8.6 — MITRE technique picker is present on the edit form', async ({
|
test('AC-8.6 — MITRE technique picker accessible via Quick search on the edit form', async ({
|
||||||
page,
|
page,
|
||||||
context,
|
context,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -208,6 +209,8 @@ test.describe('US-8 — redteam fill simulation details', () => {
|
|||||||
await seedTokenInStorage(context, redteamToken);
|
await seedTokenInStorage(context, redteamToken);
|
||||||
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
|
||||||
|
|
||||||
|
// Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search"
|
||||||
|
await page.getByRole('button', { name: /quick search/i }).click();
|
||||||
// MitreTechniquePicker renders an input with combobox role
|
// MitreTechniquePicker renders an input with combobox role
|
||||||
await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();
|
await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user