From cffa1aeea99f01504bc6c016e12fc62ba63977c7 Mon Sep 17 00:00:00 2001 From: debian Date: Fri, 6 Mar 2026 05:57:05 -0500 Subject: [PATCH] fase(15): reporting module with pdf generation --- .ralph/.loop_start_sha | 2 +- .ralph/fix_plan.md | 84 +++--- .ralph/progress.json | 2 +- dist/api/router.js | 2 + dist/db/migrations/005_reports_table.js | 25 ++ dist/jobs/workers/ReportWorker.js | 42 ++- dist/main.js | 19 +- .../infrastructure/http/FindingsController.js | 5 +- .../commands/GenerateReportCommand.js | 33 +++ .../reporting/domain/entities/Report.js | 59 ++++ .../reporting/domain/events/ReportFailed.js | 14 + .../domain/events/ReportGenerated.js | 14 + .../domain/events/ReportRequested.js | 14 + .../domain/ports/IReportGenerator.js | 2 + .../domain/ports/IReportRepository.js | 2 + .../domain/value-objects/DateRange.js | 14 + .../domain/value-objects/ReportFormat.js | 17 ++ .../domain/value-objects/ReportStatus.js | 18 ++ .../generators/HTMLReportGenerator.js | 138 ++++++++++ .../generators/JSONReportGenerator.js | 88 ++++++ .../generators/PDFReportGenerator.js | 81 ++++++ .../http/ReportingController.js | 134 +++++++++ .../repositories/KyselyReportRepository.js | 85 ++++++ frontend/src/App.tsx | 35 ++- .../components/findings/AIAnalysisPanel.tsx | 122 +++++++++ .../src/components/findings/EvidencePanel.tsx | 78 ++++++ .../components/findings/ReproductionSteps.tsx | 62 +++++ frontend/src/hooks/useAuth.ts | 1 + frontend/src/hooks/useFindings.ts | 10 +- frontend/src/pages/Reports.tsx | 257 ++++++++++++++++++ frontend/src/pages/findings/FindingDetail.tsx | 176 ++++++++++++ frontend/src/pages/findings/FindingsList.tsx | 184 +++++++++++++ .../src/pages/settings/ApiKeysSection.tsx | 190 +++++++++++++ .../src/pages/settings/AppearanceSection.tsx | 32 +++ .../settings/ExplorationDefaultsSection.tsx | 104 +++++++ .../src/pages/settings/LicenseSection.tsx | 32 +++ .../pages/settings/NotificationsSection.tsx | 98 +++++++ .../pages/settings/OrganizationSection.tsx | 140 ++++++++++ .../src/pages/settings/ProfileSection.tsx | 60 ++++ .../src/pages/settings/SettingsLayout.tsx | 46 ++++ frontend/src/types.ts | 34 +++ src/api/router.ts | 2 + src/api/server.ts | 2 + src/db/migrations/005_reports_table.ts | 24 ++ src/jobs/workers/ReportWorker.ts | 50 +++- src/main.ts | 29 +- .../infrastructure/http/FindingsController.ts | 5 +- .../commands/GenerateReportCommand.ts | 50 ++++ .../reporting/domain/entities/Report.ts | 90 ++++++ .../reporting/domain/events/ReportFailed.ts | 9 + .../domain/events/ReportGenerated.ts | 9 + .../domain/events/ReportRequested.ts | 9 + .../domain/ports/IReportGenerator.ts | 6 + .../domain/ports/IReportRepository.ts | 8 + .../domain/value-objects/DateRange.ts | 16 ++ .../domain/value-objects/ReportFormat.ts | 20 ++ .../domain/value-objects/ReportStatus.ts | 21 ++ .../generators/HTMLReportGenerator.ts | 110 ++++++++ .../generators/JSONReportGenerator.ts | 59 ++++ .../generators/PDFReportGenerator.ts | 48 ++++ .../http/ReportingController.ts | 131 +++++++++ .../repositories/KyselyReportRepository.ts | 84 ++++++ .../infrastructure/DatabaseConnection.ts | 14 + tests/modules/reporting.test.ts | 198 ++++++++++++++ 64 files changed, 3462 insertions(+), 87 deletions(-) create mode 100644 dist/db/migrations/005_reports_table.js create mode 100644 dist/modules/reporting/application/commands/GenerateReportCommand.js create mode 100644 dist/modules/reporting/domain/entities/Report.js create mode 100644 dist/modules/reporting/domain/events/ReportFailed.js create mode 100644 dist/modules/reporting/domain/events/ReportGenerated.js create mode 100644 dist/modules/reporting/domain/events/ReportRequested.js create mode 100644 dist/modules/reporting/domain/ports/IReportGenerator.js create mode 100644 dist/modules/reporting/domain/ports/IReportRepository.js create mode 100644 dist/modules/reporting/domain/value-objects/DateRange.js create mode 100644 dist/modules/reporting/domain/value-objects/ReportFormat.js create mode 100644 dist/modules/reporting/domain/value-objects/ReportStatus.js create mode 100644 dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js create mode 100644 dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js create mode 100644 dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js create mode 100644 dist/modules/reporting/infrastructure/http/ReportingController.js create mode 100644 dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js create mode 100644 frontend/src/components/findings/AIAnalysisPanel.tsx create mode 100644 frontend/src/components/findings/EvidencePanel.tsx create mode 100644 frontend/src/components/findings/ReproductionSteps.tsx create mode 100644 frontend/src/pages/Reports.tsx create mode 100644 frontend/src/pages/findings/FindingDetail.tsx create mode 100644 frontend/src/pages/findings/FindingsList.tsx create mode 100644 frontend/src/pages/settings/ApiKeysSection.tsx create mode 100644 frontend/src/pages/settings/AppearanceSection.tsx create mode 100644 frontend/src/pages/settings/ExplorationDefaultsSection.tsx create mode 100644 frontend/src/pages/settings/LicenseSection.tsx create mode 100644 frontend/src/pages/settings/NotificationsSection.tsx create mode 100644 frontend/src/pages/settings/OrganizationSection.tsx create mode 100644 frontend/src/pages/settings/ProfileSection.tsx create mode 100644 frontend/src/pages/settings/SettingsLayout.tsx create mode 100644 src/db/migrations/005_reports_table.ts create mode 100644 src/modules/reporting/application/commands/GenerateReportCommand.ts create mode 100644 src/modules/reporting/domain/entities/Report.ts create mode 100644 src/modules/reporting/domain/events/ReportFailed.ts create mode 100644 src/modules/reporting/domain/events/ReportGenerated.ts create mode 100644 src/modules/reporting/domain/events/ReportRequested.ts create mode 100644 src/modules/reporting/domain/ports/IReportGenerator.ts create mode 100644 src/modules/reporting/domain/ports/IReportRepository.ts create mode 100644 src/modules/reporting/domain/value-objects/DateRange.ts create mode 100644 src/modules/reporting/domain/value-objects/ReportFormat.ts create mode 100644 src/modules/reporting/domain/value-objects/ReportStatus.ts create mode 100644 src/modules/reporting/infrastructure/generators/HTMLReportGenerator.ts create mode 100644 src/modules/reporting/infrastructure/generators/JSONReportGenerator.ts create mode 100644 src/modules/reporting/infrastructure/generators/PDFReportGenerator.ts create mode 100644 src/modules/reporting/infrastructure/http/ReportingController.ts create mode 100644 src/modules/reporting/infrastructure/repositories/KyselyReportRepository.ts create mode 100644 tests/modules/reporting.test.ts diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index 46dba85..51ce34c 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -7526a5bc154e79ca03948cc30e23de24af7e18dc +3ff36f0b6a2c3e92b24febd488ef6abfe37ada6a diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 59c67c3..2763bec 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -223,65 +223,65 @@ Spec: `.ralph/specs/phase-11-dashboard.md` --- -## Phase 12: Sessions Pages [PENDIENTE] +## Phase 12: Sessions Pages [COMPLETO] Spec: `.ralph/specs/phase-12-sessions-pages.md` -- [ ] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section -- [ ] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable -- [ ] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs -- [ ] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly) -- [ ] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges -- [ ] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only -- [ ] 12.7: Progress bar estados explorados / maxStates -- [ ] 12.8: Stop button funcional (DELETE /api/sessions/:id) -- [ ] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed` +- [x] 12.1: Crear `components/sessions/NewExplorationForm.tsx` — React Hook Form + Zod: URL, seed, maxStates, maxDepth, allowedDomains (chips), excludedPaths (chips), auth type (none/cookies/headers/login_flow) con campos condicionales, fuzzing toggle + intensity, collapsible advanced section +- [x] 12.2: Crear `pages/sessions/SessionList.tsx` — TanStack Table: status badge, url, findings count, duration, created at; sortable + filterable +- [x] 12.3: Crear `pages/sessions/SessionDetail.tsx` — layout con tabs +- [x] 12.4: Crear `components/sessions/LiveFeed.tsx` — streaming WebSocket con auto-scroll, colores por event type (verde state, amarillo action, rojo anomaly) +- [x] 12.5: Crear `components/sessions/SessionFindings.tsx` — findings de esta sesión con severity badges +- [x] 12.6: Crear `components/sessions/SessionConfig.tsx` — ExplorationConfig read-only +- [x] 12.7: Progress bar estados explorados / maxStates +- [x] 12.8: Stop button funcional (DELETE /api/sessions/:id) +- [x] 12.9: Verificar frontend build + commit: `fase(12): session pages with live feed` --- -## Phase 13: Findings Pages [PENDIENTE] +## Phase 13: Findings Pages [COMPLETO] Spec: `.ralph/specs/phase-13-findings-pages.md` -- [ ] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search -- [ ] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout -- [ ] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb -- [ ] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer) -- [ ] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI" -- [ ] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON" -- [ ] 13.7: Status workflow buttons: open → investigating → resolved → closed -- [ ] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul -- [ ] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view` +- [x] 13.1: Crear `pages/findings/FindingsList.tsx` — TanStack Table con filtros: severity multi-select, type multi-select, status, session dropdown, text search +- [x] 13.2: Crear `pages/findings/FindingDetail.tsx` — split layout +- [x] 13.3: Crear `components/findings/ReproductionSteps.tsx` — numbered step cards con action type, selector, screenshot thumb +- [x] 13.4: Crear `components/findings/EvidencePanel.tsx` — tabs: Console (syntax-highlighted), Network (request/response table), DOM (snapshot viewer) +- [x] 13.5: Crear `components/findings/AIAnalysisPanel.tsx` — muestra enrichment si existe, o botón "Analyze with AI" +- [x] 13.6: Export buttons: "Export as Playwright", "Export as Markdown", "Export as JSON" +- [x] 13.7: Status workflow buttons: open → investigating → resolved → closed +- [x] 13.8: `components/common/SeverityBadge.tsx` — reutilizable con colores critical=rojo, high=naranja, medium=amarillo, low=azul +- [x] 13.9: Verificar frontend build + commit: `fase(13): findings pages with detail view` --- -## Phase 14: Settings Pages [PENDIENTE] +## Phase 14: Settings Pages [COMPLETO] Spec: `.ralph/specs/phase-14-settings-pages.md` -- [ ] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections -- [ ] 14.2: Section "Profile" — cambiar nombre, email, password -- [ ] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles -- [ ] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar -- [ ] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones -- [ ] 14.6: Section "Notifications" — Slack webhook URL, min severity -- [ ] 14.7: Section "Appearance" — tema dark/light, accent color -- [ ] 14.8: Section "License" — ver status licencia, input para activar key -- [ ] 14.9: Verificar frontend build + commit: `fase(14): settings pages` +- [x] 14.1: Crear `pages/settings/SettingsLayout.tsx` — layout con sidebar navigation entre sections +- [x] 14.2: Section "Profile" — cambiar nombre, email, password +- [x] 14.3: Section "Organization" — nombre org, invitar miembros, manage roles +- [x] 14.4: Section "API Keys" — crear (con nombre + permisos), listar, revocar +- [x] 14.5: Section "Exploration Defaults" — form con defaults para nuevas exploraciones +- [x] 14.6: Section "Notifications" — Slack webhook URL, min severity +- [x] 14.7: Section "Appearance" — tema dark/light, accent color +- [x] 14.8: Section "License" — ver status licencia, input para activar key +- [x] 14.9: Verificar frontend build + commit: `fase(14): settings pages` --- -## Phase 15: Reporting Module [PENDIENTE] +## Phase 15: Reporting Module [COMPLETO] Spec: `.ralph/specs/phase-15-reporting.md` -- [ ] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts` -- [ ] 15.2: Crear port: `IReportGenerator.ts` -- [ ] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión -- [ ] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo -- [ ] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF -- [ ] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download -- [ ] 15.7: Integrar con job queue: generación async -- [ ] 15.8: Migración Kysely: tabla reports -- [ ] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar -- [ ] 15.10: Tests: GenerateReportCommand con mock generator -- [ ] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation` +- [x] 15.1: Crear domain: `Report.ts` (AggregateRoot), value objects `ReportFormat.ts` (pdf/html/json), `DateRange.ts` +- [x] 15.2: Crear port: `IReportGenerator.ts` +- [x] 15.3: Crear `commands/GenerateReportCommand.ts` — crea report con findings de un rango de fechas/sesión +- [x] 15.4: Crear `infrastructure/generators/HTMLReportGenerator.ts` — genera HTML report completo +- [x] 15.5: Crear `infrastructure/generators/PDFReportGenerator.ts` — usa Playwright para renderizar HTML → PDF +- [x] 15.6: Crear `infrastructure/http/ReportingController.ts` — POST /api/reports, GET /api/reports, GET /api/reports/:id/download +- [x] 15.7: Integrar con job queue: generación async +- [x] 15.8: Migración Kysely: tabla reports +- [x] 15.9: Frontend: `pages/Reports.tsx` — generar (dialog con filtros), listar, descargar +- [x] 15.10: Tests: GenerateReportCommand con mock generator +- [x] 15.11: Verificar build completo + commit: `fase(15): reporting module with pdf generation` --- diff --git a/.ralph/progress.json b/.ralph/progress.json index f7654fb..ba5c6d3 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1 +1 @@ -{"status": "completed", "timestamp": "2026-03-05 09:58:03"} +{"status": "failed", "timestamp": "2026-03-06 04:11:47"} diff --git a/dist/api/router.js b/dist/api/router.js index ac2faf1..c7b4f2b 100644 --- a/dist/api/router.js +++ b/dist/api/router.js @@ -8,6 +8,7 @@ const express_1 = require("express"); const CrawlingController_1 = require("../modules/crawling/infrastructure/http/CrawlingController"); const FindingsController_1 = require("../modules/findings/infrastructure/http/FindingsController"); const FuzzingController_1 = require("../modules/fuzzing/infrastructure/http/FuzzingController"); +const ReportingController_1 = require("../modules/reporting/infrastructure/http/ReportingController"); const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController"); const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware"); function createRouter(deps) { @@ -21,5 +22,6 @@ function createRouter(deps) { router.use('/sessions', (0, CrawlingController_1.createCrawlingRouter)(deps.crawlingDeps)); router.use('/findings', (0, FindingsController_1.createFindingsRouter)(deps.findingsDeps)); router.use('/fuzz', (0, FuzzingController_1.createFuzzingRouter)(deps.fuzzingDeps)); + router.use('/reports', (0, ReportingController_1.createReportingRouter)(deps.reportingDeps)); return router; } diff --git a/dist/db/migrations/005_reports_table.js b/dist/db/migrations/005_reports_table.js new file mode 100644 index 0000000..7e57055 --- /dev/null +++ b/dist/db/migrations/005_reports_table.js @@ -0,0 +1,25 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.up = up; +exports.down = down; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function up(db) { + await db.schema + .createTable('reports') + .ifNotExists() + .addColumn('id', 'text', (col) => col.primaryKey()) + .addColumn('title', 'text', (col) => col.notNull()) + .addColumn('format', 'text', (col) => col.notNull()) + .addColumn('status', 'text', (col) => col.notNull().defaultTo('pending')) + .addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}')) + .addColumn('file_path', 'text') + .addColumn('error_message', 'text') + .addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('created_at', 'integer', (col) => col.notNull()) + .addColumn('completed_at', 'integer') + .execute(); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function down(db) { + await db.schema.dropTable('reports').ifExists().execute(); +} diff --git a/dist/jobs/workers/ReportWorker.js b/dist/jobs/workers/ReportWorker.js index 28c5f8b..43269fb 100644 --- a/dist/jobs/workers/ReportWorker.js +++ b/dist/jobs/workers/ReportWorker.js @@ -2,15 +2,49 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.REPORT_JOB_TYPE = void 0; exports.createReportJobHandler = createReportJobHandler; +const HTMLReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/HTMLReportGenerator"); +const JSONReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/JSONReportGenerator"); +const PDFReportGenerator_1 = require("../../modules/reporting/infrastructure/generators/PDFReportGenerator"); exports.REPORT_JOB_TYPE = 'report:generate'; function createReportJobHandler(deps) { + const htmlGen = new HTMLReportGenerator_1.HTMLReportGenerator(); + const jsonGen = new JSONReportGenerator_1.JSONReportGenerator(); + const pdfGen = new PDFReportGenerator_1.PDFReportGenerator(); return async (payload) => { const log = deps.logger.child({ jobType: exports.REPORT_JOB_TYPE, reportId: payload.reportId }); log.info({ format: payload.format }, 'Report generation job executing'); - // Full implementation in Phase 15 (Reporting Module) - // For now, return a placeholder result - const filePath = `./reports/${payload.reportId}.${payload.format}`; - log.info({ filePath }, 'Report job complete'); + const report = await deps.reportRepository.findById(payload.reportId); + if (!report) { + throw new Error(`Report not found: ${payload.reportId}`); + } + report.markGenerating(); + await deps.reportRepository.update(report); + // Load findings with filters from report + const findings = await deps.findingRepository.findAll({ + sessionId: report.filters.sessionId, + severity: report.filters.severity, + }); + let filePath; + try { + if (payload.format === 'pdf') { + filePath = await pdfGen.generate(report, findings); + } + else if (payload.format === 'json') { + filePath = await jsonGen.generate(report, findings); + } + else { + filePath = await htmlGen.generate(report, findings); + } + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err); + report.markFailed(msg); + await deps.reportRepository.update(report); + throw err; + } + report.markReady(filePath, findings.length); + await deps.reportRepository.update(report); + log.info({ filePath, totalFindings: findings.length }, 'Report job complete'); return { reportId: payload.reportId, filePath }; }; } diff --git a/dist/main.js b/dist/main.js index 7b898df..c71c5d0 100644 --- a/dist/main.js +++ b/dist/main.js @@ -49,6 +49,9 @@ const CreateApiKeyCommand_1 = require("./modules/auth/application/commands/Creat const GetUserQuery_1 = require("./modules/auth/application/queries/GetUserQuery"); const ListOrgMembersQuery_1 = require("./modules/auth/application/queries/ListOrgMembersQuery"); const PasswordService_1 = require("./modules/auth/infrastructure/auth/PasswordService"); +// Reporting module +const KyselyReportRepository_1 = require("./modules/reporting/infrastructure/repositories/KyselyReportRepository"); +const GenerateReportCommand_1 = require("./modules/reporting/application/commands/GenerateReportCommand"); // Job queue const SQLiteJobQueue_1 = require("./jobs/SQLiteJobQueue"); const ExplorationWorker_1 = require("./jobs/workers/ExplorationWorker"); @@ -72,6 +75,7 @@ async function bootstrap() { const sessionRepo = new KyselyCrawlSessionRepository_1.KyselyCrawlSessionRepository(db); const stateRepo = new KyselyStateRepository_1.KyselyStateRepository(db); const findingRepo = new KyselyFindingRepository_1.KyselyFindingRepository(db); + const reportRepo = new KyselyReportRepository_1.KyselyReportRepository(db); const fuzzRepo = new InMemoryFuzzSessionRepository_1.InMemoryFuzzSessionRepository(); // Suppress unused warning for stateRepo — used by crawling infrastructure void stateRepo; @@ -108,7 +112,14 @@ async function bootstrap() { const createApiKeyCommand = new CreateApiKeyCommand_1.CreateApiKeyCommand(apiKeyRepo, userRepo); const getUserQuery = new GetUserQuery_1.GetUserQuery(userRepo); const listOrgMembersQuery = new ListOrgMembersQuery_1.ListOrgMembersQuery(orgRepo, userRepo); - // 11. HTTP server + // 11. Reporting use cases + const generateReport = new GenerateReportCommand_1.GenerateReportCommand(reportRepo, eventBus); + // 12. Job queue (created before HTTP server so it can be injected) + const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs); + jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger })); + jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger, reportRepository: reportRepo, findingRepository: findingRepo })); + jobQueue.start(); + // 13. HTTP server const app = (0, server_1.createServer)({ config, logger, @@ -116,6 +127,7 @@ async function bootstrap() { crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions }, findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding }, fuzzingDeps: { runFuzz, repository: fuzzRepo }, + reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue }, authDeps: { registerCommand, loginCommand, @@ -130,11 +142,6 @@ async function bootstrap() { }, }); const httpServer = http_1.default.createServer(app); - // 11. Job queue - const jobQueue = new SQLiteJobQueue_1.SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs); - jobQueue.registerHandler(ExplorationWorker_1.EXPLORATION_JOB_TYPE, (0, ExplorationWorker_1.createExplorationJobHandler)({ sessionRepo, eventBus, logger })); - jobQueue.registerHandler(ReportWorker_1.REPORT_JOB_TYPE, (0, ReportWorker_1.createReportJobHandler)({ logger })); - jobQueue.start(); // 12. Socket.io + gateway const io = new socket_io_1.Server(httpServer, { cors: { origin: config.cors.origin, credentials: true }, diff --git a/dist/modules/findings/infrastructure/http/FindingsController.js b/dist/modules/findings/infrastructure/http/FindingsController.js index 44142e1..1a74d2a 100644 --- a/dist/modules/findings/infrastructure/http/FindingsController.js +++ b/dist/modules/findings/infrastructure/http/FindingsController.js @@ -38,7 +38,7 @@ function createFindingsRouter(deps) { } res.json(result.value); }); - // GET /api/findings/:id — finding detail + // GET /api/findings/:id — finding detail (includes actionTrace) router.get('/:id', async (req, res) => { const findingId = req.params['id']; const result = await deps.getFinding.execute({ findingId }); @@ -46,7 +46,8 @@ function createFindingsRouter(deps) { res.status(404).json({ error: result.error }); return; } - res.json(toDTO(result.value)); + const f = result.value; + res.json({ ...toDTO(f), actionTrace: f.actionTrace }); }); // PATCH /api/findings/:id/status — update status router.patch('/:id/status', async (req, res) => { diff --git a/dist/modules/reporting/application/commands/GenerateReportCommand.js b/dist/modules/reporting/application/commands/GenerateReportCommand.js new file mode 100644 index 0000000..1ff601c --- /dev/null +++ b/dist/modules/reporting/application/commands/GenerateReportCommand.js @@ -0,0 +1,33 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.GenerateReportCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const Report_1 = require("../../domain/entities/Report"); +const ReportFormat_1 = require("../../domain/value-objects/ReportFormat"); +class GenerateReportCommand { + constructor(reportRepository, eventBus) { + this.reportRepository = reportRepository; + this.eventBus = eventBus; + } + async execute(request) { + let format; + try { + format = ReportFormat_1.ReportFormat.fromString(request.format); + } + catch { + return (0, Result_1.Err)(`Invalid format: ${request.format}`); + } + const report = Report_1.Report.create({ + title: request.title, + format, + filters: request.filters ?? {}, + }); + await this.reportRepository.save(report); + const events = report.clearEvents(); + for (const event of events) { + await this.eventBus.publish(event); + } + return (0, Result_1.Ok)({ reportId: report.id.toString(), status: report.status.value }); + } +} +exports.GenerateReportCommand = GenerateReportCommand; diff --git a/dist/modules/reporting/domain/entities/Report.js b/dist/modules/reporting/domain/entities/Report.js new file mode 100644 index 0000000..b1dbd74 --- /dev/null +++ b/dist/modules/reporting/domain/entities/Report.js @@ -0,0 +1,59 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Report = void 0; +const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const ReportStatus_1 = require("../value-objects/ReportStatus"); +const ReportRequested_1 = require("../events/ReportRequested"); +const ReportGenerated_1 = require("../events/ReportGenerated"); +const ReportFailed_1 = require("../events/ReportFailed"); +class Report extends AggregateRoot_1.AggregateRoot { + static create(props, id) { + const reportId = id ?? UniqueId_1.UniqueId.create(); + const report = new Report({ + ...props, + status: ReportStatus_1.ReportStatus.pending(), + totalFindings: 0, + createdAt: new Date(), + }, reportId); + report.addDomainEvent(new ReportRequested_1.ReportRequested(reportId.toString(), { + title: props.title, + format: props.format.value, + filters: props.filters, + })); + return report; + } + static reconstitute(props, id) { + return new Report(props, id); + } + get title() { return this.props.title; } + get format() { return this.props.format; } + get status() { return this.props.status; } + get filters() { return this.props.filters; } + get filePath() { return this.props.filePath; } + get errorMessage() { return this.props.errorMessage; } + get totalFindings() { return this.props.totalFindings; } + get createdAt() { return this.props.createdAt; } + get completedAt() { return this.props.completedAt; } + markGenerating() { + this.props.status = ReportStatus_1.ReportStatus.generating(); + } + markReady(filePath, totalFindings) { + this.props.status = ReportStatus_1.ReportStatus.ready(); + this.props.filePath = filePath; + this.props.totalFindings = totalFindings; + this.props.completedAt = new Date(); + this.addDomainEvent(new ReportGenerated_1.ReportGenerated(this.id.toString(), { + filePath, + totalFindings, + format: this.props.format.value, + })); + } + markFailed(errorMessage) { + this.props.status = ReportStatus_1.ReportStatus.failed(); + this.props.errorMessage = errorMessage; + this.props.completedAt = new Date(); + this.addDomainEvent(new ReportFailed_1.ReportFailed(this.id.toString(), { errorMessage })); + } +} +exports.Report = Report; diff --git a/dist/modules/reporting/domain/events/ReportFailed.js b/dist/modules/reporting/domain/events/ReportFailed.js new file mode 100644 index 0000000..96e71d9 --- /dev/null +++ b/dist/modules/reporting/domain/events/ReportFailed.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ReportFailed = void 0; +const crypto_1 = require("crypto"); +class ReportFailed { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'reporting.report_failed'; + this.occurredOn = new Date(); + } +} +exports.ReportFailed = ReportFailed; diff --git a/dist/modules/reporting/domain/events/ReportGenerated.js b/dist/modules/reporting/domain/events/ReportGenerated.js new file mode 100644 index 0000000..fa675ff --- /dev/null +++ b/dist/modules/reporting/domain/events/ReportGenerated.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ReportGenerated = void 0; +const crypto_1 = require("crypto"); +class ReportGenerated { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'reporting.report_generated'; + this.occurredOn = new Date(); + } +} +exports.ReportGenerated = ReportGenerated; diff --git a/dist/modules/reporting/domain/events/ReportRequested.js b/dist/modules/reporting/domain/events/ReportRequested.js new file mode 100644 index 0000000..7ace8f8 --- /dev/null +++ b/dist/modules/reporting/domain/events/ReportRequested.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ReportRequested = void 0; +const crypto_1 = require("crypto"); +class ReportRequested { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'reporting.report_requested'; + this.occurredOn = new Date(); + } +} +exports.ReportRequested = ReportRequested; diff --git a/dist/modules/reporting/domain/ports/IReportGenerator.js b/dist/modules/reporting/domain/ports/IReportGenerator.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/reporting/domain/ports/IReportGenerator.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/reporting/domain/ports/IReportRepository.js b/dist/modules/reporting/domain/ports/IReportRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/reporting/domain/ports/IReportRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/reporting/domain/value-objects/DateRange.js b/dist/modules/reporting/domain/value-objects/DateRange.js new file mode 100644 index 0000000..48db9d8 --- /dev/null +++ b/dist/modules/reporting/domain/value-objects/DateRange.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DateRange = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class DateRange extends ValueObject_1.ValueObject { + get startDate() { return this.props.startDate; } + get endDate() { return this.props.endDate; } + static create(startDate, endDate) { + if (startDate > endDate) + throw new Error('startDate must be before endDate'); + return new DateRange({ startDate, endDate }); + } +} +exports.DateRange = DateRange; diff --git a/dist/modules/reporting/domain/value-objects/ReportFormat.js b/dist/modules/reporting/domain/value-objects/ReportFormat.js new file mode 100644 index 0000000..2cb6bb2 --- /dev/null +++ b/dist/modules/reporting/domain/value-objects/ReportFormat.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ReportFormat = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class ReportFormat extends ValueObject_1.ValueObject { + get value() { return this.props.value; } + static html() { return new ReportFormat({ value: 'html' }); } + static json() { return new ReportFormat({ value: 'json' }); } + static pdf() { return new ReportFormat({ value: 'pdf' }); } + static fromString(s) { + if (s === 'html' || s === 'json' || s === 'pdf') { + return new ReportFormat({ value: s }); + } + throw new Error(`Invalid report format: ${s}`); + } +} +exports.ReportFormat = ReportFormat; diff --git a/dist/modules/reporting/domain/value-objects/ReportStatus.js b/dist/modules/reporting/domain/value-objects/ReportStatus.js new file mode 100644 index 0000000..8f18a61 --- /dev/null +++ b/dist/modules/reporting/domain/value-objects/ReportStatus.js @@ -0,0 +1,18 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ReportStatus = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class ReportStatus extends ValueObject_1.ValueObject { + get value() { return this.props.value; } + static pending() { return new ReportStatus({ value: 'pending' }); } + static generating() { return new ReportStatus({ value: 'generating' }); } + static ready() { return new ReportStatus({ value: 'ready' }); } + static failed() { return new ReportStatus({ value: 'failed' }); } + static fromString(s) { + if (s === 'pending' || s === 'generating' || s === 'ready' || s === 'failed') { + return new ReportStatus({ value: s }); + } + throw new Error(`Invalid report status: ${s}`); + } +} +exports.ReportStatus = ReportStatus; diff --git a/dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js b/dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js new file mode 100644 index 0000000..a2ca203 --- /dev/null +++ b/dist/modules/reporting/infrastructure/generators/HTMLReportGenerator.js @@ -0,0 +1,138 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.HTMLReportGenerator = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +class HTMLReportGenerator { + async generate(report, findings) { + const outputDir = path.join(process.cwd(), 'reports', report.id.toString()); + fs.mkdirSync(outputDir, { recursive: true }); + const severityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const f of findings) { + const sev = f.severity.value; + severityCounts[sev] = (severityCounts[sev] ?? 0) + 1; + } + const findingsHtml = findings.map(f => ` +
+
+ ${f.severity.value.toUpperCase()} + ${f.type.value} + ${f.status.value} +
+

