Files
mimic/e2e/tests/us29-export-formats.spec.ts
Knacky aeb4bdb025 test(e2e): adapt export specs to 7-column schema (Scénario/Test/...)
Update AC-29.2 (Markdown) to assert | Scénario | GFM table header.
Update AC-29.3 (CSV) to assert exact 7 FR column names instead of 'name'.
Update AC-31.4 (empty engagement) MD to assert table absent, CSV header
to assert exact 7 FR columns.
Drop unused sim1/sim2 vars and makeClient import (NIT cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:21:51 +02:00

335 lines
11 KiB
TypeScript

/**
* 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<Simulation> {
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-<id>-<slug>-YYYYMMDD.{ext}
test('AC-29.6 — filename matches engagement-<id>-<slug>-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-<id>-<slug>-YYYYMMDD.md
const filenamePattern = new RegExp(
`^engagement-${engagement.id}-[a-z0-9-]+-\\d{8}\\.md$`,
);
expect(suggestedName).toMatch(filenamePattern);
});
});