/** * 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); }); });