${escapeHtml(f.description)}

+ + Session: ${f.sessionId}  ·  + ${new Date(f.createdAt).toLocaleString()} + +
+ `).join('\n'); + const html = ` + + + + + ${escapeHtml(report.title)} + + + +

${escapeHtml(report.title)}

+
+ Generated by ABE  ·  ${new Date().toLocaleString()} + ${report.filters.sessionId ? ` ·  Session: ${report.filters.sessionId}` : ''} +
+ +
+
+
${findings.length}
+
Total
+
+
+
${severityCounts['critical'] ?? 0}
+
Critical
+
+
+
${severityCounts['high'] ?? 0}
+
High
+
+
+
${severityCounts['medium'] ?? 0}
+
Medium
+
+
+
${severityCounts['low'] ?? 0}
+
Low
+
+
+ +

Findings (${findings.length})

+ ${findings.length === 0 ? '

No findings match the selected filters.

' : findingsHtml} + + + +`; + const filePath = path.join(outputDir, `report.html`); + fs.writeFileSync(filePath, html, 'utf8'); + return filePath; + } +} +exports.HTMLReportGenerator = HTMLReportGenerator; +function escapeHtml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js b/dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js new file mode 100644 index 0000000..5881ec7 --- /dev/null +++ b/dist/modules/reporting/infrastructure/generators/JSONReportGenerator.js @@ -0,0 +1,88 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.JSONReportGenerator = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +class JSONReportGenerator { + async generate(report, findings) { + const outputDir = path.join(process.cwd(), 'reports', report.id.toString()); + fs.mkdirSync(outputDir, { recursive: true }); + const data = { + reportId: report.id.toString(), + title: report.title, + generatedAt: new Date().toISOString(), + filters: report.filters, + summary: { + total: findings.length, + bySeverity: buildSeverityCount(findings), + byStatus: buildStatusCount(findings), + }, + findings: findings.map(f => ({ + id: f.id.toString(), + sessionId: f.sessionId, + type: f.type.value, + severity: f.severity.value, + description: f.description, + status: f.status.value, + browser: f.browser, + createdAt: f.createdAt.toISOString(), + resolvedAt: f.resolvedAt?.toISOString() ?? null, + evidence: f.evidence.toJSON(), + actionTraceLength: f.actionTrace.length, + })), + }; + const filePath = path.join(outputDir, 'report.json'); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + return filePath; + } +} +exports.JSONReportGenerator = JSONReportGenerator; +function buildSeverityCount(findings) { + const counts = {}; + for (const f of findings) { + const s = f.severity.value; + counts[s] = (counts[s] ?? 0) + 1; + } + return counts; +} +function buildStatusCount(findings) { + const counts = {}; + for (const f of findings) { + const s = f.status.value; + counts[s] = (counts[s] ?? 0) + 1; + } + return counts; +} diff --git a/dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js b/dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js new file mode 100644 index 0000000..d44c28d --- /dev/null +++ b/dist/modules/reporting/infrastructure/generators/PDFReportGenerator.js @@ -0,0 +1,81 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PDFReportGenerator = void 0; +const path = __importStar(require("path")); +const fs = __importStar(require("fs")); +const HTMLReportGenerator_1 = require("./HTMLReportGenerator"); +/** + * PDF report generator — uses Playwright to render the HTML report to PDF. + * Requires Playwright + Chromium to be installed. + */ +class PDFReportGenerator { + constructor() { + this.htmlGenerator = new HTMLReportGenerator_1.HTMLReportGenerator(); + } + async generate(report, findings) { + // First generate the HTML version + const htmlPath = await this.htmlGenerator.generate(report, findings); + const outputDir = path.dirname(htmlPath); + const pdfPath = path.join(outputDir, 'report.pdf'); + // Use Playwright to convert HTML to PDF + let chromium; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pw = require('playwright'); + chromium = pw.chromium; + } + catch { + throw new Error('Playwright not available — install playwright to generate PDF reports'); + } + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const htmlContent = fs.readFileSync(htmlPath, 'utf8'); + await page.setContent(htmlContent, { waitUntil: 'networkidle' }); + await page.pdf({ + path: pdfPath, + format: 'A4', + printBackground: true, + margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' }, + }); + } + finally { + await browser.close(); + } + return pdfPath; + } +} +exports.PDFReportGenerator = PDFReportGenerator; diff --git a/dist/modules/reporting/infrastructure/http/ReportingController.js b/dist/modules/reporting/infrastructure/http/ReportingController.js new file mode 100644 index 0000000..3cafdff --- /dev/null +++ b/dist/modules/reporting/infrastructure/http/ReportingController.js @@ -0,0 +1,134 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createReportingRouter = createReportingRouter; +const express_1 = require("express"); +const path = __importStar(require("path")); +const fs = __importStar(require("fs")); +const ReportWorker_1 = require("../../../../jobs/workers/ReportWorker"); +function createReportingRouter(deps) { + const router = (0, express_1.Router)(); + // POST /api/reports — create and enqueue report + router.post('/', async (req, res) => { + const { title, format, filters } = req.body; + if (!title || !format) { + res.status(400).json({ error: 'title and format are required' }); + return; + } + const result = await deps.generateReport.execute({ + title, + format: format, + filters: filters + ? { + sessionId: filters.sessionId, + startDate: filters.startDate ? new Date(filters.startDate) : undefined, + endDate: filters.endDate ? new Date(filters.endDate) : undefined, + severity: filters.severity, + } + : undefined, + }); + if (!result.ok) { + res.status(400).json({ error: result.error }); + return; + } + // Enqueue background job + await deps.jobQueue.enqueue(ReportWorker_1.REPORT_JOB_TYPE, { + reportId: result.value.reportId, + format: format, + filters: filters, + }); + res.status(201).json(result.value); + }); + // GET /api/reports — list all reports + router.get('/', async (_req, res) => { + const reports = await deps.reportRepository.findAll(); + res.json(reports.map(r => ({ + id: r.id.toString(), + title: r.title, + format: r.format.value, + status: r.status.value, + totalFindings: r.totalFindings, + createdAt: r.createdAt.toISOString(), + completedAt: r.completedAt?.toISOString() ?? null, + }))); + }); + // GET /api/reports/:id — report detail + router.get('/:id', async (req, res) => { + const report = await deps.reportRepository.findById(req.params['id']); + if (!report) { + res.status(404).json({ error: 'Report not found' }); + return; + } + res.json({ + id: report.id.toString(), + title: report.title, + format: report.format.value, + status: report.status.value, + filters: report.filters, + totalFindings: report.totalFindings, + errorMessage: report.errorMessage, + createdAt: report.createdAt.toISOString(), + completedAt: report.completedAt?.toISOString() ?? null, + }); + }); + // GET /api/reports/:id/download — download the generated file + router.get('/:id/download', async (req, res) => { + const report = await deps.reportRepository.findById(req.params['id']); + if (!report) { + res.status(404).json({ error: 'Report not found' }); + return; + } + if (report.status.value !== 'ready' || !report.filePath) { + res.status(409).json({ error: 'Report is not ready yet', status: report.status.value }); + return; + } + if (!fs.existsSync(report.filePath)) { + res.status(410).json({ error: 'Report file no longer exists' }); + return; + } + const ext = path.extname(report.filePath); + const contentTypes = { + '.html': 'text/html', + '.json': 'application/json', + '.pdf': 'application/pdf', + }; + const contentType = contentTypes[ext] ?? 'application/octet-stream'; + const filename = `report-${report.id.toString()}${ext}`; + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + fs.createReadStream(report.filePath).pipe(res); + }); + return router; +} diff --git a/dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js b/dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js new file mode 100644 index 0000000..416e37e --- /dev/null +++ b/dist/modules/reporting/infrastructure/repositories/KyselyReportRepository.js @@ -0,0 +1,85 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KyselyReportRepository = void 0; +const Report_1 = require("../../domain/entities/Report"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const ReportFormat_1 = require("../../domain/value-objects/ReportFormat"); +const ReportStatus_1 = require("../../domain/value-objects/ReportStatus"); +class KyselyReportRepository { + constructor(db) { + this.db = db; + } + async save(report) { + const row = { + id: report.id.toString(), + title: report.title, + format: report.format.value, + status: report.status.value, + filters_json: JSON.stringify(report.filters), + file_path: report.filePath ?? null, + error_message: report.errorMessage ?? null, + total_findings: report.totalFindings, + created_at: report.createdAt.getTime(), + completed_at: report.completedAt ? report.completedAt.getTime() : null, + }; + await this.db.insertInto('reports').values(row).execute(); + } + async findById(id) { + const row = await this.db + .selectFrom('reports') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async findAll() { + const rows = await this.db + .selectFrom('reports') + .selectAll() + .orderBy('created_at', 'desc') + .execute(); + return rows.map(r => this.toDomain(r)); + } + async update(report) { + await this.db + .updateTable('reports') + .set({ + status: report.status.value, + file_path: report.filePath ?? null, + error_message: report.errorMessage ?? null, + total_findings: report.totalFindings, + completed_at: report.completedAt ? report.completedAt.getTime() : null, + }) + .where('id', '=', report.id.toString()) + .execute(); + } + toDomain(row) { + const filters = this.parseJson(row.filters_json, {}); + const props = { + title: row.title, + format: ReportFormat_1.ReportFormat.fromString(row.format), + status: ReportStatus_1.ReportStatus.fromString(row.status), + filters: { + sessionId: filters.sessionId, + startDate: filters.startDate ? new Date(filters.startDate) : undefined, + endDate: filters.endDate ? new Date(filters.endDate) : undefined, + severity: filters.severity, + }, + filePath: row.file_path ?? undefined, + errorMessage: row.error_message ?? undefined, + totalFindings: row.total_findings, + createdAt: new Date(row.created_at), + completedAt: row.completed_at ? new Date(row.completed_at) : undefined, + }; + return Report_1.Report.reconstitute(props, UniqueId_1.UniqueId.from(row.id)); + } + parseJson(json, fallback) { + try { + return JSON.parse(json); + } + catch { + return fallback; + } + } +} +exports.KyselyReportRepository = KyselyReportRepository; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ad999fe..13ee5d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,21 +11,21 @@ import { Setup } from '@/pages/Setup' import { Dashboard } from '@/pages/Dashboard' import { SessionList } from '@/pages/sessions/SessionList' import { SessionDetail } from '@/pages/sessions/SessionDetail' -function FindingsList() { - return
Findings — Coming in Phase 13
-} -function FindingDetail() { - return
Finding Detail — Coming in Phase 13
-} -function Reports() { - return
Reports — Coming in Phase 15
-} +import { FindingsList } from '@/pages/findings/FindingsList' +import { FindingDetail } from '@/pages/findings/FindingDetail' +import { SettingsLayout } from '@/pages/settings/SettingsLayout' +import { ProfileSection } from '@/pages/settings/ProfileSection' +import { OrganizationSection } from '@/pages/settings/OrganizationSection' +import { ApiKeysSection } from '@/pages/settings/ApiKeysSection' +import { ExplorationDefaultsSection } from '@/pages/settings/ExplorationDefaultsSection' +import { NotificationsSection } from '@/pages/settings/NotificationsSection' +import { AppearanceSection } from '@/pages/settings/AppearanceSection' +import { LicenseSection } from '@/pages/settings/LicenseSection' +import { Reports } from '@/pages/Reports' + function VisualReview() { return
Visual Review — Coming in Phase 20
} -function Settings() { - return
Settings — Coming in Phase 14
-} export default function App() { return ( @@ -50,7 +50,16 @@ export default function App() { } /> } /> } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/components/findings/AIAnalysisPanel.tsx b/frontend/src/components/findings/AIAnalysisPanel.tsx new file mode 100644 index 0000000..db39dd4 --- /dev/null +++ b/frontend/src/components/findings/AIAnalysisPanel.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Sparkles } from 'lucide-react' +import { apiFetch } from '@/lib/api' +import { useQueryClient } from '@tanstack/react-query' +import type { AIEnrichment } from '../../types' + +interface AIAnalysisPanelProps { + findingId: string + enrichment?: AIEnrichment | null +} + +const CONFIDENCE_COLOR: Record = { + high: 'bg-green-500/15 text-green-600 border-green-500/30', + medium: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30', + low: 'bg-red-500/15 text-red-500 border-red-500/30', +} + +export function AIAnalysisPanel({ findingId, enrichment }: AIAnalysisPanelProps) { + const queryClient = useQueryClient() + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + async function handleAnalyze() { + setLoading(true) + setError(null) + try { + await apiFetch(`/api/findings/${findingId}/enrich`, { method: 'POST' }) + await queryClient.invalidateQueries({ queryKey: ['findings', findingId] }) + } catch (e) { + setError(e instanceof Error ? e.message : 'Analysis failed') + } finally { + setLoading(false) + } + } + + if (loading) { + return ( + + + + + AI Analysis + + + + + + + + + ) + } + + if (!enrichment) { + return ( + + + + + AI Analysis + + + + {error &&

{error}

} +

