234 lines
8.4 KiB
TypeScript
234 lines
8.4 KiB
TypeScript
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<string, Finding>();
|
|
|
|
async save(finding: Finding): Promise<void> {
|
|
this.store.set(finding.id.toString(), finding);
|
|
}
|
|
|
|
async findById(id: string): Promise<Finding | undefined> {
|
|
return this.store.get(id);
|
|
}
|
|
|
|
async findAll(filters?: FindingFilters): Promise<Finding[]> {
|
|
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<void> {
|
|
this.store.set(finding.id.toString(), finding);
|
|
}
|
|
|
|
async count(filters?: FindingFilters): Promise<number> {
|
|
return (await this.findAll(filters)).length;
|
|
}
|
|
|
|
async countBySeverity(): Promise<Record<string, number>> {
|
|
const result: Record<string, number> = {};
|
|
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<void> { this.published.push(event); }
|
|
subscribe(_name: string, _handler: EventHandler): void {}
|
|
}
|
|
|
|
// ─── Test Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
function makeAnomaly(overrides: Partial<IAnomaly> = {}): 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');
|
|
}
|
|
});
|
|
});
|