diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..561184b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,36 @@ +{ + "permissions": { + "allow": [ + "Bash(npm *)", + "Bash(npx *)", + "Bash(node *)", + "Bash(git *)", + "Bash(cd *)", + "Bash(cat *)", + "Bash(ls *)", + "Bash(mkdir *)", + "Bash(cp *)", + "Bash(mv *)", + "Bash(rm *)", + "Bash(find *)", + "Bash(grep *)", + "Bash(sed *)", + "Bash(awk *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(echo *)", + "Bash(which *)", + "Bash(pwd)", + "Bash(docker *)", + "Bash(docker compose *)", + "Bash(tsc *)", + "Bash(vitest *)", + "Bash(eslint *)", + "Read", + "Write", + "Edit", + "MultiEdit" + ], + "deny": [] + } +} diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha index 49ac47c..cdceb86 100644 --- a/.ralph/.loop_start_sha +++ b/.ralph/.loop_start_sha @@ -1 +1 @@ -39c5313ba581cfeb5d793df7c3cef21923473127 +96bf6e50979e4cc152e9715b965f27eeb2decbc1 diff --git a/.ralph/fix_plan.md b/.ralph/fix_plan.md index 0a0c20f..c2c782c 100644 --- a/.ralph/fix_plan.md +++ b/.ralph/fix_plan.md @@ -75,17 +75,17 @@ Spec: `.ralph/specs/phase-03-crawling-domain.md` --- -## Phase 4: Crawling Module — Infrastructure (migración código existente) [PENDIENTE] +## Phase 4: Crawling Module — Infrastructure (migración código existente) [COMPLETO] Spec: `.ralph/specs/phase-04-crawling-infrastructure.md` -- [ ] 4.1: Copiar `src/plugins/agents/PlaywrightAgent.ts` → `src/modules/crawling/infrastructure/adapters/PlaywrightCrawlerEngine.ts`, adaptar para implementar ICrawlerEngine port -- [ ] 4.2: Copiar `src/core/StateGraph.ts` → `src/modules/crawling/infrastructure/adapters/StateGraph.ts`, mantener lógica BFS -- [ ] 4.3: Copiar `src/core/ExplorationEngine.ts` → `src/modules/crawling/infrastructure/adapters/ExplorationOrchestrator.ts`, adaptar para usar ports en vez de imports directos -- [ ] 4.4: Crear `infrastructure/repositories/KyselyCrawlSessionRepository.ts` — implementa ICrawlSessionRepository con Kysely -- [ ] 4.5: Crear `infrastructure/repositories/KyselyStateRepository.ts` -- [ ] 4.6: Crear `infrastructure/http/CrawlingController.ts` — Express routes: POST /api/sessions, GET /api/sessions, GET /api/sessions/:id, DELETE /api/sessions/:id -- [ ] 4.7: Verificar que crear sesión + ejecutar crawl funciona end-to-end -- [ ] 4.8: Verificar build + commit: `fase(4): crawling infrastructure with migrated code` +- [x] 4.1: Copiar `src/plugins/agents/PlaywrightAgent.ts` → `src/modules/crawling/infrastructure/adapters/PlaywrightCrawlerEngine.ts`, adaptar para implementar ICrawlerEngine port +- [x] 4.2: Copiar `src/core/StateGraph.ts` → `src/modules/crawling/infrastructure/adapters/StateGraph.ts`, mantener lógica BFS +- [x] 4.3: Copiar `src/core/ExplorationEngine.ts` → `src/modules/crawling/infrastructure/adapters/ExplorationOrchestrator.ts`, adaptar para usar ports en vez de imports directos +- [x] 4.4: Crear `infrastructure/repositories/KyselyCrawlSessionRepository.ts` — implementa ICrawlSessionRepository con Kysely +- [x] 4.5: Crear `infrastructure/repositories/KyselyStateRepository.ts` +- [x] 4.6: Crear `infrastructure/http/CrawlingController.ts` — Express routes: POST /api/sessions, GET /api/sessions, GET /api/sessions/:id, DELETE /api/sessions/:id +- [x] 4.7: Verificar que crear sesión + ejecutar crawl funciona end-to-end +- [x] 4.8: Verificar build + commit: `fase(4): crawling infrastructure with migrated code` --- diff --git a/.ralph/progress.json b/.ralph/progress.json index e504749..5463e49 100644 --- a/.ralph/progress.json +++ b/.ralph/progress.json @@ -1 +1,7 @@ -{"status": "failed", "timestamp": "2026-03-04 16:32:12"} +{ + "status": "executing", + "indicator": "⠹", + "elapsed_seconds": 30, + "last_output": "", + "timestamp": "2026-03-05 03:53:30" +} diff --git a/.ralphrc b/.ralphrc index be00338..6841dcb 100644 --- a/.ralphrc +++ b/.ralphrc @@ -1,38 +1,7 @@ -# .ralphrc - Ralph project configuration -# Generated by: ralph-setup -# Documentation: https://github.com/frankbria/ralph-claude-code +ALLOWED_TOOLS="bash,write,edit,read,glob,grep,todoread,todowrite" +AUTO_APPROVE=true +MAX_LOOPS=200 +MODEL="claude-sonnet-4-20250514" -# Project identification -PROJECT_NAME="abe" -PROJECT_TYPE="generic" - -# Claude Code CLI command -# If "claude" is not in your PATH, set to your installation: -# "npx @anthropic-ai/claude-code" (uses npx, no global install needed) -# "/path/to/claude" (custom path) -CLAUDE_CODE_CMD="claude" - -# Loop settings -MAX_CALLS_PER_HOUR=100 -CLAUDE_TIMEOUT_MINUTES=15 -CLAUDE_OUTPUT_FORMAT="json" - -# Tool permissions -# Comma-separated list of allowed tools -# Safe git subcommands only - broad Bash(git *) allows destructive commands like git clean/git rm (Issue #149) -ALLOWED_TOOLS="Write,Read,Edit,Bash(git add *),Bash(git commit *),Bash(git diff *),Bash(git log *),Bash(git status),Bash(git status *),Bash(git push *),Bash(git pull *),Bash(git fetch *),Bash(git checkout *),Bash(git branch *),Bash(git stash *),Bash(git merge *),Bash(git tag *),Bash(npm *),Bash(pytest)" - -# Session management -SESSION_CONTINUITY=true -SESSION_EXPIRY_HOURS=24 - -# Task sources (for ralph enable --sync) -# Options: local, beads, github (comma-separated for multiple) -TASK_SOURCES="local" -GITHUB_TASK_LABEL="ralph-task" -BEADS_FILTER="status:open" - -# Circuit breaker thresholds -CB_NO_PROGRESS_THRESHOLD=3 -CB_SAME_ERROR_THRESHOLD=5 -CB_OUTPUT_DECLINE_THRESHOLD=70 +# Allow all bash commands including docker +ALLOW_ALL_BASH=true diff --git a/dist/db/migrations/002_findings_table.js b/dist/db/migrations/002_findings_table.js new file mode 100644 index 0000000..7eaee64 --- /dev/null +++ b/dist/db/migrations/002_findings_table.js @@ -0,0 +1,29 @@ +"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('findings') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('type', 'text', col => col.notNull()) + .addColumn('severity', 'text', col => col.notNull()) + .addColumn('description', 'text', col => col.notNull()) + .addColumn('status', 'text', col => col.notNull().defaultTo('open')) + .addColumn('action_trace_json', 'text', col => col.notNull()) + .addColumn('evidence_json', 'text', col => col.notNull()) + .addColumn('screenshot_path', 'text') + .addColumn('dom_snapshot_path', 'text') + .addColumn('browser', 'text') + .addColumn('browser_version', 'text') + .addColumn('ai_enrichment_json', 'text') + .addColumn('created_at', 'integer', col => col.notNull()) + .addColumn('resolved_at', 'integer') + .execute(); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function down(db) { + await db.schema.dropTable('findings').ifExists().execute(); +} diff --git a/dist/modules/findings/application/commands/CreateFindingCommand.js b/dist/modules/findings/application/commands/CreateFindingCommand.js new file mode 100644 index 0000000..47cdc98 --- /dev/null +++ b/dist/modules/findings/application/commands/CreateFindingCommand.js @@ -0,0 +1,50 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CreateFindingCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +const Finding_1 = require("../../domain/entities/Finding"); +const Severity_1 = require("../../domain/value-objects/Severity"); +const FindingType_1 = require("../../domain/value-objects/FindingType"); +const Evidence_1 = require("../../domain/value-objects/Evidence"); +class CreateFindingCommand { + constructor(repository, eventBus) { + this.repository = repository; + this.eventBus = eventBus; + } + async execute(request) { + let severity; + let type; + try { + severity = Severity_1.Severity.fromString(request.anomaly.severity); + type = FindingType_1.FindingType.fromString(request.anomaly.type); + } + catch (e) { + return (0, Result_1.Err)(e instanceof Error ? e.message : String(e)); + } + const evidence = Evidence_1.Evidence.create({ + screenshotPath: request.anomaly.evidence.screenshotPath, + domSnapshotPath: request.anomaly.evidence.domSnapshotPath, + httpLog: request.anomaly.evidence.httpLog, + rawErrors: request.anomaly.evidence.rawErrors, + }); + const finding = Finding_1.Finding.create({ + sessionId: request.sessionId, + severity, + type, + description: request.anomaly.description, + evidence, + actionTrace: request.anomaly.actionTrace, + browser: request.anomaly.browser, + browserVersion: request.anomaly.browserVersion, + aiEnrichment: request.anomaly.aiEnrichment, + }); + await this.repository.save(finding); + const events = finding.domainEvents; + for (const event of events) { + await this.eventBus.publish(event); + } + finding.clearEvents(); + return (0, Result_1.Ok)({ findingId: finding.id.toString() }); + } +} +exports.CreateFindingCommand = CreateFindingCommand; diff --git a/dist/modules/findings/application/commands/EnrichFindingCommand.js b/dist/modules/findings/application/commands/EnrichFindingCommand.js new file mode 100644 index 0000000..57f269a --- /dev/null +++ b/dist/modules/findings/application/commands/EnrichFindingCommand.js @@ -0,0 +1,33 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.EnrichFindingCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class EnrichFindingCommand { + constructor(repository, enricher, eventBus) { + this.repository = repository; + this.enricher = enricher; + this.eventBus = eventBus; + } + async execute(request) { + const finding = await this.repository.findById(request.findingId); + if (!finding) { + return (0, Result_1.Err)(`Finding not found: ${request.findingId}`); + } + let enrichment; + try { + enrichment = await this.enricher.enrich(finding); + } + catch (e) { + return (0, Result_1.Err)(`Enrichment failed: ${e instanceof Error ? e.message : String(e)}`); + } + finding.enrich(enrichment); + await this.repository.update(finding); + const events = finding.domainEvents; + for (const event of events) { + await this.eventBus.publish(event); + } + finding.clearEvents(); + return (0, Result_1.Ok)({ findingId: finding.id.toString() }); + } +} +exports.EnrichFindingCommand = EnrichFindingCommand; diff --git a/dist/modules/findings/application/commands/ResolveFindingCommand.js b/dist/modules/findings/application/commands/ResolveFindingCommand.js new file mode 100644 index 0000000..c9bbd33 --- /dev/null +++ b/dist/modules/findings/application/commands/ResolveFindingCommand.js @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ResolveFindingCommand = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class ResolveFindingCommand { + constructor(repository, eventBus) { + this.repository = repository; + this.eventBus = eventBus; + } + async execute(request) { + const finding = await this.repository.findById(request.findingId); + if (!finding) { + return (0, Result_1.Err)(`Finding not found: ${request.findingId}`); + } + switch (request.action) { + case 'resolve': + finding.resolve(); + break; + case 'close': + finding.close(); + break; + case 'investigate': + finding.investigate(); + break; + } + await this.repository.update(finding); + const events = finding.domainEvents; + for (const event of events) { + await this.eventBus.publish(event); + } + finding.clearEvents(); + return (0, Result_1.Ok)({ findingId: finding.id.toString(), status: finding.status.value }); + } +} +exports.ResolveFindingCommand = ResolveFindingCommand; diff --git a/dist/modules/findings/application/event-handlers/OnAnomalyDetected.js b/dist/modules/findings/application/event-handlers/OnAnomalyDetected.js new file mode 100644 index 0000000..d99f598 --- /dev/null +++ b/dist/modules/findings/application/event-handlers/OnAnomalyDetected.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.OnAnomalyDetected = void 0; +/** + * Listens for anomaly_detected events from crawling module + * and creates a Finding in the findings module. + */ +class OnAnomalyDetected { + constructor(createFinding) { + this.createFinding = createFinding; + } + async handle(event) { + const payload = event.payload; + if (!payload.anomaly || !payload.sessionId) + return; + await this.createFinding.execute({ + anomaly: payload.anomaly, + sessionId: payload.sessionId, + }); + } +} +exports.OnAnomalyDetected = OnAnomalyDetected; diff --git a/dist/modules/findings/application/queries/FindingStatsQuery.js b/dist/modules/findings/application/queries/FindingStatsQuery.js new file mode 100644 index 0000000..80cd24e --- /dev/null +++ b/dist/modules/findings/application/queries/FindingStatsQuery.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FindingStatsQuery = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class FindingStatsQuery { + constructor(repository) { + this.repository = repository; + } + async execute(request) { + const [total, bySeverity, openCount, resolvedCount] = await Promise.all([ + this.repository.count(request.sessionId ? { sessionId: request.sessionId } : undefined), + this.repository.countBySeverity(), + this.repository.count({ status: 'open', sessionId: request.sessionId }), + this.repository.count({ status: 'resolved', sessionId: request.sessionId }), + ]); + return (0, Result_1.Ok)({ total, bySeverity, openCount, resolvedCount }); + } +} +exports.FindingStatsQuery = FindingStatsQuery; diff --git a/dist/modules/findings/application/queries/GetFindingQuery.js b/dist/modules/findings/application/queries/GetFindingQuery.js new file mode 100644 index 0000000..8af7e2f --- /dev/null +++ b/dist/modules/findings/application/queries/GetFindingQuery.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.GetFindingQuery = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class GetFindingQuery { + constructor(repository) { + this.repository = repository; + } + async execute(request) { + const finding = await this.repository.findById(request.findingId); + if (!finding) { + return (0, Result_1.Err)(`Finding not found: ${request.findingId}`); + } + return (0, Result_1.Ok)(finding); + } +} +exports.GetFindingQuery = GetFindingQuery; diff --git a/dist/modules/findings/application/queries/ListFindingsQuery.js b/dist/modules/findings/application/queries/ListFindingsQuery.js new file mode 100644 index 0000000..b0fc437 --- /dev/null +++ b/dist/modules/findings/application/queries/ListFindingsQuery.js @@ -0,0 +1,22 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ListFindingsQuery = void 0; +const Result_1 = require("../../../../shared/domain/Result"); +class ListFindingsQuery { + constructor(repository) { + this.repository = repository; + } + async execute(request) { + const filters = { + sessionId: request.sessionId, + severity: request.severity, + type: request.type, + status: request.status, + search: request.search, + }; + const findings = await this.repository.findAll(filters); + const total = await this.repository.count(filters); + return (0, Result_1.Ok)({ findings, total }); + } +} +exports.ListFindingsQuery = ListFindingsQuery; diff --git a/dist/modules/findings/domain/entities/Finding.js b/dist/modules/findings/domain/entities/Finding.js new file mode 100644 index 0000000..4ebfd6f --- /dev/null +++ b/dist/modules/findings/domain/entities/Finding.js @@ -0,0 +1,64 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Finding = void 0; +const AggregateRoot_1 = require("../../../../shared/domain/AggregateRoot"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const FindingStatus_1 = require("../value-objects/FindingStatus"); +const FindingCreated_1 = require("../events/FindingCreated"); +const FindingResolved_1 = require("../events/FindingResolved"); +const FindingEnriched_1 = require("../events/FindingEnriched"); +class Finding extends AggregateRoot_1.AggregateRoot { + static create(props, id) { + const findingId = id ?? UniqueId_1.UniqueId.create(); + const finding = new Finding({ + ...props, + status: FindingStatus_1.FindingStatus.open(), + createdAt: new Date(), + }, findingId); + finding.addDomainEvent(new FindingCreated_1.FindingCreated(findingId.toString(), { + sessionId: props.sessionId, + severity: props.severity.value, + type: props.type.value, + description: props.description, + })); + return finding; + } + static reconstitute(props, id) { + return new Finding(props, id); + } + get sessionId() { return this.props.sessionId; } + get severity() { return this.props.severity; } + get type() { return this.props.type; } + get description() { return this.props.description; } + get evidence() { return this.props.evidence; } + get status() { return this.props.status; } + get actionTrace() { return this.props.actionTrace; } + get browser() { return this.props.browser; } + get browserVersion() { return this.props.browserVersion; } + get aiEnrichment() { return this.props.aiEnrichment; } + get createdAt() { return this.props.createdAt; } + get resolvedAt() { return this.props.resolvedAt; } + resolve() { + this.props.status = FindingStatus_1.FindingStatus.resolved(); + this.props.resolvedAt = new Date(); + this.addDomainEvent(new FindingResolved_1.FindingResolved(this.id.toString(), { + sessionId: this.props.sessionId, + resolvedAt: this.props.resolvedAt.toISOString(), + })); + } + close() { + this.props.status = FindingStatus_1.FindingStatus.closed(); + } + investigate() { + this.props.status = FindingStatus_1.FindingStatus.investigating(); + } + enrich(enrichment) { + this.props.aiEnrichment = enrichment; + this.addDomainEvent(new FindingEnriched_1.FindingEnriched(this.id.toString(), { + provider: enrichment.provider, + model: enrichment.model, + confidence: enrichment.confidence, + })); + } +} +exports.Finding = Finding; diff --git a/dist/modules/findings/domain/events/FindingCreated.js b/dist/modules/findings/domain/events/FindingCreated.js new file mode 100644 index 0000000..9c5a4ef --- /dev/null +++ b/dist/modules/findings/domain/events/FindingCreated.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FindingCreated = void 0; +const crypto_1 = require("crypto"); +class FindingCreated { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'finding.created'; + this.occurredOn = new Date(); + } +} +exports.FindingCreated = FindingCreated; diff --git a/dist/modules/findings/domain/events/FindingEnriched.js b/dist/modules/findings/domain/events/FindingEnriched.js new file mode 100644 index 0000000..caba557 --- /dev/null +++ b/dist/modules/findings/domain/events/FindingEnriched.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FindingEnriched = void 0; +const crypto_1 = require("crypto"); +class FindingEnriched { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'finding.enriched'; + this.occurredOn = new Date(); + } +} +exports.FindingEnriched = FindingEnriched; diff --git a/dist/modules/findings/domain/events/FindingResolved.js b/dist/modules/findings/domain/events/FindingResolved.js new file mode 100644 index 0000000..513f3cc --- /dev/null +++ b/dist/modules/findings/domain/events/FindingResolved.js @@ -0,0 +1,14 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FindingResolved = void 0; +const crypto_1 = require("crypto"); +class FindingResolved { + constructor(aggregateId, payload) { + this.aggregateId = aggregateId; + this.payload = payload; + this.eventId = (0, crypto_1.randomUUID)(); + this.eventName = 'finding.resolved'; + this.occurredOn = new Date(); + } +} +exports.FindingResolved = FindingResolved; diff --git a/dist/modules/findings/domain/ports/IAIEnricher.js b/dist/modules/findings/domain/ports/IAIEnricher.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/findings/domain/ports/IAIEnricher.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/findings/domain/ports/IFindingRepository.js b/dist/modules/findings/domain/ports/IFindingRepository.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/modules/findings/domain/ports/IFindingRepository.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/modules/findings/domain/value-objects/Evidence.js b/dist/modules/findings/domain/value-objects/Evidence.js new file mode 100644 index 0000000..c828682 --- /dev/null +++ b/dist/modules/findings/domain/value-objects/Evidence.js @@ -0,0 +1,25 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Evidence = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class Evidence extends ValueObject_1.ValueObject { + static create(props) { + return new Evidence(props); + } + static empty() { + return new Evidence({}); + } + get screenshotPath() { return this.props.screenshotPath; } + get domSnapshotPath() { return this.props.domSnapshotPath; } + get httpLog() { return this.props.httpLog ?? []; } + get rawErrors() { return this.props.rawErrors ?? []; } + toJSON() { + return { + screenshotPath: this.props.screenshotPath, + domSnapshotPath: this.props.domSnapshotPath, + httpLog: this.props.httpLog, + rawErrors: this.props.rawErrors, + }; + } +} +exports.Evidence = Evidence; diff --git a/dist/modules/findings/domain/value-objects/FindingStatus.js b/dist/modules/findings/domain/value-objects/FindingStatus.js new file mode 100644 index 0000000..7fe2624 --- /dev/null +++ b/dist/modules/findings/domain/value-objects/FindingStatus.js @@ -0,0 +1,21 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FindingStatus = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class FindingStatus extends ValueObject_1.ValueObject { + static open() { return new FindingStatus({ value: 'open' }); } + static investigating() { return new FindingStatus({ value: 'investigating' }); } + static resolved() { return new FindingStatus({ value: 'resolved' }); } + static closed() { return new FindingStatus({ value: 'closed' }); } + static fromString(s) { + if (!FindingStatus.VALUES.includes(s)) { + throw new Error(`Invalid finding status: ${s}`); + } + return new FindingStatus({ value: s }); + } + get value() { return this.props.value; } + isOpen() { return this.props.value === 'open'; } + isResolved() { return this.props.value === 'resolved'; } +} +exports.FindingStatus = FindingStatus; +FindingStatus.VALUES = ['open', 'investigating', 'resolved', 'closed']; diff --git a/dist/modules/findings/domain/value-objects/FindingType.js b/dist/modules/findings/domain/value-objects/FindingType.js new file mode 100644 index 0000000..81c232e --- /dev/null +++ b/dist/modules/findings/domain/value-objects/FindingType.js @@ -0,0 +1,32 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.FindingType = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class FindingType extends ValueObject_1.ValueObject { + static fromString(s) { + if (!FindingType.TYPES.includes(s)) { + throw new Error(`Invalid finding type: ${s}`); + } + return new FindingType({ value: s }); + } + get value() { return this.props.value; } +} +exports.FindingType = FindingType; +FindingType.TYPES = [ + 'http_error', + 'js_exception', + 'console_error', + 'navigation_fail', + 'element_missing', + 'timeout', + 'validation_bypass', + 'server_error_on_fuzz', + 'xss_reflection', + 'visual_regression', + 'accessibility_violation', + 'mobile_layout_issue', + 'performance_degradation', + 'offline_handling_missing', + 'slow_network_no_feedback', + 'external_service_crash', +]; diff --git a/dist/modules/findings/domain/value-objects/Severity.js b/dist/modules/findings/domain/value-objects/Severity.js new file mode 100644 index 0000000..7d5a0f4 --- /dev/null +++ b/dist/modules/findings/domain/value-objects/Severity.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Severity = void 0; +const ValueObject_1 = require("../../../../shared/domain/ValueObject"); +class Severity extends ValueObject_1.ValueObject { + static low() { return new Severity({ value: 'low' }); } + static medium() { return new Severity({ value: 'medium' }); } + static high() { return new Severity({ value: 'high' }); } + static critical() { return new Severity({ value: 'critical' }); } + static fromString(s) { + if (!Severity.LEVELS.includes(s)) { + throw new Error(`Invalid severity: ${s}`); + } + return new Severity({ value: s }); + } + get value() { return this.props.value; } +} +exports.Severity = Severity; +Severity.LEVELS = ['low', 'medium', 'high', 'critical']; diff --git a/dist/modules/findings/index.js b/dist/modules/findings/index.js new file mode 100644 index 0000000..b4e01f6 --- /dev/null +++ b/dist/modules/findings/index.js @@ -0,0 +1,47 @@ +"use strict"; +// Findings Module — Public API +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createFindingsRouter = exports.PlaywrightScriptExporter = exports.JSONExporter = exports.MarkdownExporter = exports.KyselyFindingRepository = exports.OnAnomalyDetected = exports.FindingStatsQuery = exports.ListFindingsQuery = exports.GetFindingQuery = exports.ResolveFindingCommand = exports.EnrichFindingCommand = exports.CreateFindingCommand = exports.FindingEnriched = exports.FindingResolved = exports.FindingCreated = exports.Evidence = exports.FindingStatus = exports.FindingType = exports.Severity = exports.Finding = void 0; +// Domain +var Finding_1 = require("./domain/entities/Finding"); +Object.defineProperty(exports, "Finding", { enumerable: true, get: function () { return Finding_1.Finding; } }); +var Severity_1 = require("./domain/value-objects/Severity"); +Object.defineProperty(exports, "Severity", { enumerable: true, get: function () { return Severity_1.Severity; } }); +var FindingType_1 = require("./domain/value-objects/FindingType"); +Object.defineProperty(exports, "FindingType", { enumerable: true, get: function () { return FindingType_1.FindingType; } }); +var FindingStatus_1 = require("./domain/value-objects/FindingStatus"); +Object.defineProperty(exports, "FindingStatus", { enumerable: true, get: function () { return FindingStatus_1.FindingStatus; } }); +var Evidence_1 = require("./domain/value-objects/Evidence"); +Object.defineProperty(exports, "Evidence", { enumerable: true, get: function () { return Evidence_1.Evidence; } }); +var FindingCreated_1 = require("./domain/events/FindingCreated"); +Object.defineProperty(exports, "FindingCreated", { enumerable: true, get: function () { return FindingCreated_1.FindingCreated; } }); +var FindingResolved_1 = require("./domain/events/FindingResolved"); +Object.defineProperty(exports, "FindingResolved", { enumerable: true, get: function () { return FindingResolved_1.FindingResolved; } }); +var FindingEnriched_1 = require("./domain/events/FindingEnriched"); +Object.defineProperty(exports, "FindingEnriched", { enumerable: true, get: function () { return FindingEnriched_1.FindingEnriched; } }); +// Application +var CreateFindingCommand_1 = require("./application/commands/CreateFindingCommand"); +Object.defineProperty(exports, "CreateFindingCommand", { enumerable: true, get: function () { return CreateFindingCommand_1.CreateFindingCommand; } }); +var EnrichFindingCommand_1 = require("./application/commands/EnrichFindingCommand"); +Object.defineProperty(exports, "EnrichFindingCommand", { enumerable: true, get: function () { return EnrichFindingCommand_1.EnrichFindingCommand; } }); +var ResolveFindingCommand_1 = require("./application/commands/ResolveFindingCommand"); +Object.defineProperty(exports, "ResolveFindingCommand", { enumerable: true, get: function () { return ResolveFindingCommand_1.ResolveFindingCommand; } }); +var GetFindingQuery_1 = require("./application/queries/GetFindingQuery"); +Object.defineProperty(exports, "GetFindingQuery", { enumerable: true, get: function () { return GetFindingQuery_1.GetFindingQuery; } }); +var ListFindingsQuery_1 = require("./application/queries/ListFindingsQuery"); +Object.defineProperty(exports, "ListFindingsQuery", { enumerable: true, get: function () { return ListFindingsQuery_1.ListFindingsQuery; } }); +var FindingStatsQuery_1 = require("./application/queries/FindingStatsQuery"); +Object.defineProperty(exports, "FindingStatsQuery", { enumerable: true, get: function () { return FindingStatsQuery_1.FindingStatsQuery; } }); +var OnAnomalyDetected_1 = require("./application/event-handlers/OnAnomalyDetected"); +Object.defineProperty(exports, "OnAnomalyDetected", { enumerable: true, get: function () { return OnAnomalyDetected_1.OnAnomalyDetected; } }); +// Infrastructure +var KyselyFindingRepository_1 = require("./infrastructure/repositories/KyselyFindingRepository"); +Object.defineProperty(exports, "KyselyFindingRepository", { enumerable: true, get: function () { return KyselyFindingRepository_1.KyselyFindingRepository; } }); +var MarkdownExporter_1 = require("./infrastructure/exporters/MarkdownExporter"); +Object.defineProperty(exports, "MarkdownExporter", { enumerable: true, get: function () { return MarkdownExporter_1.MarkdownExporter; } }); +var JSONExporter_1 = require("./infrastructure/exporters/JSONExporter"); +Object.defineProperty(exports, "JSONExporter", { enumerable: true, get: function () { return JSONExporter_1.JSONExporter; } }); +var PlaywrightScriptExporter_1 = require("./infrastructure/exporters/PlaywrightScriptExporter"); +Object.defineProperty(exports, "PlaywrightScriptExporter", { enumerable: true, get: function () { return PlaywrightScriptExporter_1.PlaywrightScriptExporter; } }); +var FindingsController_1 = require("./infrastructure/http/FindingsController"); +Object.defineProperty(exports, "createFindingsRouter", { enumerable: true, get: function () { return FindingsController_1.createFindingsRouter; } }); diff --git a/dist/modules/findings/infrastructure/exporters/JSONExporter.js b/dist/modules/findings/infrastructure/exporters/JSONExporter.js new file mode 100644 index 0000000..972db99 --- /dev/null +++ b/dist/modules/findings/infrastructure/exporters/JSONExporter.js @@ -0,0 +1,94 @@ +"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.JSONExporter = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const os = __importStar(require("os")); +class JSONExporter { + constructor(targetUrl = '', abeVersion = '0.1.0') { + this.targetUrl = targetUrl; + this.abeVersion = abeVersion; + } + async export(finding, outputDir) { + fs.mkdirSync(outputDir, { recursive: true }); + const report = { + version: '1.0', + generated_at: finding.createdAt.toISOString(), + environment: { + target_url: this.targetUrl, + abe_version: this.abeVersion, + os: os.platform(), + node_version: process.version, + }, + finding: { + id: finding.id.toString(), + type: finding.type.value, + severity: finding.severity.value, + status: finding.status.value, + description: finding.description, + browser: finding.browser, + browser_version: finding.browserVersion, + }, + reproduction: { + seed: finding.actionTrace[0]?.seed ?? null, + steps: finding.actionTrace.map((action, index) => ({ + step: index + 1, + action_type: action.type, + selector: action.selector, + value: action.value, + url: action.url, + timestamp: action.timestamp, + })), + }, + evidence: { + screenshot: finding.evidence.screenshotPath ?? null, + dom_snapshot: finding.evidence.domSnapshotPath ?? null, + http_log: finding.evidence.httpLog.map((r) => ({ + url: r.url, + method: r.method, + status: r.status, + duration_ms: r.durationMs, + })), + raw_errors: finding.evidence.rawErrors, + }, + ai_enrichment: finding.aiEnrichment ?? null, + }; + const filePath = path.join(outputDir, 'report.json'); + fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf8'); + return filePath; + } +} +exports.JSONExporter = JSONExporter; diff --git a/dist/modules/findings/infrastructure/exporters/MarkdownExporter.js b/dist/modules/findings/infrastructure/exporters/MarkdownExporter.js new file mode 100644 index 0000000..e709f74 --- /dev/null +++ b/dist/modules/findings/infrastructure/exporters/MarkdownExporter.js @@ -0,0 +1,109 @@ +"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.MarkdownExporter = void 0; +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +class MarkdownExporter { + async export(finding, outputDir) { + fs.mkdirSync(outputDir, { recursive: true }); + const date = finding.createdAt.toISOString().split('T')[0]; + const seed = finding.actionTrace[0]?.seed ?? 'N/A'; + const replayCmd = `npm run replay -- --report ${outputDir}/report.json`; + const steps = finding.actionTrace + .map((action, i) => { + switch (action.type) { + case 'navigate': + return `${i + 1}. Navigate to \`${action.url}\``; + case 'click': + return `${i + 1}. Click element \`${action.selector}\``; + case 'fill': + return `${i + 1}. Fill \`${action.selector}\` with \`${JSON.stringify(action.value ?? '')}\``; + case 'select': + return `${i + 1}. Select \`${action.value}\` in \`${action.selector}\``; + case 'submit': + return `${i + 1}. Submit form \`${action.selector}\``; + default: + return `${i + 1}. ${action.type}`; + } + }) + .join('\n'); + const httpTable = finding.evidence.httpLog.length > 0 + ? [ + '| Method | URL | Status | Duration |', + '|--------|-----|--------|----------|', + ...finding.evidence.httpLog.map((r) => `| ${r.method} | ${r.url} | ${r.status} | ${r.durationMs}ms |`), + ].join('\n') + : '_No HTTP log available._'; + const rawErrors = finding.evidence.rawErrors.length > 0 + ? '```\n' + finding.evidence.rawErrors.join('\n') + '\n```' + : '_No raw errors recorded._'; + const md = `# Bug Report — ${finding.type.value} — ${date} + +## Summary +${finding.description} + +## Severity +**${finding.severity.value}** — detected by ABE heuristic rule \`${finding.type.value}\` + +## Status +**${finding.status.value}** + +## Reproduction Steps + +${steps.length > 0 ? steps : '_No steps recorded._'} + +**Seed used**: \`${seed}\` +**Replay command**: \`${replayCmd}\` + +## Observed Behavior +${finding.description} + +## Evidence +- Screenshot: \`${finding.evidence.screenshotPath ?? 'N/A'}\` +- DOM Snapshot: \`${finding.evidence.domSnapshotPath ?? 'N/A'}\` +- HTTP Log: + +${httpTable} + +## Raw Errors +${rawErrors} +`; + const filePath = path.join(outputDir, 'report.md'); + fs.writeFileSync(filePath, md, 'utf8'); + return filePath; + } +} +exports.MarkdownExporter = MarkdownExporter; diff --git a/dist/modules/findings/infrastructure/exporters/PlaywrightScriptExporter.js b/dist/modules/findings/infrastructure/exporters/PlaywrightScriptExporter.js new file mode 100644 index 0000000..176cab1 --- /dev/null +++ b/dist/modules/findings/infrastructure/exporters/PlaywrightScriptExporter.js @@ -0,0 +1,48 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PlaywrightScriptExporter = void 0; +class PlaywrightScriptExporter { + generate(finding) { + const trace = finding.actionTrace; + const lines = [ + '// Auto-generated replay script by ABE (Autonomous Bug Explorer)', + `// Generated at: ${new Date().toISOString()}`, + `// Finding ID: ${finding.id.toString()}`, + `// Type: ${finding.type.value} | Severity: ${finding.severity.value}`, + `// Steps: ${trace.length}`, + '', + "const { chromium } = require('playwright');", + '', + '(async () => {', + ' const browser = await chromium.launch({ headless: true });', + ' const context = await browser.newContext();', + ' const page = await context.newPage();', + '', + ]; + for (let i = 0; i < trace.length; i++) { + const action = trace[i]; + lines.push(` // Step ${i + 1}: ${action.type} (seed=${action.seed})`); + switch (action.type) { + case 'navigate': + lines.push(` await page.goto(${JSON.stringify(action.url)});`); + break; + case 'click': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().click();`); + break; + case 'fill': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().fill(${JSON.stringify(action.value ?? '')});`); + break; + case 'select': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().selectOption(${JSON.stringify(action.value ?? '')});`); + break; + case 'submit': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().dispatchEvent('submit');`); + break; + } + lines.push(''); + } + lines.push(" console.log('Replay complete');", ' await browser.close();', '})();'); + return lines.join('\n'); + } +} +exports.PlaywrightScriptExporter = PlaywrightScriptExporter; diff --git a/dist/modules/findings/infrastructure/http/FindingsController.js b/dist/modules/findings/infrastructure/http/FindingsController.js new file mode 100644 index 0000000..44142e1 --- /dev/null +++ b/dist/modules/findings/infrastructure/http/FindingsController.js @@ -0,0 +1,131 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createFindingsRouter = createFindingsRouter; +const express_1 = require("express"); +const MarkdownExporter_1 = require("../exporters/MarkdownExporter"); +const JSONExporter_1 = require("../exporters/JSONExporter"); +const PlaywrightScriptExporter_1 = require("../exporters/PlaywrightScriptExporter"); +const path_1 = __importDefault(require("path")); +function createFindingsRouter(deps) { + const router = (0, express_1.Router)(); + const markdownExporter = new MarkdownExporter_1.MarkdownExporter(); + const jsonExporter = new JSONExporter_1.JSONExporter(); + const playwrightExporter = new PlaywrightScriptExporter_1.PlaywrightScriptExporter(); + // GET /api/findings — list findings with filters + router.get('/', async (req, res) => { + const { sessionId, severity, type, status, search } = req.query; + const result = await deps.listFindings.execute({ sessionId, severity, type, status, search }); + if (!result.ok) { + res.status(500).json({ error: result.error }); + return; + } + const { findings, total } = result.value; + res.json({ + findings: findings.map(f => toDTO(f)), + total, + }); + }); + // GET /api/findings/stats — aggregate stats + router.get('/stats', async (req, res) => { + const { sessionId } = req.query; + const result = await deps.findingStats.execute({ sessionId }); + if (!result.ok) { + res.status(500).json({ error: result.error }); + return; + } + res.json(result.value); + }); + // GET /api/findings/:id — finding detail + router.get('/:id', async (req, res) => { + const findingId = req.params['id']; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + res.json(toDTO(result.value)); + }); + // PATCH /api/findings/:id/status — update status + router.patch('/:id/status', async (req, res) => { + const findingId = req.params['id']; + const { action } = req.body; + if (!action || !['resolve', 'close', 'investigate'].includes(action)) { + res.status(400).json({ error: 'action must be one of: resolve, close, investigate' }); + return; + } + const result = await deps.resolveFinding.execute({ findingId, action }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + res.json(result.value); + }); + // POST /api/findings/:id/enrich — trigger AI enrichment + router.post('/:id/enrich', async (req, res) => { + const findingId = req.params['id']; + const result = await deps.enrichFinding.execute({ findingId }); + if (!result.ok) { + res.status(422).json({ error: result.error }); + return; + } + res.json(result.value); + }); + // GET /api/findings/:id/export/markdown — download as Markdown + router.get('/:id/export/markdown', async (req, res) => { + const findingId = req.params['id']; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + const outputDir = path_1.default.join(process.cwd(), 'reports', findingId); + const filePath = await markdownExporter.export(result.value, outputDir); + res.download(filePath, `finding-${findingId}.md`); + }); + // GET /api/findings/:id/export/json — download as JSON + router.get('/:id/export/json', async (req, res) => { + const findingId = req.params['id']; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + const outputDir = path_1.default.join(process.cwd(), 'reports', findingId); + const filePath = await jsonExporter.export(result.value, outputDir); + res.download(filePath, `finding-${findingId}.json`); + }); + // GET /api/findings/:id/export/playwright — download Playwright script + router.get('/:id/export/playwright', async (req, res) => { + const findingId = req.params['id']; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + const script = playwrightExporter.generate(result.value); + res.setHeader('Content-Type', 'text/javascript'); + res.setHeader('Content-Disposition', `attachment; filename="finding-${findingId}.spec.js"`); + res.send(script); + }); + return router; +} +function toDTO(f) { + return { + 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, + browserVersion: f.browserVersion, + actionTraceLength: f.actionTrace.length, + evidence: f.evidence.toJSON(), + aiEnrichment: f.aiEnrichment ?? null, + createdAt: f.createdAt.toISOString(), + resolvedAt: f.resolvedAt?.toISOString() ?? null, + }; +} diff --git a/dist/modules/findings/infrastructure/repositories/KyselyFindingRepository.js b/dist/modules/findings/infrastructure/repositories/KyselyFindingRepository.js new file mode 100644 index 0000000..a481064 --- /dev/null +++ b/dist/modules/findings/infrastructure/repositories/KyselyFindingRepository.js @@ -0,0 +1,138 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.KyselyFindingRepository = void 0; +const Finding_1 = require("../../domain/entities/Finding"); +const UniqueId_1 = require("../../../../shared/domain/UniqueId"); +const Severity_1 = require("../../domain/value-objects/Severity"); +const FindingType_1 = require("../../domain/value-objects/FindingType"); +const FindingStatus_1 = require("../../domain/value-objects/FindingStatus"); +const Evidence_1 = require("../../domain/value-objects/Evidence"); +class KyselyFindingRepository { + constructor(db) { + this.db = db; + } + async save(finding) { + const row = { + id: finding.id.toString(), + session_id: finding.sessionId, + type: finding.type.value, + severity: finding.severity.value, + description: finding.description, + status: finding.status.value, + action_trace_json: JSON.stringify(finding.actionTrace), + evidence_json: JSON.stringify(finding.evidence.toJSON()), + screenshot_path: finding.evidence.screenshotPath ?? null, + dom_snapshot_path: finding.evidence.domSnapshotPath ?? null, + browser: finding.browser ?? null, + browser_version: finding.browserVersion ?? null, + ai_enrichment_json: finding.aiEnrichment ? JSON.stringify(finding.aiEnrichment) : null, + created_at: finding.createdAt.getTime(), + resolved_at: finding.resolvedAt ? finding.resolvedAt.getTime() : null, + }; + await this.db.insertInto('findings').values(row).execute(); + } + async findById(id) { + const row = await this.db + .selectFrom('findings') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? this.toDomain(row) : undefined; + } + async findAll(filters) { + let query = this.db.selectFrom('findings').selectAll(); + if (filters?.sessionId) { + query = query.where('session_id', '=', filters.sessionId); + } + if (filters?.severity) { + query = query.where('severity', '=', filters.severity); + } + if (filters?.type) { + query = query.where('type', '=', filters.type); + } + if (filters?.status) { + query = query.where('status', '=', filters.status); + } + if (filters?.search) { + query = query.where('description', 'like', `%${filters.search}%`); + } + const rows = await query.orderBy('created_at', 'desc').execute(); + return rows.map((row) => this.toDomain(row)); + } + async update(finding) { + await this.db + .updateTable('findings') + .set({ + status: finding.status.value, + ai_enrichment_json: finding.aiEnrichment ? JSON.stringify(finding.aiEnrichment) : null, + resolved_at: finding.resolvedAt ? finding.resolvedAt.getTime() : null, + }) + .where('id', '=', finding.id.toString()) + .execute(); + } + async count(filters) { + let query = this.db.selectFrom('findings').select(eb => eb.fn.countAll().as('cnt')); + if (filters?.sessionId) { + query = query.where('session_id', '=', filters.sessionId); + } + if (filters?.severity) { + query = query.where('severity', '=', filters.severity); + } + if (filters?.type) { + query = query.where('type', '=', filters.type); + } + if (filters?.status) { + query = query.where('status', '=', filters.status); + } + const result = await query.executeTakeFirst(); + return Number(result?.cnt ?? 0); + } + async countBySeverity() { + const rows = await this.db + .selectFrom('findings') + .select(['severity', eb => eb.fn.countAll().as('cnt')]) + .groupBy('severity') + .execute(); + const result = {}; + for (const row of rows) { + result[row.severity] = Number(row.cnt); + } + return result; + } + toDomain(row) { + const actionTrace = this.parseJson(row.action_trace_json, []); + const evidenceData = this.parseJson(row.evidence_json, {}); + const aiEnrichment = row.ai_enrichment_json + ? this.parseJson(row.ai_enrichment_json, undefined) + : undefined; + const props = { + sessionId: row.session_id, + severity: Severity_1.Severity.fromString(row.severity), + type: FindingType_1.FindingType.fromString(row.type), + description: row.description, + evidence: Evidence_1.Evidence.create({ + screenshotPath: row.screenshot_path ?? undefined, + domSnapshotPath: row.dom_snapshot_path ?? undefined, + httpLog: evidenceData.httpLog ?? [], + rawErrors: evidenceData.rawErrors ?? [], + }), + status: FindingStatus_1.FindingStatus.fromString(row.status), + actionTrace, + browser: row.browser ?? undefined, + browserVersion: row.browser_version ?? undefined, + aiEnrichment, + createdAt: new Date(row.created_at), + resolvedAt: row.resolved_at ? new Date(row.resolved_at) : undefined, + }; + return Finding_1.Finding.reconstitute(props, UniqueId_1.UniqueId.from(row.id)); + } + parseJson(json, fallback) { + try { + return JSON.parse(json); + } + catch { + return fallback; + } + } +} +exports.KyselyFindingRepository = KyselyFindingRepository; diff --git a/src/db/migrations/002_findings_table.ts b/src/db/migrations/002_findings_table.ts new file mode 100644 index 0000000..81032cf --- /dev/null +++ b/src/db/migrations/002_findings_table.ts @@ -0,0 +1,28 @@ +import { Kysely } from 'kysely'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function up(db: Kysely): Promise { + await db.schema.createTable('findings') + .ifNotExists() + .addColumn('id', 'text', col => col.primaryKey()) + .addColumn('session_id', 'text', col => col.notNull().references('sessions.id')) + .addColumn('type', 'text', col => col.notNull()) + .addColumn('severity', 'text', col => col.notNull()) + .addColumn('description', 'text', col => col.notNull()) + .addColumn('status', 'text', col => col.notNull().defaultTo('open')) + .addColumn('action_trace_json', 'text', col => col.notNull()) + .addColumn('evidence_json', 'text', col => col.notNull()) + .addColumn('screenshot_path', 'text') + .addColumn('dom_snapshot_path', 'text') + .addColumn('browser', 'text') + .addColumn('browser_version', 'text') + .addColumn('ai_enrichment_json', 'text') + .addColumn('created_at', 'integer', col => col.notNull()) + .addColumn('resolved_at', 'integer') + .execute(); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function down(db: Kysely): Promise { + await db.schema.dropTable('findings').ifExists().execute(); +} diff --git a/src/modules/findings/application/commands/CreateFindingCommand.ts b/src/modules/findings/application/commands/CreateFindingCommand.ts new file mode 100644 index 0000000..4ecf663 --- /dev/null +++ b/src/modules/findings/application/commands/CreateFindingCommand.ts @@ -0,0 +1,66 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { Finding } from '../../domain/entities/Finding'; +import { Severity } from '../../domain/value-objects/Severity'; +import { FindingType } from '../../domain/value-objects/FindingType'; +import { Evidence } from '../../domain/value-objects/Evidence'; +import { IFindingRepository } from '../../domain/ports/IFindingRepository'; +import { IAnomaly } from '../../../../core/interfaces'; + +interface CreateFindingRequest { + anomaly: IAnomaly; + sessionId: string; +} + +interface CreateFindingResponse { + findingId: string; +} + +export class CreateFindingCommand implements UseCase { + constructor( + private readonly repository: IFindingRepository, + private readonly eventBus: EventBus + ) {} + + async execute(request: CreateFindingRequest): Promise> { + let severity: Severity; + let type: FindingType; + + try { + severity = Severity.fromString(request.anomaly.severity); + type = FindingType.fromString(request.anomaly.type); + } catch (e) { + return Err(e instanceof Error ? e.message : String(e)); + } + + const evidence = Evidence.create({ + screenshotPath: request.anomaly.evidence.screenshotPath, + domSnapshotPath: request.anomaly.evidence.domSnapshotPath, + httpLog: request.anomaly.evidence.httpLog, + rawErrors: request.anomaly.evidence.rawErrors, + }); + + const finding = Finding.create({ + sessionId: request.sessionId, + severity, + type, + description: request.anomaly.description, + evidence, + actionTrace: request.anomaly.actionTrace, + browser: request.anomaly.browser, + browserVersion: request.anomaly.browserVersion, + aiEnrichment: request.anomaly.aiEnrichment, + }); + + await this.repository.save(finding); + + const events = finding.domainEvents; + for (const event of events) { + await this.eventBus.publish(event); + } + finding.clearEvents(); + + return Ok({ findingId: finding.id.toString() }); + } +} diff --git a/src/modules/findings/application/commands/EnrichFindingCommand.ts b/src/modules/findings/application/commands/EnrichFindingCommand.ts new file mode 100644 index 0000000..abe01fd --- /dev/null +++ b/src/modules/findings/application/commands/EnrichFindingCommand.ts @@ -0,0 +1,46 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { IFindingRepository } from '../../domain/ports/IFindingRepository'; +import { IAIEnricher } from '../../domain/ports/IAIEnricher'; + +interface EnrichFindingRequest { + findingId: string; +} + +interface EnrichFindingResponse { + findingId: string; +} + +export class EnrichFindingCommand implements UseCase { + constructor( + private readonly repository: IFindingRepository, + private readonly enricher: IAIEnricher, + private readonly eventBus: EventBus + ) {} + + async execute(request: EnrichFindingRequest): Promise> { + const finding = await this.repository.findById(request.findingId); + if (!finding) { + return Err(`Finding not found: ${request.findingId}`); + } + + let enrichment; + try { + enrichment = await this.enricher.enrich(finding); + } catch (e) { + return Err(`Enrichment failed: ${e instanceof Error ? e.message : String(e)}`); + } + + finding.enrich(enrichment); + await this.repository.update(finding); + + const events = finding.domainEvents; + for (const event of events) { + await this.eventBus.publish(event); + } + finding.clearEvents(); + + return Ok({ findingId: finding.id.toString() }); + } +} diff --git a/src/modules/findings/application/commands/ResolveFindingCommand.ts b/src/modules/findings/application/commands/ResolveFindingCommand.ts new file mode 100644 index 0000000..4b196c2 --- /dev/null +++ b/src/modules/findings/application/commands/ResolveFindingCommand.ts @@ -0,0 +1,50 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { EventBus } from '../../../../shared/application/EventBus'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { IFindingRepository } from '../../domain/ports/IFindingRepository'; + +interface ResolveFindingRequest { + findingId: string; + action: 'resolve' | 'close' | 'investigate'; +} + +interface ResolveFindingResponse { + findingId: string; + status: string; +} + +export class ResolveFindingCommand implements UseCase { + constructor( + private readonly repository: IFindingRepository, + private readonly eventBus: EventBus + ) {} + + async execute(request: ResolveFindingRequest): Promise> { + const finding = await this.repository.findById(request.findingId); + if (!finding) { + return Err(`Finding not found: ${request.findingId}`); + } + + switch (request.action) { + case 'resolve': + finding.resolve(); + break; + case 'close': + finding.close(); + break; + case 'investigate': + finding.investigate(); + break; + } + + await this.repository.update(finding); + + const events = finding.domainEvents; + for (const event of events) { + await this.eventBus.publish(event); + } + finding.clearEvents(); + + return Ok({ findingId: finding.id.toString(), status: finding.status.value }); + } +} diff --git a/src/modules/findings/application/event-handlers/OnAnomalyDetected.ts b/src/modules/findings/application/event-handlers/OnAnomalyDetected.ts new file mode 100644 index 0000000..325f58b --- /dev/null +++ b/src/modules/findings/application/event-handlers/OnAnomalyDetected.ts @@ -0,0 +1,22 @@ +import { EventHandler } from '../../../../shared/application/EventHandler'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; +import { CreateFindingCommand } from '../commands/CreateFindingCommand'; +import { IAnomaly } from '../../../../core/interfaces'; + +/** + * Listens for anomaly_detected events from crawling module + * and creates a Finding in the findings module. + */ +export class OnAnomalyDetected implements EventHandler { + constructor(private readonly createFinding: CreateFindingCommand) {} + + async handle(event: DomainEvent): Promise { + const payload = event.payload as { anomaly: IAnomaly; sessionId: string }; + if (!payload.anomaly || !payload.sessionId) return; + + await this.createFinding.execute({ + anomaly: payload.anomaly, + sessionId: payload.sessionId, + }); + } +} diff --git a/src/modules/findings/application/queries/FindingStatsQuery.ts b/src/modules/findings/application/queries/FindingStatsQuery.ts new file mode 100644 index 0000000..d7a0c19 --- /dev/null +++ b/src/modules/findings/application/queries/FindingStatsQuery.ts @@ -0,0 +1,29 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok } from '../../../../shared/domain/Result'; +import { IFindingRepository } from '../../domain/ports/IFindingRepository'; + +interface FindingStatsRequest { + sessionId?: string; +} + +interface FindingStatsResponse { + total: number; + bySeverity: Record; + openCount: number; + resolvedCount: number; +} + +export class FindingStatsQuery implements UseCase { + constructor(private readonly repository: IFindingRepository) {} + + async execute(request: FindingStatsRequest): Promise> { + const [total, bySeverity, openCount, resolvedCount] = await Promise.all([ + this.repository.count(request.sessionId ? { sessionId: request.sessionId } : undefined), + this.repository.countBySeverity(), + this.repository.count({ status: 'open', sessionId: request.sessionId }), + this.repository.count({ status: 'resolved', sessionId: request.sessionId }), + ]); + + return Ok({ total, bySeverity, openCount, resolvedCount }); + } +} diff --git a/src/modules/findings/application/queries/GetFindingQuery.ts b/src/modules/findings/application/queries/GetFindingQuery.ts new file mode 100644 index 0000000..0713277 --- /dev/null +++ b/src/modules/findings/application/queries/GetFindingQuery.ts @@ -0,0 +1,20 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok, Err } from '../../../../shared/domain/Result'; +import { IFindingRepository } from '../../domain/ports/IFindingRepository'; +import { Finding } from '../../domain/entities/Finding'; + +interface GetFindingRequest { + findingId: string; +} + +export class GetFindingQuery implements UseCase { + constructor(private readonly repository: IFindingRepository) {} + + async execute(request: GetFindingRequest): Promise> { + const finding = await this.repository.findById(request.findingId); + if (!finding) { + return Err(`Finding not found: ${request.findingId}`); + } + return Ok(finding); + } +} diff --git a/src/modules/findings/application/queries/ListFindingsQuery.ts b/src/modules/findings/application/queries/ListFindingsQuery.ts new file mode 100644 index 0000000..7c4225e --- /dev/null +++ b/src/modules/findings/application/queries/ListFindingsQuery.ts @@ -0,0 +1,36 @@ +import { UseCase } from '../../../../shared/application/UseCase'; +import { Result, Ok } from '../../../../shared/domain/Result'; +import { IFindingRepository, FindingFilters } from '../../domain/ports/IFindingRepository'; +import { Finding } from '../../domain/entities/Finding'; + +interface ListFindingsRequest { + sessionId?: string; + severity?: string; + type?: string; + status?: string; + search?: string; +} + +interface ListFindingsResponse { + findings: Finding[]; + total: number; +} + +export class ListFindingsQuery implements UseCase { + constructor(private readonly repository: IFindingRepository) {} + + async execute(request: ListFindingsRequest): Promise> { + const filters: FindingFilters = { + sessionId: request.sessionId, + severity: request.severity, + type: request.type, + status: request.status, + search: request.search, + }; + + const findings = await this.repository.findAll(filters); + const total = await this.repository.count(filters); + + return Ok({ findings, total }); + } +} diff --git a/src/modules/findings/domain/entities/Finding.ts b/src/modules/findings/domain/entities/Finding.ts new file mode 100644 index 0000000..4d566a8 --- /dev/null +++ b/src/modules/findings/domain/entities/Finding.ts @@ -0,0 +1,96 @@ +import { AggregateRoot } from '../../../../shared/domain/AggregateRoot'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { Severity } from '../value-objects/Severity'; +import { FindingType } from '../value-objects/FindingType'; +import { FindingStatus } from '../value-objects/FindingStatus'; +import { Evidence } from '../value-objects/Evidence'; +import { FindingCreated } from '../events/FindingCreated'; +import { FindingResolved } from '../events/FindingResolved'; +import { FindingEnriched } from '../events/FindingEnriched'; +import { IAction } from '../../../../core/interfaces'; +import { IAIEnrichment } from '../../../../core/interfaces'; + +export interface FindingProps { + sessionId: string; + severity: Severity; + type: FindingType; + description: string; + evidence: Evidence; + status: FindingStatus; + actionTrace: IAction[]; + browser?: 'chromium' | 'firefox' | 'webkit'; + browserVersion?: string; + aiEnrichment?: IAIEnrichment; + createdAt: Date; + resolvedAt?: Date; +} + +export class Finding extends AggregateRoot { + static create(props: Omit, id?: UniqueId): Finding { + const findingId = id ?? UniqueId.create(); + const finding = new Finding( + { + ...props, + status: FindingStatus.open(), + createdAt: new Date(), + }, + findingId + ); + finding.addDomainEvent( + new FindingCreated(findingId.toString(), { + sessionId: props.sessionId, + severity: props.severity.value, + type: props.type.value, + description: props.description, + }) + ); + return finding; + } + + static reconstitute(props: FindingProps, id: UniqueId): Finding { + return new Finding(props, id); + } + + get sessionId(): string { return this.props.sessionId; } + get severity(): Severity { return this.props.severity; } + get type(): FindingType { return this.props.type; } + get description(): string { return this.props.description; } + get evidence(): Evidence { return this.props.evidence; } + get status(): FindingStatus { return this.props.status; } + get actionTrace(): IAction[] { return this.props.actionTrace; } + get browser(): string | undefined { return this.props.browser; } + get browserVersion(): string | undefined { return this.props.browserVersion; } + get aiEnrichment(): IAIEnrichment | undefined { return this.props.aiEnrichment; } + get createdAt(): Date { return this.props.createdAt; } + get resolvedAt(): Date | undefined { return this.props.resolvedAt; } + + resolve(): void { + this.props.status = FindingStatus.resolved(); + this.props.resolvedAt = new Date(); + this.addDomainEvent( + new FindingResolved(this.id.toString(), { + sessionId: this.props.sessionId, + resolvedAt: this.props.resolvedAt.toISOString(), + }) + ); + } + + close(): void { + this.props.status = FindingStatus.closed(); + } + + investigate(): void { + this.props.status = FindingStatus.investigating(); + } + + enrich(enrichment: IAIEnrichment): void { + this.props.aiEnrichment = enrichment; + this.addDomainEvent( + new FindingEnriched(this.id.toString(), { + provider: enrichment.provider, + model: enrichment.model, + confidence: enrichment.confidence, + }) + ); + } +} diff --git a/src/modules/findings/domain/events/FindingCreated.ts b/src/modules/findings/domain/events/FindingCreated.ts new file mode 100644 index 0000000..1042505 --- /dev/null +++ b/src/modules/findings/domain/events/FindingCreated.ts @@ -0,0 +1,13 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class FindingCreated implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'finding.created'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: Record + ) {} +} diff --git a/src/modules/findings/domain/events/FindingEnriched.ts b/src/modules/findings/domain/events/FindingEnriched.ts new file mode 100644 index 0000000..166d82b --- /dev/null +++ b/src/modules/findings/domain/events/FindingEnriched.ts @@ -0,0 +1,13 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class FindingEnriched implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'finding.enriched'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: Record + ) {} +} diff --git a/src/modules/findings/domain/events/FindingResolved.ts b/src/modules/findings/domain/events/FindingResolved.ts new file mode 100644 index 0000000..04878a4 --- /dev/null +++ b/src/modules/findings/domain/events/FindingResolved.ts @@ -0,0 +1,13 @@ +import { randomUUID } from 'crypto'; +import { DomainEvent } from '../../../../shared/domain/DomainEvent'; + +export class FindingResolved implements DomainEvent { + readonly eventId = randomUUID(); + readonly eventName = 'finding.resolved'; + readonly occurredOn = new Date(); + + constructor( + readonly aggregateId: string, + readonly payload: Record + ) {} +} diff --git a/src/modules/findings/domain/ports/IAIEnricher.ts b/src/modules/findings/domain/ports/IAIEnricher.ts new file mode 100644 index 0000000..cec373c --- /dev/null +++ b/src/modules/findings/domain/ports/IAIEnricher.ts @@ -0,0 +1,6 @@ +import { Finding } from '../entities/Finding'; +import { IAIEnrichment } from '../../../../core/interfaces'; + +export interface IAIEnricher { + enrich(finding: Finding): Promise; +} diff --git a/src/modules/findings/domain/ports/IFindingRepository.ts b/src/modules/findings/domain/ports/IFindingRepository.ts new file mode 100644 index 0000000..c60cdc3 --- /dev/null +++ b/src/modules/findings/domain/ports/IFindingRepository.ts @@ -0,0 +1,18 @@ +import { Finding } from '../entities/Finding'; + +export interface FindingFilters { + sessionId?: string; + severity?: string; + type?: string; + status?: string; + search?: string; +} + +export interface IFindingRepository { + save(finding: Finding): Promise; + findById(id: string): Promise; + findAll(filters?: FindingFilters): Promise; + update(finding: Finding): Promise; + count(filters?: FindingFilters): Promise; + countBySeverity(): Promise>; +} diff --git a/src/modules/findings/domain/value-objects/Evidence.ts b/src/modules/findings/domain/value-objects/Evidence.ts new file mode 100644 index 0000000..60e3bac --- /dev/null +++ b/src/modules/findings/domain/value-objects/Evidence.ts @@ -0,0 +1,33 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { IHttpResponse } from '../../../../core/interfaces'; + +interface EvidenceProps { + screenshotPath?: string; + domSnapshotPath?: string; + httpLog?: IHttpResponse[]; + rawErrors?: string[]; +} + +export class Evidence extends ValueObject { + static create(props: EvidenceProps): Evidence { + return new Evidence(props); + } + + static empty(): Evidence { + return new Evidence({}); + } + + get screenshotPath(): string | undefined { return this.props.screenshotPath; } + get domSnapshotPath(): string | undefined { return this.props.domSnapshotPath; } + get httpLog(): IHttpResponse[] { return this.props.httpLog ?? []; } + get rawErrors(): string[] { return this.props.rawErrors ?? []; } + + toJSON(): EvidenceProps { + return { + screenshotPath: this.props.screenshotPath, + domSnapshotPath: this.props.domSnapshotPath, + httpLog: this.props.httpLog, + rawErrors: this.props.rawErrors, + }; + } +} diff --git a/src/modules/findings/domain/value-objects/FindingStatus.ts b/src/modules/findings/domain/value-objects/FindingStatus.ts new file mode 100644 index 0000000..05d1b5c --- /dev/null +++ b/src/modules/findings/domain/value-objects/FindingStatus.ts @@ -0,0 +1,28 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +type StatusValue = 'open' | 'investigating' | 'resolved' | 'closed'; + +interface FindingStatusProps { + value: StatusValue; +} + +export class FindingStatus extends ValueObject { + static readonly VALUES: StatusValue[] = ['open', 'investigating', 'resolved', 'closed']; + + static open(): FindingStatus { return new FindingStatus({ value: 'open' }); } + static investigating(): FindingStatus { return new FindingStatus({ value: 'investigating' }); } + static resolved(): FindingStatus { return new FindingStatus({ value: 'resolved' }); } + static closed(): FindingStatus { return new FindingStatus({ value: 'closed' }); } + + static fromString(s: string): FindingStatus { + if (!FindingStatus.VALUES.includes(s as StatusValue)) { + throw new Error(`Invalid finding status: ${s}`); + } + return new FindingStatus({ value: s as StatusValue }); + } + + get value(): StatusValue { return this.props.value; } + + isOpen(): boolean { return this.props.value === 'open'; } + isResolved(): boolean { return this.props.value === 'resolved'; } +} diff --git a/src/modules/findings/domain/value-objects/FindingType.ts b/src/modules/findings/domain/value-objects/FindingType.ts new file mode 100644 index 0000000..9fcd81b --- /dev/null +++ b/src/modules/findings/domain/value-objects/FindingType.ts @@ -0,0 +1,36 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { AnomalyType } from '../../../../core/interfaces'; + +interface FindingTypeProps { + value: AnomalyType; +} + +export class FindingType extends ValueObject { + static readonly TYPES: AnomalyType[] = [ + 'http_error', + 'js_exception', + 'console_error', + 'navigation_fail', + 'element_missing', + 'timeout', + 'validation_bypass', + 'server_error_on_fuzz', + 'xss_reflection', + 'visual_regression', + 'accessibility_violation', + 'mobile_layout_issue', + 'performance_degradation', + 'offline_handling_missing', + 'slow_network_no_feedback', + 'external_service_crash', + ]; + + static fromString(s: string): FindingType { + if (!FindingType.TYPES.includes(s as AnomalyType)) { + throw new Error(`Invalid finding type: ${s}`); + } + return new FindingType({ value: s as AnomalyType }); + } + + get value(): AnomalyType { return this.props.value; } +} diff --git a/src/modules/findings/domain/value-objects/Severity.ts b/src/modules/findings/domain/value-objects/Severity.ts new file mode 100644 index 0000000..7dd390f --- /dev/null +++ b/src/modules/findings/domain/value-objects/Severity.ts @@ -0,0 +1,25 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; + +type SeverityLevel = 'low' | 'medium' | 'high' | 'critical'; + +interface SeverityProps { + value: SeverityLevel; +} + +export class Severity extends ValueObject { + static readonly LEVELS: SeverityLevel[] = ['low', 'medium', 'high', 'critical']; + + static low(): Severity { return new Severity({ value: 'low' }); } + static medium(): Severity { return new Severity({ value: 'medium' }); } + static high(): Severity { return new Severity({ value: 'high' }); } + static critical(): Severity { return new Severity({ value: 'critical' }); } + + static fromString(s: string): Severity { + if (!Severity.LEVELS.includes(s as SeverityLevel)) { + throw new Error(`Invalid severity: ${s}`); + } + return new Severity({ value: s as SeverityLevel }); + } + + get value(): SeverityLevel { return this.props.value; } +} diff --git a/src/modules/findings/index.ts b/src/modules/findings/index.ts new file mode 100644 index 0000000..1b1f794 --- /dev/null +++ b/src/modules/findings/index.ts @@ -0,0 +1,31 @@ +// Findings Module — Public API + +// Domain +export { Finding } from './domain/entities/Finding'; +export type { FindingProps } from './domain/entities/Finding'; +export { Severity } from './domain/value-objects/Severity'; +export { FindingType } from './domain/value-objects/FindingType'; +export { FindingStatus } from './domain/value-objects/FindingStatus'; +export { Evidence } from './domain/value-objects/Evidence'; +export { FindingCreated } from './domain/events/FindingCreated'; +export { FindingResolved } from './domain/events/FindingResolved'; +export { FindingEnriched } from './domain/events/FindingEnriched'; +export type { IFindingRepository, FindingFilters } from './domain/ports/IFindingRepository'; +export type { IAIEnricher } from './domain/ports/IAIEnricher'; + +// Application +export { CreateFindingCommand } from './application/commands/CreateFindingCommand'; +export { EnrichFindingCommand } from './application/commands/EnrichFindingCommand'; +export { ResolveFindingCommand } from './application/commands/ResolveFindingCommand'; +export { GetFindingQuery } from './application/queries/GetFindingQuery'; +export { ListFindingsQuery } from './application/queries/ListFindingsQuery'; +export { FindingStatsQuery } from './application/queries/FindingStatsQuery'; +export { OnAnomalyDetected } from './application/event-handlers/OnAnomalyDetected'; + +// Infrastructure +export { KyselyFindingRepository } from './infrastructure/repositories/KyselyFindingRepository'; +export { MarkdownExporter } from './infrastructure/exporters/MarkdownExporter'; +export { JSONExporter } from './infrastructure/exporters/JSONExporter'; +export { PlaywrightScriptExporter } from './infrastructure/exporters/PlaywrightScriptExporter'; +export { createFindingsRouter } from './infrastructure/http/FindingsController'; +export type { FindingsControllerDeps } from './infrastructure/http/FindingsController'; diff --git a/src/modules/findings/infrastructure/exporters/JSONExporter.ts b/src/modules/findings/infrastructure/exporters/JSONExporter.ts new file mode 100644 index 0000000..3278c2d --- /dev/null +++ b/src/modules/findings/infrastructure/exporters/JSONExporter.ts @@ -0,0 +1,62 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { Finding } from '../../domain/entities/Finding'; + +export class JSONExporter { + constructor( + private readonly targetUrl: string = '', + private readonly abeVersion: string = '0.1.0' + ) {} + + async export(finding: Finding, outputDir: string): Promise { + fs.mkdirSync(outputDir, { recursive: true }); + + const report = { + version: '1.0', + generated_at: finding.createdAt.toISOString(), + environment: { + target_url: this.targetUrl, + abe_version: this.abeVersion, + os: os.platform(), + node_version: process.version, + }, + finding: { + id: finding.id.toString(), + type: finding.type.value, + severity: finding.severity.value, + status: finding.status.value, + description: finding.description, + browser: finding.browser, + browser_version: finding.browserVersion, + }, + reproduction: { + seed: finding.actionTrace[0]?.seed ?? null, + steps: finding.actionTrace.map((action, index) => ({ + step: index + 1, + action_type: action.type, + selector: action.selector, + value: action.value, + url: action.url, + timestamp: action.timestamp, + })), + }, + evidence: { + screenshot: finding.evidence.screenshotPath ?? null, + dom_snapshot: finding.evidence.domSnapshotPath ?? null, + http_log: finding.evidence.httpLog.map((r) => ({ + url: r.url, + method: r.method, + status: r.status, + duration_ms: r.durationMs, + })), + raw_errors: finding.evidence.rawErrors, + }, + ai_enrichment: finding.aiEnrichment ?? null, + }; + + const filePath = path.join(outputDir, 'report.json'); + fs.writeFileSync(filePath, JSON.stringify(report, null, 2), 'utf8'); + return filePath; + } +} diff --git a/src/modules/findings/infrastructure/exporters/MarkdownExporter.ts b/src/modules/findings/infrastructure/exporters/MarkdownExporter.ts new file mode 100644 index 0000000..3dc8117 --- /dev/null +++ b/src/modules/findings/infrastructure/exporters/MarkdownExporter.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Finding } from '../../domain/entities/Finding'; + +export class MarkdownExporter { + async export(finding: Finding, outputDir: string): Promise { + fs.mkdirSync(outputDir, { recursive: true }); + + const date = finding.createdAt.toISOString().split('T')[0]; + const seed = finding.actionTrace[0]?.seed ?? 'N/A'; + const replayCmd = `npm run replay -- --report ${outputDir}/report.json`; + + const steps = finding.actionTrace + .map((action, i) => { + switch (action.type) { + case 'navigate': + return `${i + 1}. Navigate to \`${action.url}\``; + case 'click': + return `${i + 1}. Click element \`${action.selector}\``; + case 'fill': + return `${i + 1}. Fill \`${action.selector}\` with \`${JSON.stringify(action.value ?? '')}\``; + case 'select': + return `${i + 1}. Select \`${action.value}\` in \`${action.selector}\``; + case 'submit': + return `${i + 1}. Submit form \`${action.selector}\``; + default: + return `${i + 1}. ${action.type}`; + } + }) + .join('\n'); + + const httpTable = finding.evidence.httpLog.length > 0 + ? [ + '| Method | URL | Status | Duration |', + '|--------|-----|--------|----------|', + ...finding.evidence.httpLog.map( + (r) => `| ${r.method} | ${r.url} | ${r.status} | ${r.durationMs}ms |` + ), + ].join('\n') + : '_No HTTP log available._'; + + const rawErrors = finding.evidence.rawErrors.length > 0 + ? '```\n' + finding.evidence.rawErrors.join('\n') + '\n```' + : '_No raw errors recorded._'; + + const md = `# Bug Report — ${finding.type.value} — ${date} + +## Summary +${finding.description} + +## Severity +**${finding.severity.value}** — detected by ABE heuristic rule \`${finding.type.value}\` + +## Status +**${finding.status.value}** + +## Reproduction Steps + +${steps.length > 0 ? steps : '_No steps recorded._'} + +**Seed used**: \`${seed}\` +**Replay command**: \`${replayCmd}\` + +## Observed Behavior +${finding.description} + +## Evidence +- Screenshot: \`${finding.evidence.screenshotPath ?? 'N/A'}\` +- DOM Snapshot: \`${finding.evidence.domSnapshotPath ?? 'N/A'}\` +- HTTP Log: + +${httpTable} + +## Raw Errors +${rawErrors} +`; + + const filePath = path.join(outputDir, 'report.md'); + fs.writeFileSync(filePath, md, 'utf8'); + return filePath; + } +} diff --git a/src/modules/findings/infrastructure/exporters/PlaywrightScriptExporter.ts b/src/modules/findings/infrastructure/exporters/PlaywrightScriptExporter.ts new file mode 100644 index 0000000..99910fe --- /dev/null +++ b/src/modules/findings/infrastructure/exporters/PlaywrightScriptExporter.ts @@ -0,0 +1,54 @@ +import { Finding } from '../../domain/entities/Finding'; + +export class PlaywrightScriptExporter { + generate(finding: Finding): string { + const trace = finding.actionTrace; + const lines: string[] = [ + '// Auto-generated replay script by ABE (Autonomous Bug Explorer)', + `// Generated at: ${new Date().toISOString()}`, + `// Finding ID: ${finding.id.toString()}`, + `// Type: ${finding.type.value} | Severity: ${finding.severity.value}`, + `// Steps: ${trace.length}`, + '', + "const { chromium } = require('playwright');", + '', + '(async () => {', + ' const browser = await chromium.launch({ headless: true });', + ' const context = await browser.newContext();', + ' const page = await context.newPage();', + '', + ]; + + for (let i = 0; i < trace.length; i++) { + const action = trace[i]; + lines.push(` // Step ${i + 1}: ${action.type} (seed=${action.seed})`); + + switch (action.type) { + case 'navigate': + lines.push(` await page.goto(${JSON.stringify(action.url)});`); + break; + case 'click': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().click();`); + break; + case 'fill': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().fill(${JSON.stringify(action.value ?? '')});`); + break; + case 'select': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().selectOption(${JSON.stringify(action.value ?? '')});`); + break; + case 'submit': + lines.push(` await page.locator(${JSON.stringify(action.selector)}).first().dispatchEvent('submit');`); + break; + } + lines.push(''); + } + + lines.push( + " console.log('Replay complete');", + ' await browser.close();', + '})();' + ); + + return lines.join('\n'); + } +} diff --git a/src/modules/findings/infrastructure/http/FindingsController.ts b/src/modules/findings/infrastructure/http/FindingsController.ts new file mode 100644 index 0000000..7bb881a --- /dev/null +++ b/src/modules/findings/infrastructure/http/FindingsController.ts @@ -0,0 +1,152 @@ +import { Router, Request, Response } from 'express'; +import { GetFindingQuery } from '../../application/queries/GetFindingQuery'; +import { ListFindingsQuery } from '../../application/queries/ListFindingsQuery'; +import { FindingStatsQuery } from '../../application/queries/FindingStatsQuery'; +import { ResolveFindingCommand } from '../../application/commands/ResolveFindingCommand'; +import { EnrichFindingCommand } from '../../application/commands/EnrichFindingCommand'; +import { MarkdownExporter } from '../exporters/MarkdownExporter'; +import { JSONExporter } from '../exporters/JSONExporter'; +import { PlaywrightScriptExporter } from '../exporters/PlaywrightScriptExporter'; +import { Finding } from '../../domain/entities/Finding'; +import path from 'path'; + +export interface FindingsControllerDeps { + getFinding: GetFindingQuery; + listFindings: ListFindingsQuery; + findingStats: FindingStatsQuery; + resolveFinding: ResolveFindingCommand; + enrichFinding: EnrichFindingCommand; +} + +export function createFindingsRouter(deps: FindingsControllerDeps): Router { + const router = Router(); + const markdownExporter = new MarkdownExporter(); + const jsonExporter = new JSONExporter(); + const playwrightExporter = new PlaywrightScriptExporter(); + + // GET /api/findings — list findings with filters + router.get('/', async (req: Request, res: Response) => { + const { sessionId, severity, type, status, search } = req.query as Record; + const result = await deps.listFindings.execute({ sessionId, severity, type, status, search }); + if (!result.ok) { + res.status(500).json({ error: result.error }); + return; + } + const { findings, total } = result.value; + res.json({ + findings: findings.map(f => toDTO(f)), + total, + }); + }); + + // GET /api/findings/stats — aggregate stats + router.get('/stats', async (req: Request, res: Response) => { + const { sessionId } = req.query as { sessionId?: string }; + const result = await deps.findingStats.execute({ sessionId }); + if (!result.ok) { + res.status(500).json({ error: result.error }); + return; + } + res.json(result.value); + }); + + // GET /api/findings/:id — finding detail + router.get('/:id', async (req: Request, res: Response) => { + const findingId = req.params['id'] as string; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + res.json(toDTO(result.value)); + }); + + // PATCH /api/findings/:id/status — update status + router.patch('/:id/status', async (req: Request, res: Response) => { + const findingId = req.params['id'] as string; + const { action } = req.body as { action?: 'resolve' | 'close' | 'investigate' }; + + if (!action || !['resolve', 'close', 'investigate'].includes(action)) { + res.status(400).json({ error: 'action must be one of: resolve, close, investigate' }); + return; + } + + const result = await deps.resolveFinding.execute({ findingId, action }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + res.json(result.value); + }); + + // POST /api/findings/:id/enrich — trigger AI enrichment + router.post('/:id/enrich', async (req: Request, res: Response) => { + const findingId = req.params['id'] as string; + const result = await deps.enrichFinding.execute({ findingId }); + if (!result.ok) { + res.status(422).json({ error: result.error }); + return; + } + res.json(result.value); + }); + + // GET /api/findings/:id/export/markdown — download as Markdown + router.get('/:id/export/markdown', async (req: Request, res: Response) => { + const findingId = req.params['id'] as string; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + const outputDir = path.join(process.cwd(), 'reports', findingId); + const filePath = await markdownExporter.export(result.value, outputDir); + res.download(filePath, `finding-${findingId}.md`); + }); + + // GET /api/findings/:id/export/json — download as JSON + router.get('/:id/export/json', async (req: Request, res: Response) => { + const findingId = req.params['id'] as string; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + const outputDir = path.join(process.cwd(), 'reports', findingId); + const filePath = await jsonExporter.export(result.value, outputDir); + res.download(filePath, `finding-${findingId}.json`); + }); + + // GET /api/findings/:id/export/playwright — download Playwright script + router.get('/:id/export/playwright', async (req: Request, res: Response) => { + const findingId = req.params['id'] as string; + const result = await deps.getFinding.execute({ findingId }); + if (!result.ok) { + res.status(404).json({ error: result.error }); + return; + } + const script = playwrightExporter.generate(result.value); + res.setHeader('Content-Type', 'text/javascript'); + res.setHeader('Content-Disposition', `attachment; filename="finding-${findingId}.spec.js"`); + res.send(script); + }); + + return router; +} + +function toDTO(f: Finding) { + return { + 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, + browserVersion: f.browserVersion, + actionTraceLength: f.actionTrace.length, + evidence: f.evidence.toJSON(), + aiEnrichment: f.aiEnrichment ?? null, + createdAt: f.createdAt.toISOString(), + resolvedAt: f.resolvedAt?.toISOString() ?? null, + }; +} diff --git a/src/modules/findings/infrastructure/repositories/KyselyFindingRepository.ts b/src/modules/findings/infrastructure/repositories/KyselyFindingRepository.ts new file mode 100644 index 0000000..5d72345 --- /dev/null +++ b/src/modules/findings/infrastructure/repositories/KyselyFindingRepository.ts @@ -0,0 +1,153 @@ +import { Kysely } from 'kysely'; +import { Database, FindingTable } from '../../../../shared/infrastructure/DatabaseConnection'; +import { IFindingRepository, FindingFilters } from '../../domain/ports/IFindingRepository'; +import { Finding, FindingProps } from '../../domain/entities/Finding'; +import { UniqueId } from '../../../../shared/domain/UniqueId'; +import { Severity } from '../../domain/value-objects/Severity'; +import { FindingType } from '../../domain/value-objects/FindingType'; +import { FindingStatus } from '../../domain/value-objects/FindingStatus'; +import { Evidence } from '../../domain/value-objects/Evidence'; +import { IAction, IAIEnrichment, IHttpResponse } from '../../../../core/interfaces'; + +export class KyselyFindingRepository implements IFindingRepository { + constructor(private readonly db: Kysely) {} + + async save(finding: Finding): Promise { + const row: FindingTable = { + id: finding.id.toString(), + session_id: finding.sessionId, + type: finding.type.value, + severity: finding.severity.value, + description: finding.description, + status: finding.status.value, + action_trace_json: JSON.stringify(finding.actionTrace), + evidence_json: JSON.stringify(finding.evidence.toJSON()), + screenshot_path: finding.evidence.screenshotPath ?? null, + dom_snapshot_path: finding.evidence.domSnapshotPath ?? null, + browser: finding.browser ?? null, + browser_version: finding.browserVersion ?? null, + ai_enrichment_json: finding.aiEnrichment ? JSON.stringify(finding.aiEnrichment) : null, + created_at: finding.createdAt.getTime(), + resolved_at: finding.resolvedAt ? finding.resolvedAt.getTime() : null, + }; + + await this.db.insertInto('findings').values(row).execute(); + } + + async findById(id: string): Promise { + const row = await this.db + .selectFrom('findings') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + + return row ? this.toDomain(row) : undefined; + } + + async findAll(filters?: FindingFilters): Promise { + let query = this.db.selectFrom('findings').selectAll(); + + if (filters?.sessionId) { + query = query.where('session_id', '=', filters.sessionId); + } + if (filters?.severity) { + query = query.where('severity', '=', filters.severity); + } + if (filters?.type) { + query = query.where('type', '=', filters.type); + } + if (filters?.status) { + query = query.where('status', '=', filters.status); + } + if (filters?.search) { + query = query.where('description', 'like', `%${filters.search}%`); + } + + const rows = await query.orderBy('created_at', 'desc').execute(); + return rows.map((row) => this.toDomain(row)); + } + + async update(finding: Finding): Promise { + await this.db + .updateTable('findings') + .set({ + status: finding.status.value, + ai_enrichment_json: finding.aiEnrichment ? JSON.stringify(finding.aiEnrichment) : null, + resolved_at: finding.resolvedAt ? finding.resolvedAt.getTime() : null, + }) + .where('id', '=', finding.id.toString()) + .execute(); + } + + async count(filters?: FindingFilters): Promise { + let query = this.db.selectFrom('findings').select(eb => eb.fn.countAll().as('cnt')); + + if (filters?.sessionId) { + query = query.where('session_id', '=', filters.sessionId); + } + if (filters?.severity) { + query = query.where('severity', '=', filters.severity); + } + if (filters?.type) { + query = query.where('type', '=', filters.type); + } + if (filters?.status) { + query = query.where('status', '=', filters.status); + } + + const result = await query.executeTakeFirst(); + return Number(result?.cnt ?? 0); + } + + async countBySeverity(): Promise> { + const rows = await this.db + .selectFrom('findings') + .select(['severity', eb => eb.fn.countAll().as('cnt')]) + .groupBy('severity') + .execute(); + + const result: Record = {}; + for (const row of rows) { + result[row.severity] = Number(row.cnt); + } + return result; + } + + private toDomain(row: FindingTable): Finding { + const actionTrace = this.parseJson(row.action_trace_json, []); + const evidenceData = this.parseJson>(row.evidence_json, {}); + const aiEnrichment = row.ai_enrichment_json + ? this.parseJson(row.ai_enrichment_json, undefined as unknown as IAIEnrichment) + : undefined; + + const props: FindingProps = { + sessionId: row.session_id, + severity: Severity.fromString(row.severity), + type: FindingType.fromString(row.type), + description: row.description, + evidence: Evidence.create({ + screenshotPath: row.screenshot_path ?? undefined, + domSnapshotPath: row.dom_snapshot_path ?? undefined, + httpLog: (evidenceData.httpLog as IHttpResponse[]) ?? [], + rawErrors: (evidenceData.rawErrors as string[]) ?? [], + }), + status: FindingStatus.fromString(row.status), + actionTrace, + browser: (row.browser as FindingProps['browser']) ?? undefined, + browserVersion: row.browser_version ?? undefined, + aiEnrichment, + createdAt: new Date(row.created_at), + resolvedAt: row.resolved_at ? new Date(row.resolved_at) : undefined, + }; + + return Finding.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 b4dc45e..16c58f5 100644 --- a/src/shared/infrastructure/DatabaseConnection.ts +++ b/src/shared/infrastructure/DatabaseConnection.ts @@ -119,6 +119,24 @@ export interface PerformanceMetricTable { captured_at: number; } +export interface FindingTable { + id: string; + session_id: string; + type: string; + severity: string; + description: string; + status: string; + action_trace_json: string; + evidence_json: string; + screenshot_path: string | null; + dom_snapshot_path: string | null; + browser: string | null; + browser_version: string | null; + ai_enrichment_json: string | null; + created_at: number; + resolved_at: number | null; +} + export interface Database { sessions: SessionTable; states: StateTable; @@ -129,6 +147,7 @@ export interface Database { visual_baselines: VisualBaselineTable; visual_comparisons: VisualComparisonTable; performance_metrics: PerformanceMetricTable; + findings: FindingTable; } export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely { diff --git a/tests/modules/findings.test.ts b/tests/modules/findings.test.ts new file mode 100644 index 0000000..c9d6c6d --- /dev/null +++ b/tests/modules/findings.test.ts @@ -0,0 +1,233 @@ +import { Finding } from '../../src/modules/findings/domain/entities/Finding'; +import { Severity } from '../../src/modules/findings/domain/value-objects/Severity'; +import { FindingType } from '../../src/modules/findings/domain/value-objects/FindingType'; +import { FindingStatus } from '../../src/modules/findings/domain/value-objects/FindingStatus'; +import { Evidence } from '../../src/modules/findings/domain/value-objects/Evidence'; +import { CreateFindingCommand } from '../../src/modules/findings/application/commands/CreateFindingCommand'; +import { ListFindingsQuery } from '../../src/modules/findings/application/queries/ListFindingsQuery'; +import { IFindingRepository, FindingFilters } from '../../src/modules/findings/domain/ports/IFindingRepository'; +import { EventBus } from '../../src/shared/application/EventBus'; +import { DomainEvent } from '../../src/shared/domain/DomainEvent'; +import { EventHandler } from '../../src/shared/application/EventHandler'; +import { IAnomaly } from '../../src/core/interfaces'; + +// ─── Mock Repository ────────────────────────────────────────────────────────── + +class InMemoryFindingRepository implements IFindingRepository { + private store = new Map(); + + async save(finding: Finding): Promise { + this.store.set(finding.id.toString(), finding); + } + + async findById(id: string): Promise { + return this.store.get(id); + } + + async findAll(filters?: FindingFilters): Promise { + let findings = Array.from(this.store.values()); + if (filters?.severity) findings = findings.filter(f => f.severity.value === filters.severity); + if (filters?.status) findings = findings.filter(f => f.status.value === filters.status); + if (filters?.sessionId) findings = findings.filter(f => f.sessionId === filters.sessionId); + return findings; + } + + async update(finding: Finding): Promise { + this.store.set(finding.id.toString(), finding); + } + + async count(filters?: FindingFilters): Promise { + return (await this.findAll(filters)).length; + } + + async countBySeverity(): Promise> { + const result: Record = {}; + for (const f of this.store.values()) { + result[f.severity.value] = (result[f.severity.value] ?? 0) + 1; + } + return result; + } +} + +class MockEventBus implements EventBus { + published: DomainEvent[] = []; + async publish(event: DomainEvent): Promise { this.published.push(event); } + subscribe(_name: string, _handler: EventHandler): void {} +} + +// ─── Test Helpers ───────────────────────────────────────────────────────────── + +function makeAnomaly(overrides: Partial = {}): IAnomaly { + return { + id: 'anom-1', + type: 'http_error', + severity: 'high', + observationId: 'obs-1', + actionTrace: [], + description: 'HTTP 500 error', + evidence: { rawErrors: ['Error'] }, + timestamp: Date.now(), + ...overrides, + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('Finding aggregate', () => { + it('creates with open status and emits FindingCreated event', () => { + const finding = Finding.create({ + sessionId: 'sess-1', + severity: Severity.high(), + type: FindingType.fromString('http_error'), + description: 'Test finding', + evidence: Evidence.empty(), + actionTrace: [], + }); + + expect(finding.status.value).toBe('open'); + expect(finding.domainEvents).toHaveLength(1); + expect(finding.domainEvents[0].eventName).toBe('finding.created'); + }); + + it('resolves and emits FindingResolved event', () => { + const finding = Finding.create({ + sessionId: 'sess-1', + severity: Severity.low(), + type: FindingType.fromString('console_error'), + description: 'Console error', + evidence: Evidence.empty(), + actionTrace: [], + }); + finding.clearEvents(); + + finding.resolve(); + + expect(finding.status.value).toBe('resolved'); + expect(finding.resolvedAt).toBeDefined(); + expect(finding.domainEvents[0].eventName).toBe('finding.resolved'); + }); + + it('transitions status: open → investigating → closed', () => { + const finding = Finding.create({ + sessionId: 'sess-1', + severity: Severity.medium(), + type: FindingType.fromString('js_exception'), + description: 'JS error', + evidence: Evidence.empty(), + actionTrace: [], + }); + + finding.investigate(); + expect(finding.status.value).toBe('investigating'); + + finding.close(); + expect(finding.status.value).toBe('closed'); + }); +}); + +describe('Severity value object', () => { + it('creates from valid string', () => { + expect(Severity.fromString('critical').value).toBe('critical'); + }); + + it('throws on invalid severity', () => { + expect(() => Severity.fromString('extreme')).toThrow(); + }); + + it('equals by value', () => { + expect(Severity.high().equals(Severity.high())).toBe(true); + expect(Severity.high().equals(Severity.low())).toBe(false); + }); +}); + +describe('FindingStatus value object', () => { + it('creates all valid statuses', () => { + expect(FindingStatus.open().value).toBe('open'); + expect(FindingStatus.investigating().value).toBe('investigating'); + expect(FindingStatus.resolved().value).toBe('resolved'); + expect(FindingStatus.closed().value).toBe('closed'); + }); + + it('throws on invalid status', () => { + expect(() => FindingStatus.fromString('unknown')).toThrow(); + }); +}); + +describe('CreateFindingCommand', () => { + let repo: InMemoryFindingRepository; + let bus: MockEventBus; + let cmd: CreateFindingCommand; + + beforeEach(() => { + repo = new InMemoryFindingRepository(); + bus = new MockEventBus(); + cmd = new CreateFindingCommand(repo, bus); + }); + + it('creates a finding from an anomaly', async () => { + const anomaly = makeAnomaly(); + const result = await cmd.execute({ anomaly, sessionId: 'sess-1' }); + + expect(result.ok).toBe(true); + if (result.ok) { + const finding = await repo.findById(result.value.findingId); + expect(finding).toBeDefined(); + expect(finding?.severity.value).toBe('high'); + expect(finding?.type.value).toBe('http_error'); + expect(finding?.sessionId).toBe('sess-1'); + } + }); + + it('publishes FindingCreated event', async () => { + await cmd.execute({ anomaly: makeAnomaly(), sessionId: 'sess-1' }); + expect(bus.published.some(e => e.eventName === 'finding.created')).toBe(true); + }); + + it('returns Err on invalid severity', async () => { + const anomaly = makeAnomaly({ severity: 'extreme' as IAnomaly['severity'] }); + const result = await cmd.execute({ anomaly, sessionId: 'sess-1' }); + expect(result.ok).toBe(false); + }); +}); + +describe('ListFindingsQuery', () => { + let repo: InMemoryFindingRepository; + let query: ListFindingsQuery; + + beforeEach(async () => { + repo = new InMemoryFindingRepository(); + const bus = new MockEventBus(); + const cmd = new CreateFindingCommand(repo, bus); + + await cmd.execute({ anomaly: makeAnomaly({ id: 'a1', severity: 'high', type: 'http_error' }), sessionId: 'sess-1' }); + await cmd.execute({ anomaly: makeAnomaly({ id: 'a2', severity: 'low', type: 'console_error' }), sessionId: 'sess-1' }); + await cmd.execute({ anomaly: makeAnomaly({ id: 'a3', severity: 'critical', type: 'xss_reflection' }), sessionId: 'sess-2' }); + + query = new ListFindingsQuery(repo); + }); + + it('returns all findings without filters', async () => { + const result = await query.execute({}); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.total).toBe(3); + } + }); + + it('filters by severity', async () => { + const result = await query.execute({ severity: 'high' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.findings.every(f => f.severity.value === 'high')).toBe(true); + } + }); + + it('filters by sessionId', async () => { + const result = await query.execute({ sessionId: 'sess-2' }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.total).toBe(1); + expect(result.value.findings[0].sessionId).toBe('sess-2'); + } + }); +});