93 lines
7.0 KiB
TypeScript
93 lines
7.0 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { Pool } from 'pg';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { createLiteLlmClientFromEnvironment } from '../../server/ai/litellm-client';
|
|
import { runDatabaseMigrations } from '../../server/postgres/migration-runner';
|
|
import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository';
|
|
import { createRegulatoryRagRuntime } from '../../server/regulatory/regulatory-rag-runtime';
|
|
import { RegulatoryMappingService } from '../../server/regulatory/regulatory-mapping-service';
|
|
import type { Assessment, AssessmentProject, ScanRun } from '../../src/domain/canonical-model';
|
|
import { RepositoryConflictError } from '../../src/persistence/repository-errors';
|
|
|
|
const enabled = process.env.ASSESSOR_REGULATORY_MAPPING_INTEGRATION === '1' && Boolean(process.env.ASSESSOR_TEST_DATABASE_URL);
|
|
const describeIntegration = enabled ? describe : describe.skip;
|
|
|
|
describeIntegration('regulatory mapping end-to-end integration', () => {
|
|
it('maps one synthetic finding through BGE-M3, Qdrant, LiteLLM, and PostgreSQL', async () => {
|
|
const stamp = new Date().toISOString();
|
|
const suffix = randomUUID();
|
|
const project: AssessmentProject = { id: `synthetic-regulatory-${suffix}`, name: 'Synthetic regulatory integration', createdAt: stamp, updatedAt: stamp, attributes: {} };
|
|
const assessment: Assessment = { id: `synthetic-assessment-${suffix}`, projectId: project.id, name: 'Synthetic', createdAt: stamp, updatedAt: stamp };
|
|
const scanRun: ScanRun = { id: `synthetic-run-${suffix}`, assessmentId: assessment.id, sequence: 1, label: 'Synthetic', tool: { id: 'purple-knight', name: 'Purple Knight', version: 'synthetic' }, importedAt: stamp };
|
|
const occurrenceId = `synthetic-occurrence-${suffix}`;
|
|
const pool = new Pool({ connectionString: process.env.ASSESSOR_TEST_DATABASE_URL, max: 2, connectionTimeoutMillis: 5_000 });
|
|
await runDatabaseMigrations(pool);
|
|
const repository = await PostgresAssessorRepository.create(pool);
|
|
const rag = createRegulatoryRagRuntime();
|
|
const client = createLiteLlmClientFromEnvironment();
|
|
if (!client) throw new Error('Approved LiteLLM configuration is required.');
|
|
const service = new RegulatoryMappingService(repository, {
|
|
findingRetrieval: rag.findingRetrieval, index: rag.index, embeddingModel: rag.embeddingModel, client,
|
|
corpusId: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_ID,
|
|
corpusVersion: process.env.ASSESSOR_RAG_ACTIVE_CORPUS_VERSION
|
|
});
|
|
let deleted = false;
|
|
try {
|
|
await repository.saveProject(project);
|
|
await repository.saveAssessment(assessment);
|
|
const session = await repository.beginImport({ sessionId: `synthetic-session-${suffix}`, projectId: project.id, assessmentId: assessment.id, scanRun });
|
|
await session.stage({
|
|
importFiles: [{ id: `synthetic-file-${suffix}`, scanRunId: scanRun.id, fileName: 'synthetic.csv', format: 'CSV', sizeBytes: 100,
|
|
importedAt: stamp, detectedTool: scanRun.tool, detection: { level: 'high', score: 1, reasons: ['synthetic'] },
|
|
processedWith: { parserId: 'csv', parserVersion: 'synthetic', profileId: 'purple-knight', profileVersion: 'synthetic', mappingVersion: 'synthetic' },
|
|
sourceArtifact: { kind: 'not-retained', reason: 'Synthetic integration fixture' } }],
|
|
sourceTables: [{ id: `synthetic-table-${suffix}`, importFileId: `synthetic-file-${suffix}`, name: 'Synthetic', index: 0, rowCount: 1, columnCount: 2, attributes: {} }],
|
|
findingDefinitions: [{ id: `synthetic-definition-${suffix}`, canonicalKey: `purple-knight:synthetic-${suffix}`, title: 'SMB signing disabled',
|
|
description: 'Synthetic public-topic fixture', category: 'Synthetic', frameworkReferences: [], createdAt: stamp, updatedAt: stamp }],
|
|
findingOccurrences: [{ id: occurrenceId, definitionId: `synthetic-definition-${suffix}`, scanRunId: scanRun.id,
|
|
importFileId: `synthetic-file-${suffix}`, sourceTableId: `synthetic-table-${suffix}`,
|
|
sourceIdentity: { tool: scanRun.tool, originalRuleId: 'SYNTHETIC-SMB', originalTitle: 'SMB signing disabled' },
|
|
sourcePosition: { rowIndex: 1 }, originalFields: [{ key: 'title', label: 'Title', value: 'SMB signing disabled', columnIndex: 0 }],
|
|
fieldMappings: [], severity: { originalValue: 'high', normalizedValue: 'high', provenance: { decisionSource: 'source-native',
|
|
confidence: { level: 'high', score: 1, reasons: ['synthetic'] }, mappingVersion: 'synthetic' } },
|
|
status: { originalValue: 'Open', normalizedValue: 'open', provenance: { decisionSource: 'source-native',
|
|
confidence: { level: 'high', score: 1, reasons: ['synthetic'] }, mappingVersion: 'synthetic' } } }]
|
|
});
|
|
await session.commit();
|
|
|
|
const resultProjectionId = `scanner-finding:${occurrenceId}`;
|
|
await service.start(project.id, { resultProjectionId });
|
|
await service.close();
|
|
const result = await service.get(project.id, resultProjectionId);
|
|
expect(result.run?.status, result.run?.errorCode).toBe('succeeded');
|
|
expect(result.run?.candidates).toHaveLength(8);
|
|
expect(result.run?.candidates.every(candidate => candidate.evidence.some(item => item.sourceStandard === 'ISO/IEC 27001'))).toBe(true);
|
|
expect(result.run?.result?.mappings.length).toBeLessThanOrEqual(5);
|
|
expect(result.run?.result?.mappings.every(mapping => {
|
|
const candidate = result.run?.candidates.find(item => item.controlId === mapping.controlId);
|
|
return Boolean(candidate && mapping.evidenceChunkIds.every(id => candidate.evidence.some(item => item.chunkId === id)));
|
|
})).toBe(true);
|
|
const candidate = result.run!.candidates[0];
|
|
const ruleKey = result.run!.ruleKey;
|
|
const reviewed = await service.decide(project.id, {
|
|
resultProjectionId, controlId: candidate.controlId, decision: 'accepted', comment: 'Synthetic review.'
|
|
});
|
|
expect(reviewed.decisions[0]).toMatchObject({ controlId: candidate.controlId, decision: 'accepted' });
|
|
// Omitting expectedLatestDecisionId conflicts with the existing global decision for this rule+control.
|
|
await expect(repository.createRegulatoryMappingDecision({
|
|
version: 1, id: `synthetic-conflict-${suffix}`, ruleKey, sourceTool: 'purple-knight', sourceRuleId: 'SYNTHETIC-SMB',
|
|
mappingId: result.run!.id, controlId: candidate.controlId, decision: 'rejected', createdAt: new Date().toISOString()
|
|
})).rejects.toBeInstanceOf(RepositoryConflictError);
|
|
await repository.deleteProject(project.id, { projectId: project.id, cascade: true });
|
|
deleted = true;
|
|
// Regulatory mappings and decisions are global: they survive project deletion.
|
|
await expect(repository.getRegulatoryMappingRun(result.run!.id)).resolves.not.toBeNull();
|
|
expect((await repository.listRegulatoryMappingDecisions(ruleKey)).length).toBeGreaterThan(0);
|
|
} finally {
|
|
await service.close();
|
|
if (!deleted) await repository.deleteProject(project.id, { projectId: project.id, cascade: true }).catch(() => false);
|
|
await pool.end();
|
|
}
|
|
}, 120_000);
|
|
});
|