feat: sprint 6 — engagement export (md/csv/pdf) #9
324
e2e/tests/us29-export-formats.spec.ts
Normal file
324
e2e/tests/us29-export-formats.spec.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
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;
|
||||||
|
let sim1: Simulation;
|
||||||
|
let sim2: Simulation;
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
sim1 = await createSimulation(adminTok, engagement.id, 'US29 Sim Alpha');
|
||||||
|
sim2 = 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
|
||||||
|
expect(content).toContain('US29 Export Engagement');
|
||||||
|
// Must contain simulation names
|
||||||
|
expect(content).toContain('US29 Sim Alpha');
|
||||||
|
expect(content).toContain('US29 Sim Beta');
|
||||||
|
// Must contain start date
|
||||||
|
expect(content).toContain('2026-01-15');
|
||||||
|
|
||||||
|
// 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 mention 'name' column
|
||||||
|
expect(rows.headerLine).toContain('name');
|
||||||
|
|
||||||
|
// Simulation data rows must contain 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
90
e2e/tests/us30-export-rbac.spec.ts
Normal file
90
e2e/tests/us30-export-rbac.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* US-30 — SOC role has zero access to the export feature.
|
||||||
|
*
|
||||||
|
* AC-30.1: SOC login → Export button absent from DOM (not just hidden).
|
||||||
|
* AC-30.2: Direct API call with SOC Bearer → 403.
|
||||||
|
* AC-30.3: Direct API call without token → 401.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
adminToken,
|
||||||
|
createEngagement,
|
||||||
|
deleteEngagement,
|
||||||
|
deleteUserByUsername,
|
||||||
|
ensureUser,
|
||||||
|
login,
|
||||||
|
type Engagement,
|
||||||
|
} from '../fixtures/api';
|
||||||
|
import { seedTokenInStorage } from '../fixtures/auth';
|
||||||
|
|
||||||
|
const SOC_USER = 'us30-soc';
|
||||||
|
const ADMIN_USER = 'us30-admin';
|
||||||
|
const PASS = 'us30-pass-strong!';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||||
|
|
||||||
|
test.describe('US-30 — SOC zero access to export', () => {
|
||||||
|
let socTok: string;
|
||||||
|
let adminTok: string;
|
||||||
|
let engagement: Engagement;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await ensureUser(SOC_USER, PASS, 'soc');
|
||||||
|
await ensureUser(ADMIN_USER, PASS, 'admin');
|
||||||
|
socTok = (await login(SOC_USER, PASS)).token;
|
||||||
|
adminTok = (await login(ADMIN_USER, PASS)).token;
|
||||||
|
|
||||||
|
engagement = await createEngagement(adminTok, {
|
||||||
|
name: 'US30 RBAC Engagement',
|
||||||
|
start_date: '2026-01-01',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
await deleteEngagement(adminTok, engagement.id);
|
||||||
|
const rootTok = await adminToken();
|
||||||
|
for (const u of [SOC_USER, ADMIN_USER]) await deleteUserByUsername(rootTok, u);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-30.1 — SOC: Export button absent from DOM
|
||||||
|
test('AC-30.1 — SOC login: Export dropdown is NOT attached to the DOM', async ({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
await seedTokenInStorage(context, socTok);
|
||||||
|
await page.goto(`/engagements/${engagement.id}`);
|
||||||
|
|
||||||
|
// Wait for the page to fully load (engagement header should be visible)
|
||||||
|
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// The export dropdown wrapper must not be in the DOM at all
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="export-dropdown"]'),
|
||||||
|
).not.toBeAttached();
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-30.2 — SOC Bearer token → 403
|
||||||
|
test('AC-30.2 — SOC Bearer: GET /api/engagements/<id>/export?format=md → 403', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${socTok}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-30.3 — No token → 401
|
||||||
|
test('AC-30.3 — No token: GET /api/engagements/<id>/export?format=md → 401', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
176
e2e/tests/us31-export-robustness.spec.ts
Normal file
176
e2e/tests/us31-export-robustness.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* US-31 — Export robustness: format validation and edge cases.
|
||||||
|
*
|
||||||
|
* All 4 ACs use direct API calls (no UI needed) for speed and determinism.
|
||||||
|
*
|
||||||
|
* AC-31.1: missing ?format → 400 friendly message
|
||||||
|
* AC-31.2: ?format=xml → 400 friendly message
|
||||||
|
* AC-31.3: engagement 99999 → 404
|
||||||
|
* AC-31.4: engagement with 0 simulations → export OK (CSV = 1 header row only)
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
adminToken,
|
||||||
|
createEngagement,
|
||||||
|
deleteEngagement,
|
||||||
|
deleteUserByUsername,
|
||||||
|
ensureUser,
|
||||||
|
login,
|
||||||
|
makeClient,
|
||||||
|
} from '../fixtures/api';
|
||||||
|
|
||||||
|
const ADMIN_USER = 'us31-admin';
|
||||||
|
const PASS = 'us31-pass-strong!';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-4180-aware row counter — same logic as us29 helper.
|
||||||
|
* Embedded newlines inside quoted cells are not counted as row breaks.
|
||||||
|
*/
|
||||||
|
function countCsvRows(csv: string): number {
|
||||||
|
let inQuote = false;
|
||||||
|
let rowCount = 0;
|
||||||
|
let lineStart = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < csv.length; i++) {
|
||||||
|
const ch = csv[i];
|
||||||
|
if (ch === '"') {
|
||||||
|
if (inQuote && csv[i + 1] === '"') {
|
||||||
|
i++;
|
||||||
|
} 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) rowCount++;
|
||||||
|
lineStart = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tail = csv.slice(lineStart).trim();
|
||||||
|
if (tail.length > 0) rowCount++;
|
||||||
|
return rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('US-31 — Export robustness', () => {
|
||||||
|
let adminTok: string;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await ensureUser(ADMIN_USER, PASS, 'admin');
|
||||||
|
adminTok = (await login(ADMIN_USER, PASS)).token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
try {
|
||||||
|
const rootTok = await adminToken();
|
||||||
|
await deleteUserByUsername(rootTok, ADMIN_USER);
|
||||||
|
} catch { /* noop */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-31.1 — missing format → 400
|
||||||
|
test('AC-31.1 — GET /export without format → 400 with friendly error', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Need a valid engagement id — use admin to create one transiently
|
||||||
|
const engagement = await createEngagement(adminTok, {
|
||||||
|
name: 'US31 missing format eng',
|
||||||
|
start_date: '2026-01-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get(
|
||||||
|
`${BASE_URL}/api/engagements/${engagement.id}/export`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toHaveProperty('error');
|
||||||
|
expect(body.error).toMatch(/format/i);
|
||||||
|
} finally {
|
||||||
|
await deleteEngagement(adminTok, engagement.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-31.2 — unknown format → 400
|
||||||
|
test('AC-31.2 — GET /export?format=xml → 400 with friendly error', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const engagement = await createEngagement(adminTok, {
|
||||||
|
name: 'US31 bad format eng',
|
||||||
|
start_date: '2026-01-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get(
|
||||||
|
`${BASE_URL}/api/engagements/${engagement.id}/export?format=xml`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body).toHaveProperty('error');
|
||||||
|
expect(body.error).toMatch(/format/i);
|
||||||
|
} finally {
|
||||||
|
await deleteEngagement(adminTok, engagement.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-31.3 — unknown engagement → 404
|
||||||
|
test('AC-31.3 — GET /engagements/99999/export?format=md → 404', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const response = await request.get(
|
||||||
|
`${BASE_URL}/api/engagements/99999/export?format=md`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AC-31.4 — engagement with 0 simulations: export OK
|
||||||
|
test('AC-31.4 — engagement with 0 simulations: Markdown export OK (header only)', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const engagement = await createEngagement(adminTok, {
|
||||||
|
name: 'US31 empty engagement',
|
||||||
|
start_date: '2026-01-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get(
|
||||||
|
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const text = await response.text();
|
||||||
|
// Must contain engagement name in the header section
|
||||||
|
expect(text).toContain('US31 empty engagement');
|
||||||
|
} finally {
|
||||||
|
await deleteEngagement(adminTok, engagement.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AC-31.4 — engagement with 0 simulations: CSV export has only 1 header row', async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const engagement = await createEngagement(adminTok, {
|
||||||
|
name: 'US31 empty CSV engagement',
|
||||||
|
start_date: '2026-01-01',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get(
|
||||||
|
`${BASE_URL}/api/engagements/${engagement.id}/export?format=csv`,
|
||||||
|
{ headers: { Authorization: `Bearer ${adminTok}` } },
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const text = await response.text();
|
||||||
|
// Count rows via RFC-4180-aware counter (handles embedded newlines in quoted cells)
|
||||||
|
const rowCount = countCsvRows(text);
|
||||||
|
expect(rowCount).toBe(1);
|
||||||
|
// The single row is the header; must contain 'name' column
|
||||||
|
expect(text.trim()).toContain('name');
|
||||||
|
} finally {
|
||||||
|
await deleteEngagement(adminTok, engagement.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user