/** * US-18 — Simulation `done` = read-only + Reopen. * Covers AC-18.1 → AC-18.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 = 'us18-redteam'; const SOC_USER = 'us18-soc'; const PASS = 'us18-pass-strong'; interface Simulation { id: number; status: string; [key: string]: unknown; } async function createSimulation(token: string, engagementId: number, name = 'US-18 sim'): Promise { const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name }); if (r.status !== 201) throw new Error(`create sim: ${r.status}`); return r.data as Simulation; } async function deleteSimulation(token: string, simId: number): Promise { await makeClient(token).delete(`/simulations/${simId}`); } /** Drive a simulation from pending → in_progress → review_required → done */ async function driveSimToDone(token: string, simId: number): Promise { const c = makeClient(token); await c.patch(`/simulations/${simId}`, { name: 'trigger in_progress' }); await c.post(`/simulations/${simId}/transition`, { to: 'review_required' }); await c.post(`/simulations/${simId}/transition`, { to: 'done' }); } test.describe('US-18 — done read-only + Reopen', () => { 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-18 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-18.1 — PATCH on done simulation returns 409 (redteam)', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done redteam'); await driveSimToDone(redteamToken, sim.id); const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'should fail' }); expect(r.status).toBe(409); expect(r.data.error).toMatch(/simulation is done — reopen first/i); await deleteSimulation(redteamToken, sim.id); }); test('AC-18.1 — PATCH on done simulation returns 409 (soc)', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done soc'); await driveSimToDone(redteamToken, sim.id); // SOC tries to PATCH soc_comment on a done sim → 409 const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { soc_comment: 'late note' }); expect(r.status).toBe(409); expect(r.data.error).toMatch(/simulation is done — reopen first/i); await deleteSimulation(redteamToken, sim.id); }); test('AC-18.1 — PATCH on done simulation returns 409 (admin)', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done admin'); await driveSimToDone(redteamToken, sim.id); const tok = await adminToken(); const r = await makeClient(tok).patch(`/simulations/${sim.id}`, { name: 'admin override' }); expect(r.status).toBe(409); expect(r.data.error).toMatch(/simulation is done — reopen first/i); await deleteSimulation(redteamToken, sim.id); }); test('AC-18.2 — Reopen: done → review_required via transition (redteam)', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen redteam'); await driveSimToDone(redteamToken, sim.id); const r = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); expect(r.status).toBe(200); expect(r.data.status).toBe('review_required'); expect(r.data.updated_at).toBeTruthy(); await deleteSimulation(redteamToken, sim.id); }); test('AC-18.2 — Reopen: done → review_required via transition (soc)', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen soc'); await driveSimToDone(redteamToken, sim.id); const r = await makeClient(socToken).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-18.3 — review_required from pending/in_progress stays admin/redteam only (not soc)', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 soc cannot mark review'); await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' }); // SOC cannot mark in_progress → review_required 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-18.3 — other transitions from done still return 409', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 done bad transition'); await driveSimToDone(redteamToken, sim.id); // Trying to go done → done or done → in_progress should 409 const r1 = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' }); expect(r1.status).toBe(409); await deleteSimulation(redteamToken, sim.id); }); test('AC-18.4 — SimulationFormPage done: all fields disabled, only Reopen button visible', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.4 done UI'); await driveSimToDone(redteamToken, sim.id); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); // Read-only banner visible await expect(page.getByText(/done.*read-only|read-only.*done/i)).toBeVisible(); // Name field disabled const nameField = page.locator('#sim-name'); await expect(nameField).toBeDisabled(); // Reopen button visible await expect(page.getByRole('button', { name: /reopen/i })).toBeVisible(); // Save RT, Save SOC, Mark for review, Close, Delete — all absent await expect(page.getByRole('button', { name: /save/i })).not.toBeVisible(); await expect(page.getByRole('button', { name: /mark for review/i })).not.toBeVisible(); await expect(page.getByRole('button', { name: /^close$/i })).not.toBeVisible(); // MitreTechniquesField in read-only mode: no matrix icon, no input await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); test('AC-18.5 — Reopen via UI: toast appears, badge updates, fields editable', async ({ page, context, }) => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 reopen UI'); await driveSimToDone(redteamToken, sim.id); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); // Click Reopen await page.getByRole('button', { name: /reopen/i }).click(); // Toast: "Simulation reopened" await expect(page.getByText(/simulation reopened/i)).toBeVisible({ timeout: 5_000 }); // Badge updates to review_required const badge = page.getByTestId('simulation-status-badge'); await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 }); // Fields become editable again (name field enabled) await expect(page.locator('#sim-name')).toBeEnabled({ timeout: 3_000 }); // Reopen button gone; Save button now visible await expect(page.getByRole('button', { name: /reopen/i })).not.toBeVisible(); await expect(page.getByRole('button', { name: /save/i }).first()).toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); test('AC-18.5 — after Reopen, PATCH succeeds (no longer 409)', async () => { const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 PATCH after reopen'); await driveSimToDone(redteamToken, sim.id); // Reopen via API await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); // Now PATCH should succeed const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { soc_comment: 'updated' }); expect(r.status).toBe(200); await deleteSimulation(redteamToken, sim.id); }); });