/** * US-29 — Admin/redteam exports an engagement in Markdown, CSV, PDF. * * Strategy: seed one engagement with 2 simulations via the API, then drive the * ExportEngagementButton dropdown in Chromium. Downloads are captured via * page.waitForEvent('download') and read back with fs.readFile. * * AC covered: 29.1 — 29.6 */ import * as fs from 'fs/promises'; import { test, expect } from '@playwright/test'; import { adminToken, createEngagement, deleteEngagement, deleteUserByUsername, ensureUser, login, makeClient, type Engagement, } from '../fixtures/api'; const CSV_HEADER_COLS = [ 'Scénario', 'Test', 'Source de log', 'Commentaires SOC', 'Exécution', 'Logs remontés au SIEM', 'Cyber incident', ]; import { seedTokenInStorage } from '../fixtures/auth'; const ADMIN_USER = 'us29-admin'; const REDTEAM_USER = 'us29-redteam'; const PASS = 'us29-pass-strong!'; interface Simulation { id: number; name: string; } /** * RFC-4180 row counter. * Walks char-by-char tracking quoting so that newlines inside quoted cells * don't count as row breaks. Returns the total row count (including header) * plus helper strings for assertions. */ function countCsvRows(csv: string): { count: number; headerLine: string; dataText: string; } { let inQuote = false; let rowCount = 0; let lineStart = 0; let headerLine = ''; for (let i = 0; i < csv.length; i++) { const ch = csv[i]; if (ch === '"') { if (inQuote && csv[i + 1] === '"') { i++; // escaped double-quote inside quoted cell } else { inQuote = !inQuote; } } else if ((ch === '\n' || ch === '\r') && !inQuote) { if (ch === '\r' && csv[i + 1] === '\n') i++; const line = csv.slice(lineStart, i).trim(); if (line.length > 0) { if (rowCount === 0) headerLine = line; rowCount++; } lineStart = i + 1; } } // trailing row without final newline const tail = csv.slice(lineStart).trim(); if (tail.length > 0) { if (rowCount === 0) headerLine = tail; rowCount++; } const dataText = rowCount > 1 ? csv.slice(headerLine.length + 1) : ''; return { count: rowCount, headerLine, dataText }; } async function createSimulation( token: string, engagementId: number, name: string, ): Promise { const r = await makeClient(token).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; } test.describe('US-29 — Export formats (admin + redteam)', () => { let adminTok: string; let redteamTok: string; let engagement: Engagement; test.beforeAll(async () => { await ensureUser(ADMIN_USER, PASS, 'admin'); await ensureUser(REDTEAM_USER, PASS, 'redteam'); adminTok = (await login(ADMIN_USER, PASS)).token; redteamTok = (await login(REDTEAM_USER, PASS)).token; engagement = await createEngagement(adminTok, { name: 'US29 Export Engagement', description: 'Export test engagement', start_date: '2026-01-15', status: 'active', }); await createSimulation(adminTok, engagement.id, 'US29 Sim Alpha'); await createSimulation(adminTok, engagement.id, 'US29 Sim Beta'); }); test.afterAll(async () => { try { await deleteEngagement(adminTok, engagement.id); const rootTok = await adminToken(); for (const u of [ADMIN_USER, REDTEAM_USER]) await deleteUserByUsername(rootTok, u); } catch { /* noop */ } }); // AC-29.1 — Export dropdown opens test('AC-29.1 — admin: Export dropdown opens with 3 format items', async ({ page, context, }) => { await seedTokenInStorage(context, adminTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); // Click the Export button to open dropdown await dropdownWrapper.locator('button').first().click(); await expect(page.getByRole('menuitem', { name: /markdown/i }).or( page.locator('[role="menuitem"]').filter({ hasText: /markdown/i }) ).first()).toBeVisible({ timeout: 5_000 }); await expect(page.getByText(/csv/i).first()).toBeVisible(); await expect(page.getByText(/pdf/i).first()).toBeVisible(); }); // AC-29.2 — Markdown download test('AC-29.2 — admin: Markdown download contains engagement name and simulation names', async ({ page, context, }) => { await seedTokenInStorage(context, adminTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); await dropdownWrapper.locator('button').first().click(); const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(), ]); const filePath = await download.path(); expect(filePath).toBeTruthy(); const content = await fs.readFile(filePath!, 'utf-8'); // Must contain engagement name and start date in the header section expect(content).toContain('US29 Export Engagement'); expect(content).toContain('2026-01-15'); // Must use the 7-column GFM table layout expect(content).toContain('| Scénario |'); // Simulation names appear in the Scénario column expect(content).toContain('US29 Sim Alpha'); expect(content).toContain('US29 Sim Beta'); // Suggested filename from Content-Disposition must end in .md const suggestedName = download.suggestedFilename(); expect(suggestedName).toMatch(/\.md$/); }); // AC-29.3 — CSV download: N+1 rows (1 header + N simulations) test('AC-29.3 — admin: CSV download has N+1 rows (header + 2 sim rows)', async ({ page, context, }) => { await seedTokenInStorage(context, adminTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); await dropdownWrapper.locator('button').first().click(); const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(), ]); const filePath = await download.path(); expect(filePath).toBeTruthy(); const raw = await fs.readFile(filePath!, 'utf-8'); // Count RFC-4180 rows: walk char-by-char, track quoting state so that // newlines embedded inside quoted cells don't count as row breaks. const rows = countCsvRows(raw); // 1 header + 2 simulation rows expect(rows.count).toBe(3); // Header must be exactly the 7 FR columns const headerCells = rows.headerLine.split(',').map((c) => c.trim().replace(/^"|"$/g, '')); expect(headerCells).toEqual(CSV_HEADER_COLS); // Scénario column (index 0) contains simulation names expect(rows.dataText).toContain('US29 Sim Alpha'); expect(rows.dataText).toContain('US29 Sim Beta'); const suggestedName = download.suggestedFilename(); expect(suggestedName).toMatch(/\.csv$/); }); // AC-29.4 — PDF download test('AC-29.4 — admin: PDF download has %PDF magic bytes and size > 1 KB', async ({ page, context, }) => { await seedTokenInStorage(context, adminTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); await dropdownWrapper.locator('button').first().click(); const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(), ]); const filePath = await download.path(); expect(filePath).toBeTruthy(); const buf = await fs.readFile(filePath!); // Magic bytes: %PDF expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF'); // Size > 1 KB expect(buf.byteLength).toBeGreaterThan(1024); const suggestedName = download.suggestedFilename(); expect(suggestedName).toMatch(/\.pdf$/); }); // AC-29.5 — Redteam: all 3 formats work test('AC-29.5 — redteam: Markdown download works', async ({ page, context }) => { await seedTokenInStorage(context, redteamTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); await dropdownWrapper.locator('button').first().click(); const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(), ]); const filePath = await download.path(); const content = await fs.readFile(filePath!, 'utf-8'); expect(content).toContain('US29 Export Engagement'); }); test('AC-29.5 — redteam: CSV download works', async ({ page, context }) => { await seedTokenInStorage(context, redteamTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); await dropdownWrapper.locator('button').first().click(); const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(), ]); const filePath = await download.path(); const raw = await fs.readFile(filePath!, 'utf-8'); const rows = countCsvRows(raw); expect(rows.count).toBeGreaterThanOrEqual(3); }); test('AC-29.5 — redteam: PDF download works', async ({ page, context }) => { await seedTokenInStorage(context, redteamTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); await dropdownWrapper.locator('button').first().click(); const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(), ]); const filePath = await download.path(); const buf = await fs.readFile(filePath!); expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF'); }); // AC-29.6 — Filename convention: engagement---YYYYMMDD.{ext} test('AC-29.6 — filename matches engagement---YYYYMMDD pattern', async ({ page, context, }) => { await seedTokenInStorage(context, adminTok); await page.goto(`/engagements/${engagement.id}`); const dropdownWrapper = page.locator('[data-testid="export-dropdown"]'); await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 }); await dropdownWrapper.locator('button').first().click(); const [download] = await Promise.all([ page.waitForEvent('download'), page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(), ]); const suggestedName = download.suggestedFilename(); // Pattern: engagement---YYYYMMDD.md const filenamePattern = new RegExp( `^engagement-${engagement.id}-[a-z0-9-]+-\\d{8}\\.md$`, ); expect(suggestedName).toMatch(filenamePattern); }); });