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