diff --git a/e2e/tests/us10-mitre-autocomplete.spec.ts b/e2e/tests/us10-mitre-autocomplete.spec.ts new file mode 100644 index 0000000..dbb29a8 --- /dev/null +++ b/e2e/tests/us10-mitre-autocomplete.spec.ts @@ -0,0 +1,174 @@ +/** + * US-10 — MITRE ATT&CK autocomplete. + * Covers AC-10.1 → AC-10.5. + * + * AC-10.1 (make update-mitre CLI target) is not exercised from Playwright; + * the bundle is assumed present in the container image. + */ +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 = 'us10-redteam'; +const PASS = 'us10-pass-strong'; + +interface Simulation { + id: number; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-10 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) { + throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`); + } + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + const client = makeClient(token); + await client.delete(`/simulations/${simId}`); +} + +test.describe('US-10 — MITRE autocomplete', () => { + 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-10 Test 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-10.1 — bundle present in container (skipped: CLI-only, bundle assumed committed)', async () => { + // AC-10.1 is a Makefile target test. We verify the bundle is loaded + // indirectly by checking the API returns results (not 503). + const client = makeClient(redteamToken); + const r = await client.get('/mitre/techniques?q=T1059'); + // If bundle not loaded, we'd get 503 — this confirms it loaded OK + expect(r.status).not.toBe(503); + }); + + test('AC-10.2 — GET /api/mitre/techniques?q= returns max 20 results with id/name/tactics', async () => { + const client = makeClient(redteamToken); + + // Search by id prefix + const rId = await client.get('/mitre/techniques?q=T1059'); + expect(rId.status).toBe(200); + expect(Array.isArray(rId.data)).toBe(true); + expect(rId.data.length).toBeGreaterThan(0); + expect(rId.data.length).toBeLessThanOrEqual(20); + + const first = rId.data[0]; + expect(first).toHaveProperty('id'); + expect(first).toHaveProperty('name'); + expect(first).toHaveProperty('tactics'); + expect(Array.isArray(first.tactics)).toBe(true); + + // Exact id match comes first + const exactMatch = rId.data.find((t: { id: string }) => t.id === 'T1059'); + expect(exactMatch).toBeTruthy(); + expect(rId.data[0].id).toBe('T1059'); + + // Search by name (case-insensitive) + const rName = await client.get( + '/mitre/techniques?q=command%20and%20scripting%20interpreter', + ); + expect(rName.status).toBe(200); + expect(rName.data.length).toBeGreaterThan(0); + const nameMatch = rName.data.find((t: { name: string }) => + t.name.toLowerCase().includes('command and scripting'), + ); + expect(nameMatch).toBeTruthy(); + }); + + test('AC-10.3 — 503 if bundle not loaded (verified by absence: bundle IS loaded)', async () => { + const client = makeClient(redteamToken); + // We can only test the happy path from e2e; 503 requires a container + // without the bundle. We verify the endpoint does NOT return 503, + // confirming the bundle is loaded. + const r = await client.get('/mitre/techniques?q=T1'); + expect(r.status).toBe(200); + }); + + test('AC-10.4 — sub-techniques (T1059.001) included in search results', async () => { + const client = makeClient(redteamToken); + const r = await client.get('/mitre/techniques?q=T1059.001'); + expect(r.status).toBe(200); + expect(r.data.length).toBeGreaterThan(0); + const subtech = r.data.find((t: { id: string }) => t.id === 'T1059.001'); + expect(subtech).toBeTruthy(); + expect(subtech.name).toBeTruthy(); + }); + + test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection fills both fields', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-10.5 sim'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + 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 }); + + // Options visible in expected format: "T1059 — Command and Scripting Interpreter (...)" + const options = listbox.getByRole('option'); + await expect(options.first()).toBeVisible(); + const firstText = await options.first().textContent(); + expect(firstText).toMatch(/T1059/); + expect(firstText).toMatch(/—/); + + // Keyboard navigation: ArrowDown selects item, Enter confirms + await picker.press('ArrowDown'); + await picker.press('Enter'); + + // After selection the dropdown closes and input shows the selected value + await expect(listbox).not.toBeVisible(); + const inputValue = await picker.inputValue(); + expect(inputValue).toMatch(/T1059/); + expect(inputValue).toMatch(/—/); + + // Escape closes the dropdown + await picker.fill('T1'); + await expect(listbox).toBeVisible({ timeout: 5_000 }); + await picker.press('Escape'); + await expect(listbox).not.toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us11-workflow-transitions.spec.ts b/e2e/tests/us11-workflow-transitions.spec.ts new file mode 100644 index 0000000..b83ee29 --- /dev/null +++ b/e2e/tests/us11-workflow-transitions.spec.ts @@ -0,0 +1,245 @@ +/** + * US-11 — workflow transitions. + * Covers AC-11.1 → AC-11.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 = 'us11-redteam'; +const SOC_USER = 'us11-soc'; +const PASS = 'us11-pass-strong'; + +interface Simulation { + id: number; + status: string; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-11 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) { + throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`); + } + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + const client = makeClient(token); + await client.delete(`/simulations/${simId}`); +} + +test.describe('US-11 — workflow transitions', () => { + 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-11 Test 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-11.1 — pending→review_required valid (redteam); invalid target → 409', async () => { + const rtClient = makeClient(redteamToken); + + // pending → review_required: valid + const sim = await createSimulation(redteamToken, engagementId, 'AC-11.1 sim'); + const rOk = await rtClient.post(`/simulations/${sim.id}/transition`, { + to: 'review_required', + }); + expect(rOk.status).toBe(200); + expect(rOk.data.status).toBe('review_required'); + + // Invalid target → 409 + const simBad = await createSimulation(redteamToken, engagementId, 'AC-11.1 bad sim'); + const rBad = await rtClient.post(`/simulations/${simBad.id}/transition`, { + to: 'done', + }); + expect(rBad.status).toBe(409); + expect(rBad.data.error).toMatch(/invalid transition/i); + + await deleteSimulation(redteamToken, sim.id); + await deleteSimulation(redteamToken, simBad.id); + }); + + test('AC-11.1 — in_progress→review_required valid (redteam)', async () => { + const rtClient = makeClient(redteamToken); + const sim = await createSimulation(redteamToken, engagementId, 'AC-11.1 in_progress sim'); + + // Trigger in_progress via auto-transition + await rtClient.patch(`/simulations/${sim.id}`, { name: 'trigger' }); + const r = await rtClient.post(`/simulations/${sim.id}/transition`, { + to: 'review_required', + }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('review_required'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-11.2 — review_required→done valid for redteam and soc', async () => { + const rtClient = makeClient(redteamToken); + const socClient = makeClient(socToken); + + // redteam can close + const simRT = await createSimulation(redteamToken, engagementId, 'AC-11.2 redteam close'); + await rtClient.post(`/simulations/${simRT.id}/transition`, { to: 'review_required' }); + const rRT = await rtClient.post(`/simulations/${simRT.id}/transition`, { to: 'done' }); + expect(rRT.status).toBe(200); + expect(rRT.data.status).toBe('done'); + + // soc can close + const simSOC = await createSimulation(redteamToken, engagementId, 'AC-11.2 soc close'); + await rtClient.post(`/simulations/${simSOC.id}/transition`, { to: 'review_required' }); + const rSOC = await socClient.post(`/simulations/${simSOC.id}/transition`, { to: 'done' }); + expect(rSOC.status).toBe(200); + expect(rSOC.data.status).toBe('done'); + + // done → review_required is invalid (409) + const rBack = await rtClient.post(`/simulations/${simRT.id}/transition`, { + to: 'review_required', + }); + expect(rBack.status).toBe(409); + + await deleteSimulation(redteamToken, simRT.id); + await deleteSimulation(redteamToken, simSOC.id); + }); + + test('AC-11.3 — no backward transitions; no →pending or →in_progress via endpoint', async () => { + const rtClient = makeClient(redteamToken); + const sim = await createSimulation(redteamToken, engagementId, 'AC-11.3 sim'); + + // done → pending: invalid + await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'done' }); + const rPending = await rtClient.post(`/simulations/${sim.id}/transition`, { + to: 'pending', + }); + expect(rPending.status).toBe(409); + expect(rPending.data.error).toMatch(/invalid transition/i); + + // →in_progress via endpoint is always invalid + const simNew = await createSimulation(redteamToken, engagementId, 'AC-11.3 in_progress'); + const rIP = await rtClient.post(`/simulations/${simNew.id}/transition`, { + to: 'in_progress', + }); + expect(rIP.status).toBe(409); + + await deleteSimulation(redteamToken, sim.id); + await deleteSimulation(redteamToken, simNew.id); + }); + + test('AC-11.4 — workflow buttons visible per role+status in UI', async ({ + page, + context, + }) => { + const rtClient = makeClient(redteamToken); + + // pending → "Marquer en revue" visible for redteam; "Clôturer" hidden + const simPending = await createSimulation( + redteamToken, + engagementId, + 'AC-11.4 pending UI', + ); + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`); + await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0); + + // in_progress → "Marquer en revue" visible + const simIP = await createSimulation(redteamToken, engagementId, 'AC-11.4 in_progress UI'); + await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' }); + await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`); + await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0); + + // review_required → "Clôturer" visible for redteam; "Marquer en revue" hidden + const simRR = await createSimulation(redteamToken, engagementId, 'AC-11.4 review UI'); + await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'review_required' }); + await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`); + await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0); + + // review_required → "Clôturer" also visible for SOC + await seedTokenInStorage(context, socToken); + await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`); + await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible(); + + // done → both buttons hidden + await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'done' }); + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`); + await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0); + await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0); + + await deleteSimulation(redteamToken, simPending.id); + await deleteSimulation(redteamToken, simIP.id); + await deleteSimulation(redteamToken, simRR.id); + }); + + test('AC-11.5 — after transition, badge updates in UI (TanStack Query invalidation)', async ({ + page, + context, + }) => { + const rtClient = makeClient(redteamToken); + const sim = await createSimulation(redteamToken, engagementId, 'AC-11.5 badge sim'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Initially pending + const badge = page.getByTestId('simulation-status-badge'); + await expect(badge).toHaveAttribute('data-status', 'pending'); + + // Click "Marquer en revue" + await page.getByRole('button', { name: /marquer en revue/i }).click(); + + // Badge updates to review_required without page reload + await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 }); + + // "Clôturer" now visible; click it + await page.getByRole('button', { name: /clôturer/i }).click(); + await expect(badge).toHaveAttribute('data-status', 'done', { timeout: 5_000 }); + + // Verify list is also updated: navigate to engagement detail and check badge there + await page.goto(`/engagements/${engagementId}`); + const listBadge = page + .getByRole('row', { name: /AC-11.5 badge sim/i }) + .getByTestId('simulation-status-badge'); + await expect(listBadge).toHaveAttribute('data-status', 'done'); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us12-simulation-delete.spec.ts b/e2e/tests/us12-simulation-delete.spec.ts new file mode 100644 index 0000000..344d735 --- /dev/null +++ b/e2e/tests/us12-simulation-delete.spec.ts @@ -0,0 +1,153 @@ +/** + * US-12 — simulation delete (RBAC + cascade + confirm modal). + * Covers AC-12.1 → AC-12.4. + */ +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 = 'us12-redteam'; +const SOC_USER = 'us12-soc'; +const PASS = 'us12-pass-strong'; + +interface Simulation { + id: number; + engagement_id: number; + name: string; + status: string; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-12 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) { + throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`); + } + return r.data as Simulation; +} + +test.describe('US-12 — simulation delete', () => { + 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-12 Test 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-12.1 — DELETE /api/simulations/ (redteam) → 204, then 404', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-12.1 to delete'); + const client = makeClient(redteamToken); + + const rDel = await client.delete(`/simulations/${sim.id}`); + expect(rDel.status).toBe(204); + + const rGet = await client.get(`/simulations/${sim.id}`); + expect(rGet.status).toBe(404); + }); + + test('AC-12.2 — soc → 403 on DELETE', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-12.2 soc blocked'); + const socClient = makeClient(socToken); + + const r = await socClient.delete(`/simulations/${sim.id}`); + expect(r.status).toBe(403); + + // Clean up + const rtClient = makeClient(redteamToken); + await rtClient.delete(`/simulations/${sim.id}`); + }); + + test('AC-12.3 — cascade: deleting engagement deletes its simulations', async () => { + const adminTok = await adminToken(); + const adminClient = makeClient(adminTok); + + // Create a fresh engagement with simulations + const eng = await createEngagement(redteamToken, { + name: 'US-12 cascade test', + start_date: '2026-01-01', + }); + const s1 = await createSimulation(redteamToken, eng.id, 'cascade sim 1'); + const s2 = await createSimulation(redteamToken, eng.id, 'cascade sim 2'); + + // Delete the engagement + await deleteEngagement(redteamToken, eng.id); + + // Simulations must be gone + const rS1 = await adminClient.get(`/simulations/${s1.id}`); + expect(rS1.status).toBe(404); + const rS2 = await adminClient.get(`/simulations/${s2.id}`); + expect(rS2.status).toBe(404); + }); + + test('AC-12.4 — delete button visible for redteam, confirmation modal, deletes and redirects', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-12.4 UI delete'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Delete button is visible for redteam + const deleteBtn = page.getByRole('button', { name: /supprimer/i }); + await expect(deleteBtn).toBeVisible(); + + // SOC should NOT see delete button + await seedTokenInStorage(context, socToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + await expect(page.getByRole('button', { name: /supprimer/i })).toHaveCount(0); + + // Back to redteam — click delete, confirm modal appears + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + await page.getByRole('button', { name: /supprimer/i }).click(); + + // Confirmation dialog must appear + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/supprimer la simulation/i)).toBeVisible(); + + // Confirm deletion + await dialog.getByRole('button', { name: /supprimer/i }).click(); + + // Should navigate back to engagement detail + await page.waitForURL(new RegExp(`/engagements/${engagementId}$`)); + + // Simulation no longer in list + await expect(page.getByRole('row', { name: /AC-12.4 UI delete/i })).toHaveCount(0); + }); +}); diff --git a/e2e/tests/us7-simulation-create.spec.ts b/e2e/tests/us7-simulation-create.spec.ts new file mode 100644 index 0000000..e174151 --- /dev/null +++ b/e2e/tests/us7-simulation-create.spec.ts @@ -0,0 +1,192 @@ +/** + * US-7 — redteam creates a simulation inside an engagement. + * Covers AC-7.1 → AC-7.6. + */ +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 = 'us7-redteam'; +const SOC_USER = 'us7-soc'; +const PASS = 'us7-pass-strong'; + +interface Simulation { + id: number; + engagement_id: number; + name: string; + status: string; + created_at: string; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + payload: { name: string }, +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, payload); + if (r.status !== 201) { + throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`); + } + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + const client = makeClient(token); + await client.delete(`/simulations/${simId}`); +} + +test.describe('US-7 — simulation create', () => { + 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-7 Test 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-7.1 — POST creates simulation with status pending, name required', async () => { + const sim = await createSimulation(redteamToken, engagementId, { name: 'Test sim 7.1' }); + expect(sim.id).toBeTruthy(); + expect(sim.engagement_id).toBe(engagementId); + expect(sim.name).toBe('Test sim 7.1'); + expect(sim.status).toBe('pending'); + expect(sim.created_at).toBeTruthy(); + + // name required: blank name → 400 + const client = makeClient(redteamToken); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name: '' }); + expect(r.status).toBe(400); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-7.2 — soc role → 403 on POST', async () => { + const client = makeClient(socToken); + const r = await client.post(`/engagements/${engagementId}/simulations`, { + name: 'soc-blocked', + }); + expect(r.status).toBe(403); + }); + + test('AC-7.3 — unknown engagement → 404; existing engagement with no sims → empty list', async () => { + const client = makeClient(redteamToken); + + const r404 = await client.post('/engagements/999999/simulations', { name: 'ghost' }); + expect(r404.status).toBe(404); + + // Create a fresh engagement with no sims and verify list is empty + const freshEng = await createEngagement(redteamToken, { + name: 'US-7 empty engagement', + start_date: '2026-01-01', + }); + const listR = await client.get(`/engagements/${freshEng.id}/simulations`); + expect(listR.status).toBe(200); + expect(listR.data).toEqual([]); + + await deleteEngagement(redteamToken, freshEng.id); + }); + + test('AC-7.4 — GET list returns sims ordered by created_at desc', async () => { + const client = makeClient(redteamToken); + + const s1 = await createSimulation(redteamToken, engagementId, { name: 'First sim' }); + // Small delay so created_at timestamps differ + await new Promise((r) => setTimeout(r, 50)); + const s2 = await createSimulation(redteamToken, engagementId, { name: 'Second sim' }); + + const listR = await client.get(`/engagements/${engagementId}/simulations`); + expect(listR.status).toBe(200); + const list: Simulation[] = listR.data; + expect(list.length).toBeGreaterThanOrEqual(2); + // Most recent first + const ids = list.map((s) => s.id); + expect(ids.indexOf(s2.id)).toBeLessThan(ids.indexOf(s1.id)); + + await deleteSimulation(redteamToken, s1.id); + await deleteSimulation(redteamToken, s2.id); + }); + + test('AC-7.5 — /engagements/:eid shows Simulations section with list + "Nouvelle simulation" button for redteam', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, { name: 'Visible sim' }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}`); + + // Simulations section visible + await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible(); + + // Required columns + for (const col of ['Name', 'MITRE', 'Status', 'Executed at']) { + await expect(page.getByRole('columnheader', { name: new RegExp(col, 'i') })).toBeVisible(); + } + + // The created simulation row is visible + await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible(); + + // "Nouvelle simulation" button visible for redteam + await expect( + page.getByRole('link', { name: /nouvelle simulation/i }), + ).toBeVisible(); + + // SOC should NOT see "Nouvelle simulation" button + await seedTokenInStorage(context, socToken); + await page.goto(`/engagements/${engagementId}`); + await expect(page.getByRole('link', { name: /nouvelle simulation/i })).toHaveCount(0); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-7.6 — clicking a simulation row navigates to /engagements/:eid/simulations/:sid/edit', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, { name: 'Click me sim' }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}`); + + // Click the sim name link + await page.getByRole('link', { name: 'Click me sim' }).click(); + await page.waitForURL( + new RegExp(`/engagements/${engagementId}/simulations/${sim.id}/edit`), + ); + await expect(page.url()).toContain( + `/engagements/${engagementId}/simulations/${sim.id}/edit`, + ); + + 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 new file mode 100644 index 0000000..a8f91c2 --- /dev/null +++ b/e2e/tests/us8-simulation-redteam-fill.spec.ts @@ -0,0 +1,216 @@ +/** + * US-8 — redteam fills technical details of a simulation. + * Covers AC-8.1 → AC-8.6 (AC-8.6 defers to US-10 for the autocomplete UI detail). + */ +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 = 'us8-redteam'; +const SOC_USER = 'us8-soc'; +const PASS = 'us8-pass-strong'; + +interface Simulation { + id: number; + engagement_id: number; + name: string; + status: string; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-8 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) { + throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`); + } + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + const client = makeClient(token); + await client.delete(`/simulations/${simId}`); +} + +test.describe('US-8 — redteam fill simulation details', () => { + 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-8 Test 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-8.1 — PATCH accepts all redteam fields (partial OK)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-8.1 sim'); + const client = makeClient(redteamToken); + + const patch = { + name: 'Updated name', + mitre_technique_id: 'T1059', + mitre_technique_name: 'Command and Scripting Interpreter', + description: 'Some description', + commands: 'cmd /c whoami\ncmd /c ipconfig', + prerequisites: 'Admin shell', + executed_at: '2026-05-01T12:00:00', + execution_result: 'Success', + }; + 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'); + 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'); + expect(r.data.execution_result).toBe('Success'); + + // Partial PATCH (only name) should also work + const rPartial = await client.patch(`/simulations/${sim.id}`, { name: 'Partial update' }); + expect(rPartial.status).toBe(200); + expect(rPartial.data.name).toBe('Partial update'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-8.2 — auto-transition pending→in_progress on PATCH with non-empty redteam field', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-8.2 sim'); + expect(sim.status).toBe('pending'); + const client = makeClient(redteamToken); + + // PATCH with a non-empty redteam field triggers auto-transition + const r = await client.patch(`/simulations/${sim.id}`, { name: 'trigger transition' }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-8.2 — auto-transition does NOT trigger for soc PATCH', async () => { + // Create sim then transition it to review_required so SOC can patch it + const sim = await createSimulation(redteamToken, engagementId, 'AC-8.2 soc no-trigger'); + const rtClient = makeClient(redteamToken); + // Move to in_progress first + await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-8.2 soc no-trigger' }); + // Move to review_required + const tr = await rtClient.post(`/simulations/${sim.id}/transition`, { + to: 'review_required', + }); + expect(tr.status).toBe(200); + + // Now SOC patches soc-only fields — status must NOT change + const socClient = makeClient(socToken); + const rSoc = await socClient.patch(`/simulations/${sim.id}`, { soc_comment: 'noted' }); + expect(rSoc.status).toBe(200); + expect(rSoc.data.status).toBe('review_required'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-8.3 — commands stored and returned as raw multiline string', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-8.3 sim'); + const client = makeClient(redteamToken); + const cmds = 'cmd1\ncmd2\ncmd3'; + const r = await client.patch(`/simulations/${sim.id}`, { commands: cmds }); + expect(r.status).toBe(200); + expect(r.data.commands).toBe(cmds); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-8.4 — invalid executed_at → 400; null clears field', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-8.4 sim'); + const client = makeClient(redteamToken); + + const rBad = await client.patch(`/simulations/${sim.id}`, { + executed_at: 'not-a-date', + }); + expect(rBad.status).toBe(400); + expect(rBad.data.error).toMatch(/invalid executed_at/i); + + // Valid ISO 8601 + const rOk = await client.patch(`/simulations/${sim.id}`, { + executed_at: '2026-05-01T09:00:00', + }); + expect(rOk.status).toBe(200); + + // Null clears the field + const rNull = await client.patch(`/simulations/${sim.id}`, { executed_at: null }); + expect(rNull.status).toBe(200); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-8.5 — edit page shows Red Team and SOC sections; redteam sees both editable, name validation', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-8.5 sim'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Both sections visible + await expect(page.getByRole('heading', { name: /red team/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: /soc/i })).toBeVisible(); + + // Name field is present and enabled for redteam + const nameField = page.locator('#sim-name'); + await expect(nameField).toBeVisible(); + await expect(nameField).toBeEnabled(); + + // Clear the name, try to save → client validation error + await nameField.fill(''); + await page.getByRole('button', { name: /save red team/i }).click(); + await expect(page.getByText(/name is required/i)).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-8.6 — MITRE technique picker is present on the edit form', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-8.6 sim'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // MitreTechniquePicker renders an input with combobox role + await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us9-soc-restricted-edit.spec.ts b/e2e/tests/us9-soc-restricted-edit.spec.ts new file mode 100644 index 0000000..d8459c7 --- /dev/null +++ b/e2e/tests/us9-soc-restricted-edit.spec.ts @@ -0,0 +1,200 @@ +/** + * US-9 — SOC analyst fills their part; redteam fields blocked. + * Covers AC-9.1 → AC-9.4. + */ +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 = 'us9-redteam'; +const SOC_USER = 'us9-soc'; +const PASS = 'us9-pass-strong'; + +interface Simulation { + id: number; + status: string; + [key: string]: unknown; +} + +async function createSimulation( + token: string, + engagementId: number, + name = 'US-9 sim', +): Promise { + const client = makeClient(token); + const r = await client.post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) { + throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`); + } + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + const client = makeClient(token); + await client.delete(`/simulations/${simId}`); +} + +async function advanceToReviewRequired( + redteamToken: string, + simId: number, +): Promise { + const client = makeClient(redteamToken); + // Trigger auto-transition to in_progress + await client.patch(`/simulations/${simId}`, { name: 'ready' }); + // Transition to review_required + const r = await client.post(`/simulations/${simId}/transition`, { + to: 'review_required', + }); + if (r.status !== 200) { + throw new Error(`transition to review_required failed: ${r.status}`); + } +} + +test.describe('US-9 — SOC restricted edit', () => { + 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-9 Test 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-9.1 — soc PATCH with redteam field → 403 soc cannot edit redteam fields', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-9.1 sim'); + await advanceToReviewRequired(redteamToken, sim.id); + + const socClient = makeClient(socToken); + + // Redteam field in payload → 403 + const r = await socClient.patch(`/simulations/${sim.id}`, { + name: 'SOC tries to change name', + }); + expect(r.status).toBe(403); + expect(r.data.error).toMatch(/soc cannot edit redteam fields/i); + + // SOC-only fields → 200 + const rOk = await socClient.patch(`/simulations/${sim.id}`, { + soc_comment: 'Detected', + log_source: 'SIEM', + logs: 'log entry', + incident_number: 'INC-001', + }); + expect(rOk.status).toBe(200); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-9.2 — soc PATCH blocked when status is pending or in_progress', async () => { + const socClient = makeClient(socToken); + + // pending → 403 + const simPending = await createSimulation(redteamToken, engagementId, 'AC-9.2 pending'); + const rPending = await socClient.patch(`/simulations/${simPending.id}`, { + soc_comment: 'too early', + }); + expect(rPending.status).toBe(403); + expect(rPending.data.error).toMatch(/simulation not ready for SOC review/i); + + // in_progress → 403 + const simInProgress = await createSimulation( + redteamToken, + engagementId, + 'AC-9.2 in_progress', + ); + const rtClient = makeClient(redteamToken); + await rtClient.patch(`/simulations/${simInProgress.id}`, { name: 'trigger' }); + const rInProgress = await socClient.patch(`/simulations/${simInProgress.id}`, { + soc_comment: 'still too early', + }); + expect(rInProgress.status).toBe(403); + expect(rInProgress.data.error).toMatch(/simulation not ready for SOC review/i); + + await deleteSimulation(redteamToken, simPending.id); + await deleteSimulation(redteamToken, simInProgress.id); + }); + + test('AC-9.3 — edit page for soc: RT section read-only, SOC section editable when review_required', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-9.3 sim'); + await advanceToReviewRequired(redteamToken, sim.id); + + await seedTokenInStorage(context, socToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // RT fields are disabled + await expect(page.locator('#sim-name')).toBeDisabled(); + await expect(page.locator('#sim-description')).toBeDisabled(); + await expect(page.locator('#sim-commands')).toBeDisabled(); + + // SOC fields are enabled + await expect(page.locator('#sim-log-source')).toBeEnabled(); + await expect(page.locator('#sim-logs')).toBeEnabled(); + await expect(page.locator('#sim-soc-comment')).toBeEnabled(); + await expect(page.locator('#sim-incident')).toBeEnabled(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-9.4 — soc visits pending/in_progress simulation: banner visible, SOC fields disabled', async ({ + page, + context, + }) => { + // Test with pending status + const simPending = await createSimulation(redteamToken, engagementId, 'AC-9.4 pending'); + + await seedTokenInStorage(context, socToken); + await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`); + + // Banner must be visible + await expect(page.getByTestId('soc-blocked-banner')).toBeVisible(); + await expect( + page.getByText(/simulation pas encore en revue/i), + ).toBeVisible(); + + // SOC fields are disabled + await expect(page.locator('#sim-log-source')).toBeDisabled(); + await expect(page.locator('#sim-soc-comment')).toBeDisabled(); + + // Test with in_progress status + const simIP = await createSimulation(redteamToken, engagementId, 'AC-9.4 in_progress'); + const rtClient = makeClient(redteamToken); + await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' }); + + await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`); + await expect(page.getByTestId('soc-blocked-banner')).toBeVisible(); + await expect(page.locator('#sim-log-source')).toBeDisabled(); + + await deleteSimulation(redteamToken, simPending.id); + await deleteSimulation(redteamToken, simIP.id); + }); +});