diff --git a/app/debrief_svc.py b/app/debrief_svc.py index daf4c17..c956782 100644 --- a/app/debrief_svc.py +++ b/app/debrief_svc.py @@ -25,14 +25,14 @@ async def build_steps_d3(self, operation_ids): for operation in operations: # Add operation node - graph_output['nodes'].append(dict(name=operation.name, type='operation', id=op_id, img='operation', + graph_output['nodes'].append(dict(name=operation.name, type='operation', id=operation.id, img='operation', timestamp=self._format_timestamp(operation.created))) # Add agents for this operation agents = [x for x in operation.agents if x] self._add_agents_to_d3(agents, id_store, graph_output) for agent in agents: - graph_output['links'].append(dict(source=op_id, + graph_output['links'].append(dict(source=operation.id, target=id_store['agent' + agent.unique], type='has_agent')) @@ -42,12 +42,12 @@ async def build_steps_d3(self, operation_ids): link_graph_id = id_store['link' + link.unique] = max(id_store.values()) + 1 display_name = link.ability.name + (' (cleanup)' if link.cleanup else '') graph_output['nodes'].append(dict(type='link', name='link:'+link.unique, id=link_graph_id, - status=link.status, operation=op_id, img=link.ability.tactic, + status=link.status, operation=operation.id, img=link.ability.tactic, attrs=dict(status=link.status, name=display_name), timestamp=self._format_timestamp(link.created))) if not previous_link_graph_id: - graph_output['links'].append(dict(source=op_id, target=link_graph_id, type='next_link')) + graph_output['links'].append(dict(source=operation.id, target=link_graph_id, type='next_link')) else: graph_output['links'].append(dict(source=previous_link_graph_id, target=link_graph_id, type='next_link')) diff --git a/app/objects/c_story.py b/app/objects/c_story.py index 0109405..b1a6023 100644 --- a/app/objects/c_story.py +++ b/app/objects/c_story.py @@ -99,12 +99,16 @@ def adjust_icon_svgs(path): for icon_svg in svg.getroot().iter("{http://www.w3.org/2000/svg}svg"): if icon_svg.get('id') == 'copy-svg': continue - viewbox = [int(float(val)) for val in icon_svg.get('viewBox').split()] + viewbox_attr = icon_svg.get('viewBox') + if not viewbox_attr: + continue + viewbox = [int(float(val)) for val in viewbox_attr.split()] aspect = viewbox[2] / viewbox[3] icon_svg.set('width', str(round(float(icon_svg.get('height')) * aspect))) if not icon_svg.get('id') or 'legend' not in icon_svg.get('id'): icon_svg.set('x', '-' + str(int(icon_svg.get('width')) / 2)) - svg.write(open(path, 'wb')) + with open(path, 'wb') as f: + svg.write(f) @staticmethod def get_table_object(val): diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a236d3 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "debrief-e2e-tests", + "version": "1.0.0", + "private": true, + "scripts": { + "test:e2e": "npx playwright test", + "test:e2e:headed": "npx playwright test --headed", + "test:e2e:debug": "npx playwright test --debug" + }, + "devDependencies": { + "@playwright/test": "^1.49.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..cf06061 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,36 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); + +const CALDERA_URL = process.env.CALDERA_URL || 'http://localhost:8888'; +const CALDERA_USER = process.env.CALDERA_USER || (process.env.CI ? undefined : 'admin'); +const CALDERA_PASS = process.env.CALDERA_PASS || (process.env.CI ? undefined : 'admin'); + +if (process.env.CI && (!CALDERA_USER || !CALDERA_PASS)) { + throw new Error('CALDERA_USER and CALDERA_PASS must be set in CI'); +} + +module.exports = defineConfig({ + testDir: './tests/e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [['html', { open: 'never' }], ['list']], + timeout: 60_000, + use: { + baseURL: CALDERA_URL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + headless: true, + httpCredentials: { + username: CALDERA_USER, + password: CALDERA_PASS, + }, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tests/e2e/debrief.spec.js b/tests/e2e/debrief.spec.js new file mode 100644 index 0000000..1c122f4 --- /dev/null +++ b/tests/e2e/debrief.spec.js @@ -0,0 +1,400 @@ +// @ts-check +const { test, expect } = require('@playwright/test'); + +const PLUGIN_ROUTE = '/#/plugins/debrief'; + +// --------------------------------------------------------------------------- +// Shared mock data +// --------------------------------------------------------------------------- +const MOCK_OPERATIONS = [ + { id: 'op-001', name: 'Test Operation Alpha', state: 'finished', planner: { name: 'atomic' }, objective: { name: 'default' }, start: '2025-01-01 00:00:00' }, + { id: 'op-002', name: 'Test Operation Beta', state: 'running', planner: { name: 'batch' }, objective: { name: 'custom-obj' }, start: '2025-01-02 12:00:00' }, +]; + +const MOCK_REPORT = { + operations: [ + { + name: 'Test Operation Alpha', + state: 'finished', + planner: { name: 'atomic' }, + objective: { name: 'default' }, + start: '2025-01-01 00:00:00', + host_group: [ + { paw: 'abc123', host: 'workstation1', platform: 'windows', username: 'admin', privilege: 'Elevated', exe_name: 'sandcat.exe' }, + ], + chain: [ + { id: 'link-1', status: 0, finish: '2025-01-01 00:01:00', ability: { name: 'whoami' }, paw: 'abc123', command: 'd2hvYW1p' }, + ], + }, + ], + ttps: {}, +}; + +const MOCK_SECTIONS = { + report_sections: [ + { key: 'main-summary', name: 'Main Summary' }, + { key: 'statistics', name: 'Statistics' }, + { key: 'agents', name: 'Agents' }, + { key: 'attackpath-graph', name: 'Attack Path Graph' }, + { key: 'steps-graph', name: 'Steps Graph' }, + { key: 'tactic-graph', name: 'Tactic Graph' }, + { key: 'technique-graph', name: 'Technique Graph' }, + { key: 'fact-graph', name: 'Fact Graph' }, + { key: 'tactic-technique-table', name: 'Tactic/Technique Table' }, + { key: 'steps-table', name: 'Steps Table' }, + { key: 'facts-table', name: 'Facts Table' }, + ], +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Mock all standard debrief API routes. Pass `overrides` to replace or add + * route handlers (keyed by glob pattern). + */ +async function mockDebriefRoutes(page, overrides = {}) { + const defaults = { + '**/api/v2/operations': (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_OPERATIONS) }), + '**/plugin/debrief/report': (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_REPORT) }), + '**/plugin/debrief/graph**': (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [], links: [] }) }), + '**/plugin/debrief/sections': (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_SECTIONS) }), + '**/plugin/debrief/logos': (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ logos: [] }) }), + }; + const routes = { ...defaults, ...overrides }; + for (const [pattern, handler] of Object.entries(routes)) { + await page.route(pattern, handler); + } +} + +/** Navigate to the debrief plugin page and wait for the heading to appear. */ +async function navigateToDebrief(page) { + await page.goto(PLUGIN_ROUTE, { waitUntil: 'domcontentloaded' }); + await page.locator('h2', { hasText: 'Debrief' }).waitFor({ state: 'visible', timeout: 15_000 }); +} + +/** Select the first operation from the dropdown and wait for the report to load. */ +async function selectFirstOperation(page) { + const select = page.locator('select').first(); + await select.selectOption({ index: 1 }); + // Wait for the report API response to arrive + await page.waitForResponse((resp) => resp.url().includes('/plugin/debrief/report'), { timeout: 10_000 }).catch(() => {}); +} + +// =========================================================================== +// 1. Plugin page loads +// =========================================================================== +test.describe('Debrief plugin page load', () => { + test('should display the Debrief heading', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('h2', { hasText: 'Debrief' })).toBeVisible(); + }); + + test('should display campaign analytics description', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('text=Campaign Analytics')).toBeVisible({ timeout: 15_000 }); + }); + + test('should render Graph Settings button', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('button', { hasText: 'Graph Settings' })).toBeVisible({ timeout: 15_000 }); + }); + + test('should render Download PDF Report button', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('button', { hasText: 'Download PDF Report' })).toBeVisible({ timeout: 15_000 }); + }); + + test('should render Download Operation JSON button', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('button', { hasText: 'Download Operation JSON' })).toBeVisible({ timeout: 15_000 }); + }); + + test('should have an operation select dropdown', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('select').first()).toBeVisible({ timeout: 15_000 }); + }); + + test('should have a horizontal rule separator', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('hr').first()).toBeVisible({ timeout: 15_000 }); + }); +}); + +// =========================================================================== +// 2. Operation selection for debrief +// =========================================================================== +test.describe('Debrief operation selection', () => { + test('buttons should be disabled when no operation is selected', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('button', { hasText: 'Download PDF Report' })).toBeDisabled({ timeout: 15_000 }); + await expect(page.locator('button', { hasText: 'Download Operation JSON' })).toBeDisabled(); + await expect(page.locator('button', { hasText: 'Graph Settings' })).toBeDisabled(); + }); + + test('operations dropdown should populate from API', async ({ page }) => { + await mockDebriefRoutes(page); + await navigateToDebrief(page); + const options = page.locator('select option'); + // 1 disabled placeholder + 2 mock operations + await expect(options).toHaveCount(3, { timeout: 15_000 }); + }); + + test('selecting an operation should trigger report loading', async ({ page }) => { + let reportRequested = false; + await mockDebriefRoutes(page, { + '**/plugin/debrief/report': (route) => { + reportRequested = true; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_REPORT) }); + }, + }); + await navigateToDebrief(page); + await selectFirstOperation(page); + expect(reportRequested).toBeTruthy(); + }); +}); + +// =========================================================================== +// 3. Report section display (tabs: stats, agents, steps, tactics, facts) +// =========================================================================== +test.describe('Debrief report section display', () => { + test('should show tabs when an operation is selected', async ({ page }) => { + await mockDebriefRoutes(page); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await expect(page.locator('a', { hasText: 'Stats' })).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('a', { hasText: 'Agents' })).toBeVisible(); + await expect(page.locator('a', { hasText: 'Steps' })).toBeVisible(); + await expect(page.locator('a', { hasText: 'Tactics & Techniques' })).toBeVisible(); + await expect(page.locator('a', { hasText: 'Fact Graph' })).toBeVisible(); + }); + + test('Stats tab should show operation statistics table headers', async ({ page }) => { + await mockDebriefRoutes(page); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await expect(page.locator('th', { hasText: 'Name' }).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('th', { hasText: 'State' }).first()).toBeVisible(); + await expect(page.locator('th', { hasText: 'Planner' }).first()).toBeVisible(); + await expect(page.locator('th', { hasText: 'Objective' }).first()).toBeVisible(); + await expect(page.locator('th', { hasText: 'Time' }).first()).toBeVisible(); + }); +}); + +// =========================================================================== +// 4. PDF download button +// =========================================================================== +test.describe('Debrief PDF download', () => { + test('PDF button should be disabled without operation selection', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('button', { hasText: 'Download PDF Report' })).toBeDisabled({ timeout: 15_000 }); + }); + + test('clicking PDF button should open report modal', async ({ page }) => { + await mockDebriefRoutes(page); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Download PDF Report' }).click(); + await expect(page.locator('.modal.is-active')).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('text=Download Report as PDF')).toBeVisible(); + }); + + test('report modal should have Report Sections heading', async ({ page }) => { + await mockDebriefRoutes(page); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Download PDF Report' }).click(); + await expect(page.locator('h5', { hasText: 'Report Sections' })).toBeVisible({ timeout: 5_000 }); + }); + + test('report modal should show custom logo checkbox', async ({ page }) => { + await mockDebriefRoutes(page); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Download PDF Report' }).click(); + await expect(page.locator('text=Use custom logo')).toBeVisible({ timeout: 5_000 }); + }); + + test('report modal Download button triggers PDF API call', async ({ page }) => { + let pdfRequested = false; + await mockDebriefRoutes(page, { + '**/plugin/debrief/pdf': (route) => { + pdfRequested = true; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ filename: 'debrief-report.pdf', pdf_bytes: '' }) }); + }, + }); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Download PDF Report' }).click(); + await page.locator('.modal.is-active').waitFor({ state: 'visible', timeout: 5_000 }); + const downloadBtn = page.locator('.modal.is-active button', { hasText: 'Download' }); + await downloadBtn.click(); + await page.waitForResponse((resp) => resp.url().includes('/plugin/debrief/pdf'), { timeout: 10_000 }).catch(() => {}); + expect(pdfRequested).toBeTruthy(); + }); +}); + +// =========================================================================== +// 5. JSON export +// =========================================================================== +test.describe('Debrief JSON export', () => { + test('JSON button should be disabled without operation selection', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('button', { hasText: 'Download Operation JSON' })).toBeDisabled({ timeout: 15_000 }); + }); + + test('clicking JSON button should trigger JSON API call', async ({ page }) => { + let jsonRequested = false; + await mockDebriefRoutes(page, { + '**/plugin/debrief/json': (route) => { + jsonRequested = true; + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ filename: 'debrief-report', operations: [] }) }); + }, + }); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Download Operation JSON' }).click(); + await page.waitForResponse((resp) => resp.url().includes('/plugin/debrief/json'), { timeout: 10_000 }).catch(() => {}); + expect(jsonRequested).toBeTruthy(); + }); +}); + +// =========================================================================== +// 6. D3 graph rendering +// =========================================================================== +test.describe('Debrief D3 graph rendering', () => { + test('should have SVG containers for all graph types', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('#debrief-steps-svg')).toBeAttached({ timeout: 15_000 }); + await expect(page.locator('#debrief-attackpath-svg')).toBeAttached(); + await expect(page.locator('#debrief-tactic-svg')).toBeAttached(); + await expect(page.locator('#debrief-technique-svg')).toBeAttached(); + }); + + test('should have graph type selector with all options', async ({ page }) => { + await navigateToDebrief(page); + const graphSelect = page.locator('select', { has: page.locator('option[value="attackpath"]') }); + await expect(graphSelect).toBeAttached({ timeout: 15_000 }); + await expect(page.locator('option[value="steps"]')).toBeAttached(); + await expect(page.locator('option[value="tactic"]')).toBeAttached(); + await expect(page.locator('option[value="technique"]')).toBeAttached(); + }); + + test('should have fact graph SVG container', async ({ page }) => { + await navigateToDebrief(page); + await expect(page.locator('#debrief-fact-svg')).toBeAttached({ timeout: 15_000 }); + }); + + test('should have playback control buttons', async ({ page }) => { + await navigateToDebrief(page); + // Playback buttons: fast-backward, backward, play/pause, forward, fast-forward, plus legend toggle + const buttons = page.locator('#debrief-graph .buttons button'); + await expect(buttons).toHaveCount(6, { timeout: 15_000 }); + }); + + test('should have a legend toggle button', async ({ page }) => { + await navigateToDebrief(page); + const legendBtn = page.locator('button', { hasText: /Legend/ }); + await expect(legendBtn).toBeAttached({ timeout: 15_000 }); + }); + + test('graph settings modal should show display options', async ({ page }) => { + await mockDebriefRoutes(page); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Graph Settings' }).click(); + await expect(page.locator('.modal.is-active')).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('text=Display Options')).toBeVisible(); + await expect(page.locator('text=Show labels')).toBeVisible(); + await expect(page.locator('text=Show icons')).toBeVisible(); + await expect(page.locator('text=Data Options')).toBeVisible(); + await expect(page.locator('text=Show operation steps')).toBeVisible(); + await expect(page.locator('text=Show steps as tactics')).toBeVisible(); + }); +}); + +// =========================================================================== +// 7. Error states +// =========================================================================== +test.describe('Debrief error states', () => { + test('should handle no operations gracefully', async ({ page }) => { + await mockDebriefRoutes(page, { + '**/api/v2/operations': (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }), + }); + await navigateToDebrief(page); + await expect(page.locator('h2', { hasText: 'Debrief' })).toBeVisible(); + const options = page.locator('select option:not([disabled])'); + await expect(options).toHaveCount(0); + }); + + test('should handle operations API failure gracefully', async ({ page }) => { + await mockDebriefRoutes(page, { + '**/api/v2/operations': (route) => + route.fulfill({ status: 500, body: 'Server Error' }), + }); + await navigateToDebrief(page); + await expect(page.locator('h2', { hasText: 'Debrief' })).toBeVisible(); + }); + + test('should handle report API failure gracefully', async ({ page }) => { + await mockDebriefRoutes(page, { + '**/plugin/debrief/report': (route) => + route.fulfill({ status: 500, body: 'Report generation failed' }), + }); + await navigateToDebrief(page); + await selectFirstOperation(page); + await expect(page.locator('h2', { hasText: 'Debrief' })).toBeVisible(); + }); + + test('should handle PDF download failure gracefully', async ({ page }) => { + await mockDebriefRoutes(page, { + '**/plugin/debrief/pdf': (route) => + route.fulfill({ status: 500, body: 'PDF generation failed' }), + }); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Download PDF Report' }).click(); + await page.locator('.modal.is-active').waitFor({ state: 'visible', timeout: 5_000 }); + const downloadBtn = page.locator('.modal.is-active button', { hasText: 'Download' }); + await downloadBtn.click(); + await page.waitForResponse((resp) => resp.url().includes('/plugin/debrief/pdf'), { timeout: 10_000 }).catch(() => {}); + await expect(page.locator('h2', { hasText: 'Debrief' })).toBeVisible(); + }); + + test('should handle JSON export failure gracefully', async ({ page }) => { + await mockDebriefRoutes(page, { + '**/plugin/debrief/json': (route) => + route.fulfill({ status: 500, body: 'JSON export failed' }), + }); + await navigateToDebrief(page); + await selectFirstOperation(page); + + await page.locator('button', { hasText: 'Download Operation JSON' }).click(); + await page.waitForResponse((resp) => resp.url().includes('/plugin/debrief/json'), { timeout: 10_000 }).catch(() => {}); + await expect(page.locator('h2', { hasText: 'Debrief' })).toBeVisible(); + }); + + test('should handle network timeout on operations API', async ({ page }) => { + await mockDebriefRoutes(page, { + '**/api/v2/operations': (route) => route.abort('timedout'), + }); + await navigateToDebrief(page); + await expect(page.locator('h2', { hasText: 'Debrief' })).toBeVisible(); + }); +});