From df8a6b605be92551b05f04071739b33b18ec4b91 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 27 May 2026 04:51:34 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20sprint=203=20acceptance=20tests=20?= =?UTF-8?q?=E2=80=94=20US-13=20to=20US-16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/tests/us10-mitre-autocomplete.spec.ts | 17 +- e2e/tests/us13-multi-techniques.spec.ts | 195 +++++++ e2e/tests/us14-techniques-tags.spec.ts | 260 +++++++++ e2e/tests/us15-mitre-matrix-modal.spec.ts | 506 ++++++++++++++++++ e2e/tests/us16-regression-sprint2.spec.ts | 282 ++++++++++ e2e/tests/us8-simulation-redteam-fill.spec.ts | 13 +- 6 files changed, 1261 insertions(+), 12 deletions(-) create mode 100644 e2e/tests/us13-multi-techniques.spec.ts create mode 100644 e2e/tests/us14-techniques-tags.spec.ts create mode 100644 e2e/tests/us15-mitre-matrix-modal.spec.ts create mode 100644 e2e/tests/us16-regression-sprint2.spec.ts diff --git a/e2e/tests/us10-mitre-autocomplete.spec.ts b/e2e/tests/us10-mitre-autocomplete.spec.ts index dbb29a8..ac51db1 100644 --- a/e2e/tests/us10-mitre-autocomplete.spec.ts +++ b/e2e/tests/us10-mitre-autocomplete.spec.ts @@ -128,7 +128,7 @@ test.describe('US-10 — MITRE autocomplete', () => { 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, context, }) => { @@ -137,12 +137,14 @@ test.describe('US-10 — MITRE autocomplete', () => { await seedTokenInStorage(context, redteamToken); 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 }); await expect(picker).toBeVisible(); // Type a query — after debounce (200ms) the dropdown opens with results await picker.fill('T1059'); - // Wait for dropdown to appear (debounce + network) const listbox = page.getByRole('listbox', { name: /mitre techniques/i }); await expect(listbox).toBeVisible({ timeout: 5_000 }); @@ -157,13 +159,14 @@ test.describe('US-10 — MITRE autocomplete', () => { await picker.press('ArrowDown'); 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(); - const inputValue = await picker.inputValue(); - expect(inputValue).toMatch(/T1059/); - expect(inputValue).toMatch(/—/); + await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059'); - // 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 expect(listbox).toBeVisible({ timeout: 5_000 }); await picker.press('Escape'); diff --git a/e2e/tests/us13-multi-techniques.spec.ts b/e2e/tests/us13-multi-techniques.spec.ts new file mode 100644 index 0000000..982c44a --- /dev/null +++ b/e2e/tests/us13-multi-techniques.spec.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/e2e/tests/us14-techniques-tags.spec.ts b/e2e/tests/us14-techniques-tags.spec.ts new file mode 100644 index 0000000..593caf8 --- /dev/null +++ b/e2e/tests/us14-techniques-tags.spec.ts @@ -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 { + 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 { + 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 { + 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); + }); +}); diff --git a/e2e/tests/us15-mitre-matrix-modal.spec.ts b/e2e/tests/us15-mitre-matrix-modal.spec.ts new file mode 100644 index 0000000..a0ac810 --- /dev/null +++ b/e2e/tests/us15-mitre-matrix-modal.spec.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/e2e/tests/us16-regression-sprint2.spec.ts b/e2e/tests/us16-regression-sprint2.spec.ts new file mode 100644 index 0000000..30a873a --- /dev/null +++ b/e2e/tests/us16-regression-sprint2.spec.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/e2e/tests/us8-simulation-redteam-fill.spec.ts b/e2e/tests/us8-simulation-redteam-fill.spec.ts index a8f91c2..013c8ef 100644 --- a/e2e/tests/us8-simulation-redteam-fill.spec.ts +++ b/e2e/tests/us8-simulation-redteam-fill.spec.ts @@ -79,8 +79,7 @@ test.describe('US-8 — redteam fill simulation details', () => { const patch = { name: 'Updated name', - mitre_technique_id: 'T1059', - mitre_technique_name: 'Command and Scripting Interpreter', + technique_ids: ['T1059'], description: 'Some description', commands: 'cmd /c whoami\ncmd /c ipconfig', prerequisites: 'Admin shell', @@ -90,8 +89,10 @@ test.describe('US-8 — redteam fill simulation details', () => { const r = await client.patch(`/simulations/${sim.id}`, patch); expect(r.status).toBe(200); expect(r.data.name).toBe('Updated name'); - expect(r.data.mitre_technique_id).toBe('T1059'); - expect(r.data.mitre_technique_name).toBe('Command and Scripting Interpreter'); + // sprint 3: techniques array replaces scalar scalars + 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.commands).toBe('cmd /c whoami\ncmd /c ipconfig'); expect(r.data.prerequisites).toBe('Admin shell'); @@ -199,7 +200,7 @@ test.describe('US-8 — redteam fill simulation details', () => { 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, context, }) => { @@ -208,6 +209,8 @@ test.describe('US-8 — redteam fill simulation details', () => { await seedTokenInStorage(context, redteamToken); 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 await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();