/** * US-4 — engagement CRUD + RBAC + UI surfaces. * Covers AC-4.1 → AC-4.9. */ import { test, expect } from '@playwright/test'; import { adminToken, createEngagement, deleteAllEngagements, deleteEngagement, deleteUserByUsername, ensureUser, listEngagements, login, makeClient, } from '../fixtures/api'; import { seedTokenInStorage } from '../fixtures/auth'; const REDTEAM_USER = 'us4-redteam'; const SOC_USER = 'us4-soc'; const PASS = 'us4-pass-strong'; test.describe('US-4 — engagement CRUD', () => { let redteamToken: string; let socToken: string; 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; // Clean slate so AC-4.7 list assertions are predictable. await deleteAllEngagements(await adminToken()); }); test.afterAll(async () => { try { const tok = await adminToken(); await deleteAllEngagements(tok); for (const u of [REDTEAM_USER, SOC_USER]) { await deleteUserByUsername(tok, u); } } catch { /* noop */ } }); test('AC-4.1 — GET /api/engagements returns serialized list (created_by = {id, username})', async () => { const seeded = await createEngagement(redteamToken, { name: 'AC-4.1 sample', start_date: '2026-02-01', }); const items = await listEngagements(redteamToken); const row = items.find((i) => i.id === seeded.id); expect(row).toBeTruthy(); expect(row).toMatchObject({ name: 'AC-4.1 sample', status: 'planned', start_date: '2026-02-01', }); expect(row!.created_by).toMatchObject({ username: REDTEAM_USER }); expect(typeof row!.created_by!.id).toBe('number'); }); test('AC-4.2 — POST validates name/dates/status', async () => { const client = makeClient(redteamToken); const blankName = await client.post('/engagements', { name: '', start_date: '2026-03-01', }); expect(blankName.status).toBe(400); const noStart = await client.post('/engagements', { name: 'x' }); expect(noStart.status).toBe(400); const badDate = await client.post('/engagements', { name: 'x', start_date: 'not-a-date', }); expect(badDate.status).toBe(400); const endBeforeStart = await client.post('/engagements', { name: 'x', start_date: '2026-04-10', end_date: '2026-04-01', }); expect(endBeforeStart.status).toBe(400); const badStatus = await client.post('/engagements', { name: 'x', start_date: '2026-04-01', status: 'frozen', }); expect(badStatus.status).toBe(400); const defaultStatus = await client.post('/engagements', { name: 'AC-4.2 default-status', start_date: '2026-04-01', }); expect(defaultStatus.status).toBe(201); expect(defaultStatus.data.status).toBe('planned'); }); test('AC-4.3 — GET /api/engagements/ returns 200 + object, 404 if unknown', async () => { const seeded = await createEngagement(redteamToken, { name: 'AC-4.3 sample', start_date: '2026-05-01', }); const client = makeClient(redteamToken); const ok = await client.get(`/engagements/${seeded.id}`); expect(ok.status).toBe(200); expect(ok.data.id).toBe(seeded.id); const missing = await client.get('/engagements/999999'); expect(missing.status).toBe(404); }); test('AC-4.4 — PATCH (admin/redteam) updates fields', async () => { const seeded = await createEngagement(redteamToken, { name: 'AC-4.4 orig', start_date: '2026-06-01', }); const client = makeClient(redteamToken); const r = await client.patch(`/engagements/${seeded.id}`, { name: 'AC-4.4 updated', status: 'active', end_date: '2026-06-15', }); expect(r.status).toBe(200); expect(r.data).toMatchObject({ name: 'AC-4.4 updated', status: 'active', end_date: '2026-06-15', }); }); test('AC-4.5 — DELETE (admin/redteam) returns 204', async () => { const seeded = await createEngagement(redteamToken, { name: 'AC-4.5 disposable', start_date: '2026-07-01', }); const client = makeClient(redteamToken); const r = await client.delete(`/engagements/${seeded.id}`); expect(r.status).toBe(204); const after = await client.get(`/engagements/${seeded.id}`); expect(after.status).toBe(404); }); test('AC-4.6 — soc can read but not write (403 on POST/PATCH/DELETE)', async () => { const socClient = makeClient(socToken); const list = await socClient.get('/engagements'); expect(list.status).toBe(200); const post = await socClient.post('/engagements', { name: 'soc-blocked', start_date: '2026-08-01', }); expect(post.status).toBe(403); // Seed via redteam to get a target id. const target = await createEngagement(redteamToken, { name: 'AC-4.6 target', start_date: '2026-08-15', }); const patch = await socClient.patch(`/engagements/${target.id}`, { name: 'soc-edit' }); expect(patch.status).toBe(403); const del = await socClient.delete(`/engagements/${target.id}`); expect(del.status).toBe(403); // Clean up via redteam. await deleteEngagement(redteamToken, target.id); }); test('AC-4.7 — /engagements page lists rows with required columns + role-aware buttons', async ({ page, context, }) => { // Seed one row visible to the redteam user. await createEngagement(redteamToken, { name: 'UI list sample', start_date: '2026-09-01', status: 'active', }); await seedTokenInStorage(context, redteamToken); await page.goto('/engagements'); await expect(page.getByRole('heading', { name: /^engagements$/i })).toBeVisible(); // Column headers for (const h of ['Name', 'Status', 'Start', 'End', 'Created by']) { await expect(page.getByRole('columnheader', { name: new RegExp(h, 'i') })).toBeVisible(); } // The row + status badge + created_by visible const row = page.getByRole('row', { name: /UI list sample/i }); await expect(row).toBeVisible(); await expect(row.getByText(REDTEAM_USER)).toBeVisible(); // Redteam sees the action buttons. await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible(); await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible(); await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible(); // Soc should NOT see write buttons. await seedTokenInStorage(context, socToken); await page.goto('/engagements'); const rowAsSoc = page.getByRole('row', { name: /UI list sample/i }); await expect(rowAsSoc).toBeVisible(); await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0); await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0); await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0); }); test('AC-4.8 — /engagements/new form: client validation + API error display', async ({ page, context, }) => { await seedTokenInStorage(context, redteamToken); await page.goto('/engagements/new'); await expect(page.getByRole('heading', { name: /new engagement/i })).toBeVisible(); // Submit empty → client-side errors visible. await page.getByRole('button', { name: /create engagement/i }).click(); await expect(page.getByText(/name is required/i)).toBeVisible(); await expect(page.getByText(/start date is required/i)).toBeVisible(); // Fill bad date order → client validation flags end_date. await page.fill('#eng-name', 'UI form test'); await page.fill('#eng-start', '2026-10-10'); await page.fill('#eng-end', '2026-10-01'); await page.getByRole('button', { name: /create engagement/i }).click(); await expect(page.getByText(/end date must be on or after start date/i)).toBeVisible(); // Fix dates → submit succeeds, redirects to detail. await page.fill('#eng-end', '2026-10-20'); await page.getByRole('button', { name: /create engagement/i }).click(); await page.waitForURL(/\/engagements\/\d+$/); await expect(page.getByRole('heading', { name: /UI form test/i })).toBeVisible(); // Edit path: navigate to /edit and tweak. const detailUrl = page.url(); const id = Number(detailUrl.split('/').pop()); await page.goto(`/engagements/${id}/edit`); await expect(page.getByRole('heading', { name: /edit engagement/i })).toBeVisible(); await page.fill('#eng-name', 'UI form test (edited)'); await page.getByRole('button', { name: /save changes/i }).click(); await page.waitForURL(new RegExp(`/engagements/${id}$`)); await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible(); }); test('AC-4.9 — /engagements/ detail page shows Sprint 2 placeholder', async ({ page, context, }) => { const seeded = await createEngagement(redteamToken, { name: 'AC-4.9 detail target', start_date: '2026-11-01', description: 'A description for detail rendering.', }); await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${seeded.id}`); await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible(); await expect( page.getByText(/simulations à venir au sprint 2/i), ).toBeVisible(); }); });