/** * US-19 — Engagement auto-status: planned → active. * Covers AC-19.1 → AC-19.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 = 'us19-redteam'; const PASS = 'us19-pass-strong'; interface Simulation { id: number; status: string; [key: string]: unknown; } async function createSimulation(token: string, engagementId: number, name = 'US-19 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}`); } async function getEngagement(token: string, eid: number): Promise<{ status: string }> { const r = await makeClient(token).get(`/engagements/${eid}`); return r.data as { status: string }; } test.describe('US-19 — engagement auto-status', () => { let redteamToken: string; test.beforeAll(async () => { await ensureUser(REDTEAM_USER, PASS, 'redteam'); redteamToken = (await login(REDTEAM_USER, PASS)).token; }); test.afterAll(async () => { try { const tok = await adminToken(); await deleteUserByUsername(tok, REDTEAM_USER); } catch { /* noop */ } }); test('AC-19.1 — engagement stays planned when sim is created (no auto-transition yet)', async () => { const eng = await createEngagement(redteamToken, { name: 'US-19 stay planned', start_date: '2026-01-01', }); // Creating a simulation alone does NOT activate engagement const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 created'); const engData = await getEngagement(redteamToken, eng.id); expect(engData.status).toBe('planned'); await deleteSimulation(redteamToken, sim.id); await deleteEngagement(redteamToken, eng.id); }); test('AC-19.1 — engagement auto-activates when sim transitions to in_progress (via PATCH redteam field)', async () => { const eng = await createEngagement(redteamToken, { name: 'US-19 auto-activate', start_date: '2026-01-01', }); expect(eng.status).toBe('planned'); const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 trigger'); // PATCH a redteam field → auto-transition sim to in_progress → engagement → active const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' }); expect(r.status).toBe(200); expect(r.data.status).toBe('in_progress'); const engData = await getEngagement(redteamToken, eng.id); expect(engData.status).toBe('active'); await deleteSimulation(redteamToken, sim.id); await deleteEngagement(redteamToken, eng.id); }); test('AC-19.1 — engagement auto-activates when sim transitions via technique_ids', async () => { const eng = await createEngagement(redteamToken, { name: 'US-19 technique activate', start_date: '2026-01-01', }); const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 technique'); const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] }); expect(r.status).toBe(200); expect(r.data.status).toBe('in_progress'); const engData = await getEngagement(redteamToken, eng.id); expect(engData.status).toBe('active'); await deleteSimulation(redteamToken, sim.id); await deleteEngagement(redteamToken, eng.id); }); test('AC-19.1 — engagement auto-activates when sim transitions via tactic_ids', async () => { const eng = await createEngagement(redteamToken, { name: 'US-19 tactic activate', start_date: '2026-01-01', }); const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 tactic'); const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] }); expect(r.status).toBe(200); expect(r.data.status).toBe('in_progress'); const engData = await getEngagement(redteamToken, eng.id); expect(engData.status).toBe('active'); await deleteSimulation(redteamToken, sim.id); await deleteEngagement(redteamToken, eng.id); }); test('AC-19.2 — already active engagement stays active (no double-transition)', async () => { const eng = await createEngagement(redteamToken, { name: 'US-19 already active', start_date: '2026-01-01', }); // First sim activates the engagement const sim1 = await createSimulation(redteamToken, eng.id, 'AC-19.2 first'); await makeClient(redteamToken).patch(`/simulations/${sim1.id}`, { name: 'trigger 1' }); const engAfterFirst = await getEngagement(redteamToken, eng.id); expect(engAfterFirst.status).toBe('active'); // Second sim trigger — engagement stays active (not planned, not any other status) const sim2 = await createSimulation(redteamToken, eng.id, 'AC-19.2 second'); await makeClient(redteamToken).patch(`/simulations/${sim2.id}`, { name: 'trigger 2' }); const engAfterSecond = await getEngagement(redteamToken, eng.id); expect(engAfterSecond.status).toBe('active'); await deleteSimulation(redteamToken, sim1.id); await deleteSimulation(redteamToken, sim2.id); await deleteEngagement(redteamToken, eng.id); }); test('AC-19.3 — no backward auto transition: engagement status never goes back to planned', async () => { const eng = await createEngagement(redteamToken, { name: 'US-19 no backward', start_date: '2026-01-01', }); const sim = await createSimulation(redteamToken, eng.id, 'AC-19.3 backward'); await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' }); const engActive = await getEngagement(redteamToken, eng.id); expect(engActive.status).toBe('active'); // Deleting the sim does not revert engagement to planned await deleteSimulation(redteamToken, sim.id); const engAfterDelete = await getEngagement(redteamToken, eng.id); expect(engAfterDelete.status).toBe('active'); await deleteEngagement(redteamToken, eng.id); }); test('AC-19.4 — frontend invalidates engagement cache after simulation PATCH (badge updates without reload)', async ({ page, context, }) => { const eng = await createEngagement(redteamToken, { name: 'US-19 frontend cache', start_date: '2026-01-01', }); const sim = await createSimulation(redteamToken, eng.id, 'AC-19.4 cache'); await seedTokenInStorage(context, redteamToken); // Navigate to engagement detail — engagement shows "planned" await page.goto(`/engagements/${eng.id}`); // Status badge should be planned initially const statusBadge = page.getByTestId('engagement-status-badge').first(); if (await statusBadge.count() > 0) { await expect(statusBadge).toContainText(/planned/i); } // Now trigger in_progress via form await page.goto(`/engagements/${eng.id}/simulations/${sim.id}/edit`); const nameField = page.locator('#sim-name'); await nameField.fill('trigger auto-active'); await page.getByRole('button', { name: /save/i }).first().click(); // Navigate back to engagement — status should now show active (cache invalidated) await page.goto(`/engagements/${eng.id}`); await page.waitForLoadState('networkidle'); await expect(page.getByText(/active/i).first()).toBeVisible({ timeout: 5_000 }); await deleteSimulation(redteamToken, sim.id); await deleteEngagement(redteamToken, eng.id); }); });