+ Get AI-powered root cause analysis, user impact assessment, and suggested fixes. +

+ +
+
+ ) + } + + return ( + + +
+ + + AI Analysis + + + {enrichment.confidence} confidence + +
+

+ {enrichment.provider} / {enrichment.model} ·{' '} + {new Date(enrichment.generatedAt).toLocaleString()} +

+
+ +
+

Root Cause

+

{enrichment.rootCause}

+
+
+

User Impact

+

{enrichment.userImpact}

+
+
+

Suggested Fix

+

{enrichment.suggestedFix}

+
+ {enrichment.debugPrompt && ( +
+

Debug Prompt

+
+              {enrichment.debugPrompt}
+            
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/findings/EvidencePanel.tsx b/frontend/src/components/findings/EvidencePanel.tsx new file mode 100644 index 0000000..e3f3e7d --- /dev/null +++ b/frontend/src/components/findings/EvidencePanel.tsx @@ -0,0 +1,78 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import type { AnomalyEvidence } from '../../types' + +interface EvidencePanelProps { + evidence: AnomalyEvidence +} + +export function EvidencePanel({ evidence }: EvidencePanelProps) { + return ( + + + Console + Network + {evidence.domSnapshotPath && DOM} + + + + {evidence.rawErrors && evidence.rawErrors.length > 0 ? ( + + {evidence.rawErrors.map((err, i) => ( +
{err}
+ ))} +
+ ) : ( +

No console errors captured.

+ )} +
+ + + {evidence.httpLog && evidence.httpLog.length > 0 ? ( +
+ + + + Method + Status + URL + Duration + + + + {evidence.httpLog.map((req, i) => ( + + {req.method} + = 400 ? 'text-destructive' : ''}`}> + {req.status} + + {req.url} + {req.durationMs}ms + + ))} + +
+
+ ) : ( +

No network requests captured.

+ )} +
+ + {evidence.domSnapshotPath && ( + +
+

DOM snapshot: {evidence.domSnapshotPath}

+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/findings/ReproductionSteps.tsx b/frontend/src/components/findings/ReproductionSteps.tsx new file mode 100644 index 0000000..5d2ddc8 --- /dev/null +++ b/frontend/src/components/findings/ReproductionSteps.tsx @@ -0,0 +1,62 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import type { Action } from '../../types' + +interface ReproductionStepsProps { + steps: Action[] +} + +const ACTION_ICONS: Record = { + click: '🖱️', + fill: '⌨️', + navigate: '🔗', + scroll: '📜', + hover: '🎯', + select: '📋', + press: '⌨️', +} + +export function ReproductionSteps({ steps }: ReproductionStepsProps) { + if (steps.length === 0) { + return ( + + + Reproduction Steps + + +

No action trace available.

+
+
+ ) + } + + return ( + + + Reproduction Steps + + +
    + {steps.map((step, i) => ( +
  1. + {i + 1}. + {ACTION_ICONS[step.type] ?? '▶️'} +
    + {step.type} + {step.selector && ( + + {step.selector} + + )} + {step.value && ( + + value: "{step.value}" + + )} +
    +
  2. + ))} +
+
+
+ ) +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index b6d2485..65aa25e 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -6,6 +6,7 @@ interface User { email: string name: string role: string + orgId?: string } export function useAuth() { diff --git a/frontend/src/hooks/useFindings.ts b/frontend/src/hooks/useFindings.ts index 14cf654..f002597 100644 --- a/frontend/src/hooks/useFindings.ts +++ b/frontend/src/hooks/useFindings.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { apiFetch } from '@/lib/api' -import type { AnomalySummary, Stats } from '../types' +import type { AnomalySummary, Finding, Stats } from '../types' export function useFindings(params?: { sessionId?: string; severity?: string }) { const qs = new URLSearchParams() @@ -14,6 +14,14 @@ export function useFindings(params?: { sessionId?: string; severity?: string }) }) } +export function useFinding(id: string) { + return useQuery({ + queryKey: ['findings', id], + queryFn: () => apiFetch(`/api/findings/${id}`), + enabled: !!id, + }) +} + export function useFindingStats() { return useQuery({ queryKey: ['findings', 'stats'], diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx new file mode 100644 index 0000000..078721d --- /dev/null +++ b/frontend/src/pages/Reports.tsx @@ -0,0 +1,257 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { toast } from 'sonner' + +interface Report { + id: string + title: string + format: 'html' | 'json' | 'pdf' + status: 'pending' | 'generating' | 'ready' | 'failed' + totalFindings: number + createdAt: string + completedAt: string | null +} + +interface GenerateForm { + title: string + format: 'html' | 'json' | 'pdf' + sessionId: string + startDate: string + endDate: string + severity: string +} + +const STATUS_COLORS: Record = { + pending: 'secondary', + generating: 'secondary', + ready: 'default', + failed: 'destructive', +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleString() +} + +export function Reports() { + const qc = useQueryClient() + const [open, setOpen] = useState(false) + const [form, setForm] = useState({ + title: '', + format: 'html', + sessionId: '', + startDate: '', + endDate: '', + severity: '', + }) + + const { data: reports = [], isLoading } = useQuery({ + queryKey: ['reports'], + queryFn: () => apiFetch('/api/reports'), + refetchInterval: 5000, + }) + + const generate = useMutation({ + mutationFn: (body: object) => + apiFetch<{ reportId: string }>('/api/reports', { + method: 'POST', + body: JSON.stringify(body), + }), + onSuccess: () => { + toast.success('Report queued — it will be ready shortly') + setOpen(false) + void qc.invalidateQueries({ queryKey: ['reports'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + function handleSubmit() { + if (!form.title) { toast.error('Title is required'); return } + const filters: Record = {} + if (form.sessionId) filters['sessionId'] = form.sessionId + if (form.startDate) filters['startDate'] = form.startDate + if (form.endDate) filters['endDate'] = form.endDate + if (form.severity) filters['severity'] = form.severity + generate.mutate({ title: form.title, format: form.format, filters }) + } + + function handleDownload(report: Report) { + window.open(`/api/reports/${report.id}/download`, '_blank') + } + + return ( +
+
+
+

Reports

+

+ Generate and download bug reports in multiple formats. +

+
+ +
+ + {isLoading ? ( +

Loading...

+ ) : reports.length === 0 ? ( +
+

No reports yet.

+

Click "Generate Report" to create one.

+
+ ) : ( + + + + Title + Format + Status + Findings + Created + Completed + + + + + {reports.map(r => ( + + {r.title} + + {r.format.toUpperCase()} + + + + {r.status} + + + {r.status === 'ready' ? r.totalFindings : '—'} + + {formatDate(r.createdAt)} + + + {r.completedAt ? formatDate(r.completedAt) : '—'} + + + {r.status === 'ready' && ( + + )} + + + ))} + +
+ )} + + + + + Generate Report + +
+
+ + setForm(f => ({ ...f, title: e.target.value }))} + /> +
+
+ + +
+
+ + setForm(f => ({ ...f, sessionId: e.target.value }))} + /> +
+
+
+ + setForm(f => ({ ...f, startDate: e.target.value }))} + /> +
+
+ + setForm(f => ({ ...f, endDate: e.target.value }))} + /> +
+
+
+ + +
+
+ + + + +
+
+
+ ) +} diff --git a/frontend/src/pages/findings/FindingDetail.tsx b/frontend/src/pages/findings/FindingDetail.tsx new file mode 100644 index 0000000..db73ab2 --- /dev/null +++ b/frontend/src/pages/findings/FindingDetail.tsx @@ -0,0 +1,176 @@ +import { useParams, useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { ArrowLeft, Download, FileText, FileJson, Code2, CheckCircle2, Search, XCircle } from 'lucide-react' +import { SeverityBadge } from '@/components/common/SeverityBadge' +import { ReproductionSteps } from '@/components/findings/ReproductionSteps' +import { EvidencePanel } from '@/components/findings/EvidencePanel' +import { AIAnalysisPanel } from '@/components/findings/AIAnalysisPanel' +import { useFinding } from '@/hooks/useFindings' +import { apiFetch } from '@/lib/api' +import type { FindingStatus } from '../../types' + +const STATUS_COLOR: Record = { + open: 'bg-red-500/15 text-red-500 border-red-500/30', + investigating: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30', + resolved: 'bg-green-500/15 text-green-600 border-green-500/30', + closed: 'bg-slate-500/15 text-slate-500 border-slate-500/30', +} + +const STATUS_TRANSITIONS: Record> = { + open: [ + { action: 'investigate', label: 'Investigate', icon: Search }, + { action: 'resolve', label: 'Resolve', icon: CheckCircle2 }, + ], + investigating: [ + { action: 'resolve', label: 'Resolve', icon: CheckCircle2 }, + { action: 'close', label: 'Close', icon: XCircle }, + ], + resolved: [ + { action: 'close', label: 'Close', icon: XCircle }, + ], + closed: [], +} + +function downloadHref(findingId: string, format: 'markdown' | 'json' | 'playwright') { + const API_URL = import.meta.env.VITE_API_URL || '' + return `${API_URL}/api/findings/${findingId}/export/${format}` +} + +export function FindingDetail() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const queryClient = useQueryClient() + const { data: finding, isLoading } = useFinding(id ?? '') + + async function handleStatusChange(action: 'investigate' | 'resolve' | 'close') { + if (!id) return + try { + await apiFetch(`/api/findings/${id}/status`, { + method: 'PATCH', + body: JSON.stringify({ action }), + }) + await queryClient.invalidateQueries({ queryKey: ['findings', id] }) + await queryClient.invalidateQueries({ queryKey: ['findings'] }) + } catch (e) { + console.error('Failed to update status:', e) + } + } + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (!finding) { + return
Finding not found.
+ } + + const transitions = STATUS_TRANSITIONS[finding.status] ?? [] + + return ( +
+ {/* Header */} +
+ +
+
+ + + {finding.status} + + {finding.type} +
+

{finding.description}

+

+ {finding.browser && {finding.browser} · } + Found {new Date(finding.createdAt).toLocaleString()} + {finding.resolvedAt && ` · Resolved ${new Date(finding.resolvedAt).toLocaleString()}`} +

+
+
+ + {/* Action bar */} +
+ {/* Status workflow */} + {transitions.map(t => ( + + ))} + +
+ + {/* Export buttons */} + + + + + + + + + + +
+ + {/* Main split layout */} +
+ {/* Left: Evidence + Reproduction */} +
+ + + Evidence + + Steps + {finding.actionTrace.length > 0 && ( + {finding.actionTrace.length} + )} + + + + + + + + + +
+ + {/* Right: AI Analysis */} +
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/findings/FindingsList.tsx b/frontend/src/pages/findings/FindingsList.tsx new file mode 100644 index 0000000..6957bb5 --- /dev/null +++ b/frontend/src/pages/findings/FindingsList.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + getFilteredRowModel, + flexRender, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Skeleton } from '@/components/ui/skeleton' +import { ArrowUpDown, X } from 'lucide-react' +import { SeverityBadge } from '@/components/common/SeverityBadge' +import { useFindings } from '@/hooks/useFindings' +import type { AnomalySummary, Severity } from '../../types' + +const SEVERITIES: Severity[] = ['critical', 'high', 'medium', 'low'] + +const columns: ColumnDef[] = [ + { + accessorKey: 'severity', + header: 'Severity', + cell: ({ row }) => , + }, + { + accessorKey: 'type', + header: ({ column }) => ( + + ), + cell: ({ row }) => {row.original.type}, + }, + { + accessorKey: 'description', + header: 'Description', + cell: ({ row }) => ( + + {row.original.description} + + ), + }, + { + accessorKey: 'timestamp', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {new Date(row.original.timestamp).toLocaleString()} + + ), + }, +] + +export function FindingsList() { + const navigate = useNavigate() + const [severity, setSeverity] = useState('all') + const [search, setSearch] = useState('') + const [sorting, setSorting] = useState([{ id: 'timestamp', desc: true }]) + + const { data: allFindings = [], isLoading } = useFindings( + severity !== 'all' ? { severity } : undefined + ) + + const table = useReactTable({ + data: allFindings, + columns, + state: { sorting, globalFilter: search }, + onSortingChange: setSorting, + onGlobalFilterChange: setSearch, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }) + + return ( +
+
+
+

Findings

+

{table.getRowModel().rows.length} findings

+
+
+ + {/* Filters */} +
+ setSearch(e.target.value)} + className="max-w-xs" + /> + + {(severity !== 'all' || search) && ( + + )} +
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map(i => )} +
+ ) : ( +
+ + + {table.getHeaderGroups().map(hg => ( + + {hg.headers.map(h => ( + + {flexRender(h.column.columnDef.header, h.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + No findings yet. Start an exploration! + + + ) : ( + table.getRowModel().rows.map(row => ( + navigate(`/findings/${row.original.id}`)} + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/settings/ApiKeysSection.tsx b/frontend/src/pages/settings/ApiKeysSection.tsx new file mode 100644 index 0000000..ceb4db7 --- /dev/null +++ b/frontend/src/pages/settings/ApiKeysSection.tsx @@ -0,0 +1,190 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Key, Trash2, Copy, CheckCircle2 } from 'lucide-react' + +interface ApiKey { + id: string + name: string + keyPrefix: string + permissions: string[] + expiresAt: string | null + lastUsedAt: string | null + createdAt: string +} + +interface CreatedKey { + id: string + name: string + keyPrefix: string + token: string +} + +export function ApiKeysSection() { + const queryClient = useQueryClient() + const [open, setOpen] = useState(false) + const [name, setName] = useState('') + const [submitting, setSubmitting] = useState(false) + const [created, setCreated] = useState(null) + const [copied, setCopied] = useState(false) + const [error, setError] = useState(null) + + const { data: keys = [], isLoading } = useQuery({ + queryKey: ['api-keys'], + queryFn: () => apiFetch('/api/auth/api-keys'), + }) + + async function handleCreate(e: React.FormEvent) { + e.preventDefault() + if (!name) return + setSubmitting(true) + setError(null) + try { + const result = await apiFetch('/api/auth/api-keys', { + method: 'POST', + body: JSON.stringify({ name, permissions: ['*'] }), + }) + setCreated(result) + setName('') + await queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Create failed') + } finally { + setSubmitting(false) + } + } + + async function handleRevoke(id: string) { + try { + await apiFetch(`/api/auth/api-keys/${id}`, { method: 'DELETE' }) + await queryClient.invalidateQueries({ queryKey: ['api-keys'] }) + } catch (err) { + console.error('Revoke failed:', err) + } + } + + function copyToken() { + if (created?.token) { + void navigator.clipboard.writeText(created.token) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + function handleDialogChange(v: boolean) { + setOpen(v) + if (!v) { + setCreated(null) + setError(null) + } + } + + return ( +
+
+
+

API Keys

+

Manage API keys for programmatic access.

+
+ + + + + + + Create API Key + + {created ? ( +
+

+ Copy your API key now — it won't be shown again. +

+
+ {created.token} + +
+ +
+ ) : ( +
+ {error &&

{error}

} +
+ + setName(e.target.value)} + required + /> +
+ +
+ )} +
+
+
+ + + + {isLoading ? ( +

Loading...

+ ) : keys.length === 0 ? ( +

No API keys yet.

+ ) : ( +
+ {keys.map(k => ( +
+ +
+

{k.name}

+

{k.keyPrefix}***

+
+ {k.lastUsedAt && ( + + Last used {new Date(k.lastUsedAt).toLocaleDateString()} + + )} + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/settings/AppearanceSection.tsx b/frontend/src/pages/settings/AppearanceSection.tsx new file mode 100644 index 0000000..180590c --- /dev/null +++ b/frontend/src/pages/settings/AppearanceSection.tsx @@ -0,0 +1,32 @@ +import { useTheme } from '@/components/layout/ThemeProvider' +import { Card, CardContent } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' + +export function AppearanceSection() { + const { theme, toggleTheme } = useTheme() + + return ( +
+
+

Appearance

+

Customize how ABE looks.

+
+ + + +
+
+ +

Toggle between dark and light theme.

+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/pages/settings/ExplorationDefaultsSection.tsx b/frontend/src/pages/settings/ExplorationDefaultsSection.tsx new file mode 100644 index 0000000..1b7f6a8 --- /dev/null +++ b/frontend/src/pages/settings/ExplorationDefaultsSection.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { ServerConfig } from '@/types' + +export function ExplorationDefaultsSection() { + const queryClient = useQueryClient() + const { data: config, isLoading } = useQuery({ + queryKey: ['config'], + queryFn: () => apiFetch('/api/config'), + }) + + const [saving, setSaving] = useState(false) + const [maxStates, setMaxStates] = useState() + const [maxDepth, setMaxDepth] = useState() + const [actionDelayMs, setActionDelayMs] = useState() + + const effectiveMaxStates = maxStates ?? config?.defaultMaxStates ?? 50 + const effectiveMaxDepth = maxDepth ?? config?.defaultMaxDepth ?? 5 + const effectiveDelay = actionDelayMs ?? config?.defaultActionDelayMs ?? 500 + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + try { + await apiFetch('/api/config', { + method: 'PATCH', + body: JSON.stringify({ + defaultMaxStates: effectiveMaxStates, + defaultMaxDepth: effectiveMaxDepth, + defaultActionDelayMs: effectiveDelay, + }), + }) + await queryClient.invalidateQueries({ queryKey: ['config'] }) + } finally { + setSaving(false) + } + } + + if (isLoading) return

Loading...

+ + return ( +
+
+

Exploration Defaults

+

Default values for new explorations.

+
+ + + +
+
+ + setMaxStates(Number(e.target.value))} + /> +

+ Maximum number of states to explore per session. +

+
+
+ + setMaxDepth(Number(e.target.value))} + /> +

Maximum BFS depth of exploration.

+
+
+ + setActionDelayMs(Number(e.target.value))} + /> +

+ Delay between actions in milliseconds. +

+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/pages/settings/LicenseSection.tsx b/frontend/src/pages/settings/LicenseSection.tsx new file mode 100644 index 0000000..3819e25 --- /dev/null +++ b/frontend/src/pages/settings/LicenseSection.tsx @@ -0,0 +1,32 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Shield } from 'lucide-react' + +export function LicenseSection() { + return ( +
+
+

License

+

Manage your ABE license.

+
+ + + +
+ + Current Plan +
+
+ +
+ Plan + Free / OSS +
+ + License activation will be available in Phase 17 (RSA-signed keys with feature entitlements). + +
+
+
+ ) +} diff --git a/frontend/src/pages/settings/NotificationsSection.tsx b/frontend/src/pages/settings/NotificationsSection.tsx new file mode 100644 index 0000000..977c013 --- /dev/null +++ b/frontend/src/pages/settings/NotificationsSection.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { ServerConfig } from '@/types' + +export function NotificationsSection() { + const queryClient = useQueryClient() + const { data: config, isLoading } = useQuery({ + queryKey: ['config'], + queryFn: () => apiFetch('/api/config'), + }) + + const [saving, setSaving] = useState(false) + const [webhookUrl, setWebhookUrl] = useState() + const [minSeverity, setMinSeverity] = useState() + + const effectiveWebhook = webhookUrl ?? config?.slackWebhookUrl ?? '' + const effectiveMinSev = minSeverity ?? config?.notifyMinSeverity ?? 'high' + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + try { + await apiFetch('/api/config', { + method: 'PATCH', + body: JSON.stringify({ + slackWebhookUrl: effectiveWebhook || null, + notifyMinSeverity: effectiveMinSev, + }), + }) + await queryClient.invalidateQueries({ queryKey: ['config'] }) + } finally { + setSaving(false) + } + } + + if (isLoading) return

Loading...

+ + return ( +
+
+

Notifications

+

Configure Slack alerts for findings.

+
+ + + +
+
+ + setWebhookUrl(e.target.value)} + /> +

+ Leave empty to disable Slack notifications. +

+
+
+ + +

+ Only send alerts for findings at or above this severity. +

+
+ +
+
+
+
+ ) +} diff --git a/frontend/src/pages/settings/OrganizationSection.tsx b/frontend/src/pages/settings/OrganizationSection.tsx new file mode 100644 index 0000000..572cc85 --- /dev/null +++ b/frontend/src/pages/settings/OrganizationSection.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useAuth } from '@/hooks/useAuth' +import { apiFetch } from '@/lib/api' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Badge } from '@/components/ui/badge' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { UserPlus } from 'lucide-react' + +interface OrgMember { + userId: string + email: string + name: string + role: string +} + +export function OrganizationSection() { + const { user } = useAuth() + const queryClient = useQueryClient() + const orgId = user?.orgId + + const { data: members = [], isLoading } = useQuery({ + queryKey: ['org', orgId, 'members'], + queryFn: () => apiFetch(`/api/auth/organizations/${orgId}/members`), + enabled: !!orgId, + }) + + const [email, setEmail] = useState('') + const [role, setRole] = useState('member') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleInvite(e: React.FormEvent) { + e.preventDefault() + if (!orgId || !email) return + setSubmitting(true) + setError(null) + try { + await apiFetch(`/api/auth/organizations/${orgId}/members`, { + method: 'POST', + body: JSON.stringify({ email, role }), + }) + setEmail('') + await queryClient.invalidateQueries({ queryKey: ['org', orgId, 'members'] }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Invite failed') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

Organization

+

Manage members and roles.

+
+ + + + Members + + + {isLoading ? ( +

Loading...

+ ) : members.length === 0 ? ( +

No members yet.

+ ) : ( + members.map(m => ( +
+ + + {m.name?.charAt(0).toUpperCase() ?? '?'} + + +
+

{m.name}

+

{m.email}

+
+ {m.role} +
+ )) + )} +
+
+ + + + + + Invite Member + + Add a new member to your organization. + + +
+ {error &&

{error}

} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + +
+ +
+
+
+
+ ) +} diff --git a/frontend/src/pages/settings/ProfileSection.tsx b/frontend/src/pages/settings/ProfileSection.tsx new file mode 100644 index 0000000..869392a --- /dev/null +++ b/frontend/src/pages/settings/ProfileSection.tsx @@ -0,0 +1,60 @@ +import { useAuth } from '@/hooks/useAuth' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' + +export function ProfileSection() { + const { user, isLoading } = useAuth() + + if (isLoading) { + return ( +
+ + +
+ ) + } + + return ( +
+
+

Profile

+

Your account information.

+
+ + + +
+ + + {user?.name?.charAt(0).toUpperCase() ?? '?'} + + +
+ {user?.name} + {user?.email} +
+
+
+ +
+ Role + {user?.role} +
+
+ User ID + {user?.id} +
+
+
+ + + + Change Password + Password management coming in a future release. + + +
+ ) +} diff --git a/frontend/src/pages/settings/SettingsLayout.tsx b/frontend/src/pages/settings/SettingsLayout.tsx new file mode 100644 index 0000000..1c7e681 --- /dev/null +++ b/frontend/src/pages/settings/SettingsLayout.tsx @@ -0,0 +1,46 @@ +import { NavLink, Outlet } from 'react-router-dom' +import { User, Building, Key, Sliders, Bell, Palette, Shield } from 'lucide-react' +import { cn } from '@/lib/utils' + +const navItems = [ + { label: 'Profile', href: '/settings/profile', icon: User }, + { label: 'Organization', href: '/settings/organization', icon: Building }, + { label: 'API Keys', href: '/settings/api-keys', icon: Key }, + { label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders }, + { label: 'Notifications', href: '/settings/notifications', icon: Bell }, + { label: 'Appearance', href: '/settings/appearance', icon: Palette }, + { label: 'License', href: '/settings/license', icon: Shield }, +] + +export function SettingsLayout() { + return ( +
+ + +
+ +
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index c31e839..e25acc9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -88,6 +88,40 @@ export interface AnomalySummary { browser?: BrowserType; } +export type FindingStatus = 'open' | 'investigating' | 'resolved' | 'closed'; + +export interface Finding { + id: string; + sessionId: string; + type: string; + severity: Severity; + description: string; + status: FindingStatus; + browser?: BrowserType; + browserVersion?: string; + actionTraceLength: number; + actionTrace: Action[]; + evidence: AnomalyEvidence; + aiEnrichment?: AIEnrichment | null; + createdAt: string; + resolvedAt?: string | null; +} + +export interface FindingSummary { + id: string; + sessionId: string; + type: string; + severity: Severity; + description: string; + status: FindingStatus; + browser?: BrowserType; + actionTraceLength: number; + evidence: AnomalyEvidence; + aiEnrichment?: AIEnrichment | null; + createdAt: string; + resolvedAt?: string | null; +} + export interface Session { sessionId: string; url: string; diff --git a/src/api/router.ts b/src/api/router.ts index 52cbb6d..ec34400 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -5,6 +5,7 @@ import { Router } from 'express'; import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController'; import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController'; import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController'; +import { createReportingRouter } from '../modules/reporting/infrastructure/http/ReportingController'; import { createAuthController } from '../modules/auth/infrastructure/http/AuthController'; import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware'; import { ServerDependencies } from './server'; @@ -64,6 +65,7 @@ export function createRouter(deps: ServerDependencies): Router { router.use('/sessions', createCrawlingRouter(deps.crawlingDeps)); router.use('/findings', createFindingsRouter(deps.findingsDeps)); router.use('/fuzz', createFuzzingRouter(deps.fuzzingDeps)); + router.use('/reports', createReportingRouter(deps.reportingDeps)); return router; } diff --git a/src/api/server.ts b/src/api/server.ts index 75f0848..5dad985 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -18,6 +18,7 @@ import { createRouter } from './router'; import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController'; import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController'; import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController'; +import { ReportingControllerDeps } from '../modules/reporting/infrastructure/http/ReportingController'; import { AuthControllerDeps } from './router'; export interface ServerDependencies { @@ -27,6 +28,7 @@ export interface ServerDependencies { crawlingDeps: CrawlingControllerDeps; findingsDeps: FindingsControllerDeps; fuzzingDeps: FuzzingControllerDeps; + reportingDeps: ReportingControllerDeps; authDeps: AuthControllerDeps; } diff --git a/src/db/migrations/005_reports_table.ts b/src/db/migrations/005_reports_table.ts new file mode 100644 index 0000000..69df431 --- /dev/null +++ b/src/db/migrations/005_reports_table.ts @@ -0,0 +1,24 @@ +import { Kysely } from 'kysely'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function up(db: Kysely): Promise { + await db.schema + .createTable('reports') + .ifNotExists() + .addColumn('id', 'text', (col) => col.primaryKey()) + .addColumn('title', 'text', (col) => col.notNull()) + .addColumn('format', 'text', (col) => col.notNull()) + .addColumn('status', 'text', (col) => col.notNull().defaultTo('pending')) + .addColumn('filters_json', 'text', (col) => col.notNull().defaultTo('{}')) + .addColumn('file_path', 'text') + .addColumn('error_message', 'text') + .addColumn('total_findings', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('created_at', 'integer', (col) => col.notNull()) + .addColumn('completed_at', 'integer') + .execute(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function down(db: Kysely): Promise { + await db.schema.dropTable('reports').ifExists().execute(); +} diff --git a/src/jobs/workers/ReportWorker.ts b/src/jobs/workers/ReportWorker.ts index eabaeae..c29390f 100644 --- a/src/jobs/workers/ReportWorker.ts +++ b/src/jobs/workers/ReportWorker.ts @@ -1,9 +1,14 @@ /** * ReportWorker — handles 'report:generate' jobs. - * Generates reports in the background (full implementation in Phase 15). + * Generates HTML, JSON, or PDF reports in the background. */ import { JobHandler } from '../JobQueue'; import { Logger } from '../../shared/infrastructure/Logger'; +import { IReportRepository } from '../../modules/reporting/domain/ports/IReportRepository'; +import { IFindingRepository } from '../../modules/findings/domain/ports/IFindingRepository'; +import { HTMLReportGenerator } from '../../modules/reporting/infrastructure/generators/HTMLReportGenerator'; +import { JSONReportGenerator } from '../../modules/reporting/infrastructure/generators/JSONReportGenerator'; +import { PDFReportGenerator } from '../../modules/reporting/infrastructure/generators/PDFReportGenerator'; export const REPORT_JOB_TYPE = 'report:generate'; @@ -25,16 +30,51 @@ export interface ReportJobResult { export function createReportJobHandler(deps: { logger: Logger; + reportRepository: IReportRepository; + findingRepository: IFindingRepository; }): JobHandler { + const htmlGen = new HTMLReportGenerator(); + const jsonGen = new JSONReportGenerator(); + const pdfGen = new PDFReportGenerator(); + return async (payload: ReportJobPayload): Promise => { const log = deps.logger.child({ jobType: REPORT_JOB_TYPE, reportId: payload.reportId }); log.info({ format: payload.format }, 'Report generation job executing'); - // Full implementation in Phase 15 (Reporting Module) - // For now, return a placeholder result - const filePath = `./reports/${payload.reportId}.${payload.format}`; - log.info({ filePath }, 'Report job complete'); + const report = await deps.reportRepository.findById(payload.reportId); + if (!report) { + throw new Error(`Report not found: ${payload.reportId}`); + } + report.markGenerating(); + await deps.reportRepository.update(report); + + // Load findings with filters from report + const findings = await deps.findingRepository.findAll({ + sessionId: report.filters.sessionId, + severity: report.filters.severity, + }); + + let filePath: string; + try { + if (payload.format === 'pdf') { + filePath = await pdfGen.generate(report, findings); + } else if (payload.format === 'json') { + filePath = await jsonGen.generate(report, findings); + } else { + filePath = await htmlGen.generate(report, findings); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + report.markFailed(msg); + await deps.reportRepository.update(report); + throw err; + } + + report.markReady(filePath, findings.length); + await deps.reportRepository.update(report); + + log.info({ filePath, totalFindings: findings.length }, 'Report job complete'); return { reportId: payload.reportId, filePath }; }; } diff --git a/src/main.ts b/src/main.ts index fc37e75..d4d7cad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,6 +51,10 @@ import { GetUserQuery } from './modules/auth/application/queries/GetUserQuery'; import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery'; import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth/PasswordService'; +// Reporting module +import { KyselyReportRepository } from './modules/reporting/infrastructure/repositories/KyselyReportRepository'; +import { GenerateReportCommand } from './modules/reporting/application/commands/GenerateReportCommand'; + // Job queue import { SQLiteJobQueue } from './jobs/SQLiteJobQueue'; import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker'; @@ -80,6 +84,7 @@ async function bootstrap(): Promise { const sessionRepo = new KyselyCrawlSessionRepository(db); const stateRepo = new KyselyStateRepository(db); const findingRepo = new KyselyFindingRepository(db); + const reportRepo = new KyselyReportRepository(db); const fuzzRepo = new InMemoryFuzzSessionRepository(); // Suppress unused warning for stateRepo — used by crawling infrastructure @@ -125,7 +130,19 @@ async function bootstrap(): Promise { const getUserQuery = new GetUserQuery(userRepo); const listOrgMembersQuery = new ListOrgMembersQuery(orgRepo, userRepo); - // 11. HTTP server + // 11. Reporting use cases + const generateReport = new GenerateReportCommand(reportRepo, eventBus); + + // 12. Job queue (created before HTTP server so it can be injected) + const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs); + jobQueue.registerHandler( + EXPLORATION_JOB_TYPE, + createExplorationJobHandler({ sessionRepo, eventBus, logger }), + ); + jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger, reportRepository: reportRepo, findingRepository: findingRepo })); + jobQueue.start(); + + // 13. HTTP server const app = createServer({ config, logger, @@ -133,6 +150,7 @@ async function bootstrap(): Promise { crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions }, findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding }, fuzzingDeps: { runFuzz, repository: fuzzRepo }, + reportingDeps: { generateReport, reportRepository: reportRepo, jobQueue }, authDeps: { registerCommand, loginCommand, @@ -149,15 +167,6 @@ async function bootstrap(): Promise { const httpServer = http.createServer(app); - // 11. Job queue - const jobQueue = new SQLiteJobQueue(db, logger, config.jobs.pollIntervalMs); - jobQueue.registerHandler( - EXPLORATION_JOB_TYPE, - createExplorationJobHandler({ sessionRepo, eventBus, logger }), - ); - jobQueue.registerHandler(REPORT_JOB_TYPE, createReportJobHandler({ logger })); - jobQueue.start(); - // 12. Socket.io + gateway const io = new SocketIOServer(httpServer, { cors: { origin: config.cors.origin, credentials: true }, diff --git a/src/modules/findings/infrastructure/http/FindingsController.ts b/src/modules/findings/infrastructure/http/FindingsController.ts index 7bb881a..d76815b 100644 --- a/src/modules/findings/infrastructure/http/FindingsController.ts +++ b/src/modules/findings/infrastructure/http/FindingsController.ts @@ -50,7 +50,7 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router { res.json(result.value); }); - // GET /api/findings/:id — finding detail + // GET /api/findings/:id — finding detail (includes actionTrace) router.get('/:id', async (req: Request, res: Response) => { const findingId = req.params['id'] as string; const result = await deps.getFinding.execute({ findingId }); @@ -58,7 +58,8 @@ export function createFindingsRouter(deps: FindingsControllerDeps): Router { res.status(404).json({ error: result.error }); return; } - res.json(toDTO(result.value)); + const f = result.value; + res.json({ ...toDTO(f), actionTrace: f.actionTrace }); }); // PATCH /api/findings/:id/status — update status diff --git a/src/modules/reporting/application/commands/GenerateReportCommand.ts b/src/modules/reporting/application/commands/GenerateReportCommand.ts new file mode 100644 index 0000000..33911c3 --- /dev/null +++ b/src/modules/reporting/application/commands/GenerateReportCommand.ts @@ -0,0 +1,50 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { IReportRepository } from '../../domain/ports/IReportRepository'; +import { Report, ReportFilters } from '../../domain/entities/Report'; +import { ReportFormat } from '../../domain/value-objects/ReportFormat'; + +export interface GenerateReportRequest { + title: string; + format: 'html' | 'json' | 'pdf'; + filters?: ReportFilters; +} + +export interface GenerateReportResponse { + reportId: string; + status: string; +} + +export class GenerateReportCommand + implements UseCase +{ + constructor( + private readonly reportRepository: IReportRepository, + private readonly eventBus: EventBus + ) {} + + async execute(request: GenerateReportRequest): Promise> { + let format: ReportFormat; + try { + format = ReportFormat.fromString(request.format); + } catch { + return Err(`Invalid format: ${request.format}`); + } + + const report = Report.create({ + title: request.title, + format, + filters: request.filters ?? {}, + }); + + await this.reportRepository.save(report); + + const events = report.clearEvents(); + for (const event of events) { + await this.eventBus.publish(event); + } + + return Ok({ reportId: report.id.toString(), status: report.status.value }); + } +} diff --git a/src/modules/reporting/domain/entities/Report.ts b/src/modules/reporting/domain/entities/Report.ts new file mode 100644 index 0000000..463b998 --- /dev/null +++ b/src/modules/reporting/domain/entities/Report.ts @@ -0,0 +1,90 @@ +import { AggregateRoot } from '../../../../shared/domain/AggregateRoot'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { ReportFormat } from '../value-objects/ReportFormat'; +import { ReportStatus } from '../value-objects/ReportStatus'; +import { ReportRequested } from '../events/ReportRequested'; +import { ReportGenerated } from '../events/ReportGenerated'; +import { ReportFailed } from '../events/ReportFailed'; + +export interface ReportFilters { + sessionId?: string; + startDate?: Date; + endDate?: Date; + severity?: string; +} + +export interface ReportProps { + title: string; + format: ReportFormat; + status: ReportStatus; + filters: ReportFilters; + filePath?: string; + errorMessage?: string; + totalFindings: number; + createdAt: Date; + completedAt?: Date; +} + +export class Report extends AggregateRoot { + static create(props: { title: string; format: ReportFormat; filters: ReportFilters }, id?: UniqueId): Report { + const reportId = id ?? UniqueId.create(); + const report = new Report( + { + ...props, + status: ReportStatus.pending(), + totalFindings: 0, + createdAt: new Date(), + }, + reportId + ); + report.addDomainEvent( + new ReportRequested(reportId.toString(), { + title: props.title, + format: props.format.value, + filters: props.filters as Record, + }) + ); + return report; + } + + static reconstitute(props: ReportProps, id: UniqueId): Report { + return new Report(props, id); + } + + get title(): string { return this.props.title; } + get format(): ReportFormat { return this.props.format; } + get status(): ReportStatus { return this.props.status; } + get filters(): ReportFilters { return this.props.filters; } + get filePath(): string | undefined { return this.props.filePath; } + get errorMessage(): string | undefined { return this.props.errorMessage; } + get totalFindings(): number { return this.props.totalFindings; } + get createdAt(): Date { return this.props.createdAt; } + get completedAt(): Date | undefined { return this.props.completedAt; } + + markGenerating(): void { + this.props.status = ReportStatus.generating(); + } + + markReady(filePath: string, totalFindings: number): void { + this.props.status = ReportStatus.ready(); + this.props.filePath = filePath; + this.props.totalFindings = totalFindings; + this.props.completedAt = new Date(); + this.addDomainEvent( + new ReportGenerated(this.id.toString(), { + filePath, + totalFindings, + format: this.props.format.value, + }) + ); + } + + markFailed(errorMessage: string): void { + this.props.status = ReportStatus.failed(); + this.props.errorMessage = errorMessage; + this.props.completedAt = new Date(); + this.addDomainEvent( + new ReportFailed(this.id.toString(), { errorMessage }) + ); + } +} diff --git a/src/modules/reporting/domain/events/ReportFailed.ts b/src/modules/reporting/domain/events/ReportFailed.ts new file mode 100644 index 0000000..07b3ba4 --- /dev/null +++ b/src/modules/reporting/domain/events/ReportFailed.ts @@ -0,0 +1,9 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class ReportFailed implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'reporting.report_failed'; + readonly occurredOn = new Date(); + constructor(readonly aggregateId: string, readonly payload: Record) {} +} diff --git a/src/modules/reporting/domain/events/ReportGenerated.ts b/src/modules/reporting/domain/events/ReportGenerated.ts new file mode 100644 index 0000000..05c3168 --- /dev/null +++ b/src/modules/reporting/domain/events/ReportGenerated.ts @@ -0,0 +1,9 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class ReportGenerated implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'reporting.report_generated'; + readonly occurredOn = new Date(); + constructor(readonly aggregateId: string, readonly payload: Record) {} +} diff --git a/src/modules/reporting/domain/events/ReportRequested.ts b/src/modules/reporting/domain/events/ReportRequested.ts new file mode 100644 index 0000000..bf1a81a --- /dev/null +++ b/src/modules/reporting/domain/events/ReportRequested.ts @@ -0,0 +1,9 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class ReportRequested implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'reporting.report_requested'; + readonly occurredOn = new Date(); + constructor(readonly aggregateId: string, readonly payload: Record) {} +} diff --git a/src/modules/reporting/domain/ports/IReportGenerator.ts b/src/modules/reporting/domain/ports/IReportGenerator.ts new file mode 100644 index 0000000..6ee3539 --- /dev/null +++ b/src/modules/reporting/domain/ports/IReportGenerator.ts @@ -0,0 +1,6 @@ +import { Finding } from '../../../findings/domain/entities/Finding'; +import { Report } from '../entities/Report'; + +export interface IReportGenerator { + generate(report: Report, findings: Finding[]): Promise; +} diff --git a/src/modules/reporting/domain/ports/IReportRepository.ts b/src/modules/reporting/domain/ports/IReportRepository.ts new file mode 100644 index 0000000..ddc2d67 --- /dev/null +++ b/src/modules/reporting/domain/ports/IReportRepository.ts @@ -0,0 +1,8 @@ +import { Report } from '../entities/Report'; + +export interface IReportRepository { + save(report: Report): Promise; + findById(id: string): Promise; + findAll(): Promise; + update(report: Report): Promise; +} diff --git a/src/modules/reporting/domain/value-objects/DateRange.ts b/src/modules/reporting/domain/value-objects/DateRange.ts new file mode 100644 index 0000000..d356d65 --- /dev/null +++ b/src/modules/reporting/domain/value-objects/DateRange.ts @@ -0,0 +1,16 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +interface DateRangeProps { + startDate: Date; + endDate: Date; +} + +export class DateRange extends ValueObject { + get startDate(): Date { return this.props.startDate; } + get endDate(): Date { return this.props.endDate; } + + static create(startDate: Date, endDate: Date): DateRange { + if (startDate > endDate) throw new Error('startDate must be before endDate'); + return new DateRange({ startDate, endDate }); + } +} diff --git a/src/modules/reporting/domain/value-objects/ReportFormat.ts b/src/modules/reporting/domain/value-objects/ReportFormat.ts new file mode 100644 index 0000000..f84081b --- /dev/null +++ b/src/modules/reporting/domain/value-objects/ReportFormat.ts @@ -0,0 +1,20 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +interface ReportFormatProps { + value: 'html' | 'json' | 'pdf'; +} + +export class ReportFormat extends ValueObject { + get value(): 'html' | 'json' | 'pdf' { return this.props.value; } + + static html(): ReportFormat { return new ReportFormat({ value: 'html' }); } + static json(): ReportFormat { return new ReportFormat({ value: 'json' }); } + static pdf(): ReportFormat { return new ReportFormat({ value: 'pdf' }); } + + static fromString(s: string): ReportFormat { + if (s === 'html' || s === 'json' || s === 'pdf') { + return new ReportFormat({ value: s }); + } + throw new Error(`Invalid report format: ${s}`); + } +} diff --git a/src/modules/reporting/domain/value-objects/ReportStatus.ts b/src/modules/reporting/domain/value-objects/ReportStatus.ts new file mode 100644 index 0000000..4c86c07 --- /dev/null +++ b/src/modules/reporting/domain/value-objects/ReportStatus.ts @@ -0,0 +1,21 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +interface ReportStatusProps { + value: 'pending' | 'generating' | 'ready' | 'failed'; +} + +export class ReportStatus extends ValueObject { + get value(): 'pending' | 'generating' | 'ready' | 'failed' { return this.props.value; } + + static pending(): ReportStatus { return new ReportStatus({ value: 'pending' }); } + static generating(): ReportStatus { return new ReportStatus({ value: 'generating' }); } + static ready(): ReportStatus { return new ReportStatus({ value: 'ready' }); } + static failed(): ReportStatus { return new ReportStatus({ value: 'failed' }); } + + static fromString(s: string): ReportStatus { + if (s === 'pending' || s === 'generating' || s === 'ready' || s === 'failed') { + return new ReportStatus({ value: s }); + } + throw new Error(`Invalid report status: ${s}`); + } +} diff --git a/src/modules/reporting/infrastructure/generators/HTMLReportGenerator.ts b/src/modules/reporting/infrastructure/generators/HTMLReportGenerator.ts new file mode 100644 index 0000000..202a277 --- /dev/null +++ b/src/modules/reporting/infrastructure/generators/HTMLReportGenerator.ts @@ -0,0 +1,110 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { IReportGenerator } from '../../domain/ports/IReportGenerator'; +import { Report } from '../../domain/entities/Report'; +import { Finding } from '../../../findings/domain/entities/Finding'; + +export class HTMLReportGenerator implements IReportGenerator { + async generate(report: Report, findings: Finding[]): Promise { + const outputDir = path.join(process.cwd(), 'reports', report.id.toString()); + fs.mkdirSync(outputDir, { recursive: true }); + + const severityCounts: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const f of findings) { + const sev = f.severity.value; + severityCounts[sev] = (severityCounts[sev] ?? 0) + 1; + } + + const findingsHtml = findings.map(f => ` +
+
+ ${f.severity.value.toUpperCase()} + ${f.type.value} + ${f.status.value} +
+

${escapeHtml(f.description)}

+ + Session: ${f.sessionId}  ·  + ${new Date(f.createdAt).toLocaleString()} + +
+ `).join('\n'); + + const html = ` + + + + + ${escapeHtml(report.title)} + + + +

${escapeHtml(report.title)}

+
+ Generated by ABE  ·  ${new Date().toLocaleString()} + ${report.filters.sessionId ? ` ·  Session: ${report.filters.sessionId}` : ''} +
+ +
+
+
${findings.length}
+
Total
+
+
+
${severityCounts['critical'] ?? 0}
+
Critical
+
+
+
${severityCounts['high'] ?? 0}
+
High
+
+
+
${severityCounts['medium'] ?? 0}
+
Medium
+
+
+
${severityCounts['low'] ?? 0}
+
Low
+
+
+ +

Findings (${findings.length})

+ ${findings.length === 0 ? '

No findings match the selected filters.

' : findingsHtml} + +
Generated by ABE — Autonomous Bug Explorer
+ +`; + + const filePath = path.join(outputDir, `report.html`); + fs.writeFileSync(filePath, html, 'utf8'); + return filePath; + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/modules/reporting/infrastructure/generators/JSONReportGenerator.ts b/src/modules/reporting/infrastructure/generators/JSONReportGenerator.ts new file mode 100644 index 0000000..f2ea1e3 --- /dev/null +++ b/src/modules/reporting/infrastructure/generators/JSONReportGenerator.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { IReportGenerator } from '../../domain/ports/IReportGenerator'; +import { Report } from '../../domain/entities/Report'; +import { Finding } from '../../../findings/domain/entities/Finding'; + +export class JSONReportGenerator implements IReportGenerator { + async generate(report: Report, findings: Finding[]): Promise { + const outputDir = path.join(process.cwd(), 'reports', report.id.toString()); + fs.mkdirSync(outputDir, { recursive: true }); + + const data = { + reportId: report.id.toString(), + title: report.title, + generatedAt: new Date().toISOString(), + filters: report.filters, + summary: { + total: findings.length, + bySeverity: buildSeverityCount(findings), + byStatus: buildStatusCount(findings), + }, + findings: findings.map(f => ({ + id: f.id.toString(), + sessionId: f.sessionId, + type: f.type.value, + severity: f.severity.value, + description: f.description, + status: f.status.value, + browser: f.browser, + createdAt: f.createdAt.toISOString(), + resolvedAt: f.resolvedAt?.toISOString() ?? null, + evidence: f.evidence.toJSON(), + actionTraceLength: f.actionTrace.length, + })), + }; + + const filePath = path.join(outputDir, 'report.json'); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); + return filePath; + } +} + +function buildSeverityCount(findings: Finding[]): Record { + const counts: Record = {}; + for (const f of findings) { + const s = f.severity.value; + counts[s] = (counts[s] ?? 0) + 1; + } + return counts; +} + +function buildStatusCount(findings: Finding[]): Record { + const counts: Record = {}; + for (const f of findings) { + const s = f.status.value; + counts[s] = (counts[s] ?? 0) + 1; + } + return counts; +} diff --git a/src/modules/reporting/infrastructure/generators/PDFReportGenerator.ts b/src/modules/reporting/infrastructure/generators/PDFReportGenerator.ts new file mode 100644 index 0000000..2ed59e2 --- /dev/null +++ b/src/modules/reporting/infrastructure/generators/PDFReportGenerator.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { IReportGenerator } from '../../domain/ports/IReportGenerator'; +import { Report } from '../../domain/entities/Report'; +import { Finding } from '../../../findings/domain/entities/Finding'; +import { HTMLReportGenerator } from './HTMLReportGenerator'; + +/** + * PDF report generator — uses Playwright to render the HTML report to PDF. + * Requires Playwright + Chromium to be installed. + */ +export class PDFReportGenerator implements IReportGenerator { + private readonly htmlGenerator = new HTMLReportGenerator(); + + async generate(report: Report, findings: Finding[]): Promise { + // First generate the HTML version + const htmlPath = await this.htmlGenerator.generate(report, findings); + const outputDir = path.dirname(htmlPath); + const pdfPath = path.join(outputDir, 'report.pdf'); + + // Use Playwright to convert HTML to PDF + let chromium; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pw = require('playwright') as typeof import('playwright'); + chromium = pw.chromium; + } catch { + throw new Error('Playwright not available — install playwright to generate PDF reports'); + } + + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const htmlContent = fs.readFileSync(htmlPath, 'utf8'); + await page.setContent(htmlContent, { waitUntil: 'networkidle' }); + await page.pdf({ + path: pdfPath, + format: 'A4', + printBackground: true, + margin: { top: '1cm', bottom: '1cm', left: '1cm', right: '1cm' }, + }); + } finally { + await browser.close(); + } + + return pdfPath; + } +} diff --git a/src/modules/reporting/infrastructure/http/ReportingController.ts b/src/modules/reporting/infrastructure/http/ReportingController.ts new file mode 100644 index 0000000..e442be7 --- /dev/null +++ b/src/modules/reporting/infrastructure/http/ReportingController.ts @@ -0,0 +1,131 @@ +import { Router, Request, Response } from 'express'; +import * as path from 'path'; +import * as fs from 'fs'; +import { GenerateReportCommand } from '../../application/commands/GenerateReportCommand'; +import { IReportRepository } from '../../domain/ports/IReportRepository'; +import { IJobQueue } from '../../../../jobs/JobQueue'; +import { REPORT_JOB_TYPE, ReportJobPayload } from '../../../../jobs/workers/ReportWorker'; + +export interface ReportingControllerDeps { + generateReport: GenerateReportCommand; + reportRepository: IReportRepository; + jobQueue: IJobQueue; +} + +export function createReportingRouter(deps: ReportingControllerDeps): Router { + const router = Router(); + + // POST /api/reports — create and enqueue report + router.post('/', async (req: Request, res: Response) => { + const { title, format, filters } = req.body as { + title?: string; + format?: string; + filters?: { + sessionId?: string; + startDate?: string; + endDate?: string; + severity?: string; + }; + }; + + if (!title || !format) { + res.status(400).json({ error: 'title and format are required' }); + return; + } + + const result = await deps.generateReport.execute({ + title, + format: format as 'html' | 'json' | 'pdf', + filters: filters + ? { + sessionId: filters.sessionId, + startDate: filters.startDate ? new Date(filters.startDate) : undefined, + endDate: filters.endDate ? new Date(filters.endDate) : undefined, + severity: filters.severity, + } + : undefined, + }); + + if (!result.ok) { + res.status(400).json({ error: result.error }); + return; + } + + // Enqueue background job + await deps.jobQueue.enqueue(REPORT_JOB_TYPE, { + reportId: result.value.reportId, + format: format as 'html' | 'json' | 'pdf', + filters: filters as ReportJobPayload['filters'], + }); + + res.status(201).json(result.value); + }); + + // GET /api/reports — list all reports + router.get('/', async (_req: Request, res: Response) => { + const reports = await deps.reportRepository.findAll(); + res.json( + reports.map(r => ({ + id: r.id.toString(), + title: r.title, + format: r.format.value, + status: r.status.value, + totalFindings: r.totalFindings, + createdAt: r.createdAt.toISOString(), + completedAt: r.completedAt?.toISOString() ?? null, + })) + ); + }); + + // GET /api/reports/:id — report detail + router.get('/:id', async (req: Request, res: Response) => { + const report = await deps.reportRepository.findById(req.params['id'] as string); + if (!report) { + res.status(404).json({ error: 'Report not found' }); + return; + } + res.json({ + id: report.id.toString(), + title: report.title, + format: report.format.value, + status: report.status.value, + filters: report.filters, + totalFindings: report.totalFindings, + errorMessage: report.errorMessage, + createdAt: report.createdAt.toISOString(), + completedAt: report.completedAt?.toISOString() ?? null, + }); + }); + + // GET /api/reports/:id/download — download the generated file + router.get('/:id/download', async (req: Request, res: Response) => { + const report = await deps.reportRepository.findById(req.params['id'] as string); + if (!report) { + res.status(404).json({ error: 'Report not found' }); + return; + } + if (report.status.value !== 'ready' || !report.filePath) { + res.status(409).json({ error: 'Report is not ready yet', status: report.status.value }); + return; + } + if (!fs.existsSync(report.filePath)) { + res.status(410).json({ error: 'Report file no longer exists' }); + return; + } + + const ext = path.extname(report.filePath); + const contentTypes: Record = { + '.html': 'text/html', + '.json': 'application/json', + '.pdf': 'application/pdf', + }; + const contentType = contentTypes[ext] ?? 'application/octet-stream'; + const filename = `report-${report.id.toString()}${ext}`; + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + fs.createReadStream(report.filePath).pipe(res); + }); + + return router; +} diff --git a/src/modules/reporting/infrastructure/repositories/KyselyReportRepository.ts b/src/modules/reporting/infrastructure/repositories/KyselyReportRepository.ts new file mode 100644 index 0000000..4e8df61 --- /dev/null +++ b/src/modules/reporting/infrastructure/repositories/KyselyReportRepository.ts @@ -0,0 +1,84 @@ +import { Kysely } from 'kysely'; +import { Database, ReportTable } from '../../../../shared/infrastructure/DatabaseConnection'; +import { IReportRepository } from '../../domain/ports/IReportRepository'; +import { Report, ReportProps, ReportFilters } from '../../domain/entities/Report'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { ReportFormat } from '../../domain/value-objects/ReportFormat'; +import { ReportStatus } from '../../domain/value-objects/ReportStatus'; + +export class KyselyReportRepository implements IReportRepository { + constructor(private readonly db: Kysely) {} + + async save(report: Report): Promise { + const row: ReportTable = { + id: report.id.toString(), + title: report.title, + format: report.format.value, + status: report.status.value, + filters_json: JSON.stringify(report.filters), + file_path: report.filePath ?? null, + error_message: report.errorMessage ?? null, + total_findings: report.totalFindings, + created_at: report.createdAt.getTime(), + completed_at: report.completedAt ? report.completedAt.getTime() : null, + }; + await this.db.insertInto('reports').values(row).execute(); + } + + async findById(id: string): Promise { + const row = await this.db + .selectFrom('reports') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + + async findAll(): Promise { + const rows = await this.db + .selectFrom('reports') + .selectAll() + .orderBy('created_at', 'desc') + .execute(); + return rows.map(r => this.toDomain(r)); + } + + async update(report: Report): Promise { + await this.db + .updateTable('reports') + .set({ + status: report.status.value, + file_path: report.filePath ?? null, + error_message: report.errorMessage ?? null, + total_findings: report.totalFindings, + completed_at: report.completedAt ? report.completedAt.getTime() : null, + }) + .where('id', '=', report.id.toString()) + .execute(); + } + + private toDomain(row: ReportTable): Report { + const filters = this.parseJson(row.filters_json, {}); + const props: ReportProps = { + title: row.title, + format: ReportFormat.fromString(row.format), + status: ReportStatus.fromString(row.status), + filters: { + sessionId: filters.sessionId, + startDate: filters.startDate ? new Date(filters.startDate) : undefined, + endDate: filters.endDate ? new Date(filters.endDate) : undefined, + severity: filters.severity, + }, + filePath: row.file_path ?? undefined, + errorMessage: row.error_message ?? undefined, + totalFindings: row.total_findings, + createdAt: new Date(row.created_at), + completedAt: row.completed_at ? new Date(row.completed_at) : undefined, + }; + return Report.reconstitute(props, UniqueId.from(row.id)); + } + + private parseJson(json: string, fallback: T): T { + try { return JSON.parse(json) as T; } catch { return fallback; } + } +} diff --git a/src/shared/infrastructure/DatabaseConnection.ts b/src/shared/infrastructure/DatabaseConnection.ts index 0d6b152..5aa0aac 100644 --- a/src/shared/infrastructure/DatabaseConnection.ts +++ b/src/shared/infrastructure/DatabaseConnection.ts @@ -201,6 +201,19 @@ export interface AuthSessionTable { created_at: number; } +export interface ReportTable { + id: string; + title: string; + format: string; + status: string; + filters_json: string; + file_path: string | null; + error_message: string | null; + total_findings: number; + created_at: number; + completed_at: number | null; +} + export interface Database { sessions: SessionTable; states: StateTable; @@ -218,6 +231,7 @@ export interface Database { org_members: OrgMemberTable; api_keys: ApiKeyTable; auth_sessions: AuthSessionTable; + reports: ReportTable; } export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely { diff --git a/tests/modules/reporting.test.ts b/tests/modules/reporting.test.ts new file mode 100644 index 0000000..f9072a6 --- /dev/null +++ b/tests/modules/reporting.test.ts @@ -0,0 +1,198 @@ +import { Report } from '../../src/modules/reporting/domain/entities/Report'; +import { ReportFormat } from '../../src/modules/reporting/domain/value-objects/ReportFormat'; +import { ReportStatus } from '../../src/modules/reporting/domain/value-objects/ReportStatus'; +import { GenerateReportCommand } from '../../src/modules/reporting/application/commands/GenerateReportCommand'; +import { IReportRepository } from '../../src/modules/reporting/domain/ports/IReportRepository'; +import { EventBus } from '../../src/shared/application/EventBus'; +import { DomainEvent } from '../../src/shared/domain/DomainEvent'; +import { EventHandler } from '../../src/shared/application/EventHandler'; + +// ─── Mock Repository ────────────────────────────────────────────────────────── + +class InMemoryReportRepository implements IReportRepository { + private store = new Map(); + + async save(report: Report): Promise { + this.store.set(report.id.toString(), report); + } + + async findById(id: string): Promise { + return this.store.get(id); + } + + async findAll(): Promise { + return Array.from(this.store.values()); + } + + async update(report: Report): Promise { + this.store.set(report.id.toString(), report); + } +} + +// ─── Mock EventBus ──────────────────────────────────────────────────────────── + +class MockEventBus implements EventBus { + published: DomainEvent[] = []; + + async publish(event: DomainEvent): Promise { + this.published.push(event); + } + + subscribe(_name: string, _handler: EventHandler): void { + // no-op + } +} + +// ─── Report domain tests ─────────────────────────────────────────────────────── + +describe('ReportFormat', () => { + it('parses valid formats', () => { + expect(ReportFormat.fromString('html').value).toBe('html'); + expect(ReportFormat.fromString('json').value).toBe('json'); + expect(ReportFormat.fromString('pdf').value).toBe('pdf'); + }); + + it('throws on invalid format', () => { + expect(() => ReportFormat.fromString('xml')).toThrow(); + }); +}); + +describe('ReportStatus', () => { + it('creates statuses', () => { + expect(ReportStatus.pending().value).toBe('pending'); + expect(ReportStatus.generating().value).toBe('generating'); + expect(ReportStatus.ready().value).toBe('ready'); + expect(ReportStatus.failed().value).toBe('failed'); + }); + + it('parses from string', () => { + expect(ReportStatus.fromString('ready').value).toBe('ready'); + }); + + it('throws on unknown status', () => { + expect(() => ReportStatus.fromString('unknown')).toThrow(); + }); +}); + +describe('Report aggregate', () => { + it('creates a report with pending status and emits ReportRequested event', () => { + const report = Report.create({ + title: 'Test Report', + format: ReportFormat.fromString('html'), + filters: { severity: 'high' }, + }); + + expect(report.title).toBe('Test Report'); + expect(report.status.value).toBe('pending'); + expect(report.totalFindings).toBe(0); + + const events = report.clearEvents(); + expect(events).toHaveLength(1); + expect(events[0]!.eventName).toBe('reporting.report_requested'); + }); + + it('marks report as generating', () => { + const report = Report.create({ + title: 'T', + format: ReportFormat.fromString('json'), + filters: {}, + }); + report.clearEvents(); + + report.markGenerating(); + expect(report.status.value).toBe('generating'); + }); + + it('marks report as ready and emits ReportGenerated', () => { + const report = Report.create({ + title: 'T', + format: ReportFormat.fromString('json'), + filters: {}, + }); + report.clearEvents(); + + report.markReady('/reports/123/report.json', 5); + expect(report.status.value).toBe('ready'); + expect(report.filePath).toBe('/reports/123/report.json'); + expect(report.totalFindings).toBe(5); + expect(report.completedAt).toBeInstanceOf(Date); + + const events = report.clearEvents(); + expect(events[0]!.eventName).toBe('reporting.report_generated'); + }); + + it('marks report as failed and emits ReportFailed', () => { + const report = Report.create({ + title: 'T', + format: ReportFormat.fromString('pdf'), + filters: {}, + }); + report.clearEvents(); + + report.markFailed('Playwright error'); + expect(report.status.value).toBe('failed'); + expect(report.errorMessage).toBe('Playwright error'); + + const events = report.clearEvents(); + expect(events[0]!.eventName).toBe('reporting.report_failed'); + }); +}); + +// ─── GenerateReportCommand tests ─────────────────────────────────────────────── + +describe('GenerateReportCommand', () => { + let repo: InMemoryReportRepository; + let eventBus: MockEventBus; + let cmd: GenerateReportCommand; + + beforeEach(() => { + repo = new InMemoryReportRepository(); + eventBus = new MockEventBus(); + cmd = new GenerateReportCommand(repo, eventBus); + }); + + it('creates and persists a report, returns Ok with reportId', async () => { + const result = await cmd.execute({ title: 'My Report', format: 'html' }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const { reportId, status } = result.value; + expect(status).toBe('pending'); + + const saved = await repo.findById(reportId); + expect(saved).toBeDefined(); + expect(saved!.title).toBe('My Report'); + expect(saved!.format.value).toBe('html'); + }); + + it('publishes ReportRequested event after creation', async () => { + await cmd.execute({ title: 'Report', format: 'json' }); + + expect(eventBus.published).toHaveLength(1); + expect(eventBus.published[0]!.eventName).toBe('reporting.report_requested'); + }); + + it('applies filters when provided', async () => { + const result = await cmd.execute({ + title: 'Filtered', + format: 'pdf', + filters: { sessionId: 'abc-123', severity: 'critical' }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const saved = await repo.findById(result.value.reportId); + expect(saved!.filters.sessionId).toBe('abc-123'); + expect(saved!.filters.severity).toBe('critical'); + }); + + it('returns Err for invalid format', async () => { + const result = await cmd.execute({ title: 'Bad', format: 'xml' as 'html' }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toContain('Invalid format'); + }); +});