Files
mimic/e2e/tests/us31-export-robustness.spec.ts

189 lines
5.7 KiB
TypeScript
Raw Normal View History

/**
* 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,
} from '../fixtures/api';
const CSV_HEADER_COLS = [
'Scénario',
'Test',
'Source de log',
'Commentaires SOC',
'Exécution',
'Logs remontés au SIEM',
'Cyber incident',
];
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();
// Engagement header section present
expect(text).toContain('US31 empty engagement');
// With 0 simulations the GFM table is absent (no rows to render)
expect(text).not.toContain('| Scénario |');
} 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 with exactly the 7 FR columns
const headerCells = text.trim().split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
expect(headerCells).toEqual(CSV_HEADER_COLS);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
});