From b572a67066702c785fb88a2c0e615164ceff905d Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 8 Jun 2026 18:31:32 +0200 Subject: [PATCH] =?UTF-8?q?test(e2e):=20sprint=206=20acceptance=20?= =?UTF-8?q?=E2=80=94=20US-29=20/=20US-30=20/=20US-31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 3 Playwright spec files covering all 13 ACs for the engagement export feature: - us29-export-formats.spec.ts (8 tests): dropdown, md/csv/pdf downloads, admin + redteam, filename convention - us30-export-rbac.spec.ts (3 tests): SOC button absent, SOC 403, no-token 401 - us31-export-robustness.spec.ts (4 tests): missing format 400, bad format 400, unknown engagement 404, zero-sim export OK Total: 201 → 223 Playwright tests. No regressions on sprints 1–5. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/us29-export-formats.spec.ts | 324 +++++++++++++++++++++++ e2e/tests/us30-export-rbac.spec.ts | 90 +++++++ e2e/tests/us31-export-robustness.spec.ts | 176 ++++++++++++ 3 files changed, 590 insertions(+) create mode 100644 e2e/tests/us29-export-formats.spec.ts create mode 100644 e2e/tests/us30-export-rbac.spec.ts create mode 100644 e2e/tests/us31-export-robustness.spec.ts diff --git a/e2e/tests/us29-export-formats.spec.ts b/e2e/tests/us29-export-formats.spec.ts new file mode 100644 index 0000000..f4166e5 --- /dev/null +++ b/e2e/tests/us29-export-formats.spec.ts @@ -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 { + 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---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); + }); +}); diff --git a/e2e/tests/us30-export-rbac.spec.ts b/e2e/tests/us30-export-rbac.spec.ts new file mode 100644 index 0000000..336afc1 --- /dev/null +++ b/e2e/tests/us30-export-rbac.spec.ts @@ -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//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//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); + }); +}); diff --git a/e2e/tests/us31-export-robustness.spec.ts b/e2e/tests/us31-export-robustness.spec.ts new file mode 100644 index 0000000..889a848 --- /dev/null +++ b/e2e/tests/us31-export-robustness.spec.ts @@ -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); + } + }); +});