import { describe, expect, it } from 'vitest'; import type { Assessment, AssessmentProject, CanonicalAssessmentModel, FindingReview, ImportFileRecord, ScanRun, SourceTableRecord } from '../../src/domain/canonical-model'; import type { ImportStagingData } from '../../src/persistence/assessor-repository'; import { compareScanRuns } from '../../src/comparison/comparison-engine'; import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository'; import { RepositoryConflictError, RepositoryValidationError } from '../../src/persistence/repository-errors'; const timestamp = '2026-07-17T08:00:00.000Z'; const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] }; const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' }; const project: AssessmentProject = { id: 'project-1', name: 'Project 1', createdAt: timestamp, updatedAt: timestamp, attributes: {} }; const assessment: Assessment = { id: 'assessment-1', projectId: project.id, name: 'Assessment 1', createdAt: timestamp, updatedAt: timestamp }; function scanRun(sequence: number): ScanRun { return { id: `run-${sequence}`, assessmentId: assessment.id, sequence, label: `Run ${sequence}`, tool: { id: 'pingcastle', name: 'PingCastle', version: '3.3' }, importedAt: timestamp }; } function importData(run: ScanRun, options: { occurrenceId?: string; ruleId?: string; severity?: 'high' | 'critical'; includeDefinition?: boolean; sha256?: string } = {}): Required { const fileId = `file-${run.id}`; const tableId = `table-${run.id}`; const occurrenceId = options.occurrenceId ?? `occurrence-${run.id}`; const ruleId = options.ruleId ?? 'RULE-1'; const file: ImportFileRecord = { id: fileId, scanRunId: run.id, fileName: `${run.id}.csv`, format: 'CSV', sizeBytes: 100, importedAt: timestamp, detectedTool: run.tool, detection: confidence, processedWith: { parserId: 'csv', parserVersion: '1.0.0', profileId: 'pingcastle', profileVersion: '1.0.0', mappingVersion: '1.0.0' }, sourceArtifact: { kind: 'not-retained', reason: 'Synthetic test' }, sha256: options.sha256 ?? 'a'.repeat(64) }; const table: SourceTableRecord = { id: tableId, importFileId: fileId, name: 'Findings', index: 0, rowCount: 1, columnCount: 3, attributes: {} }; const severity = options.severity ?? 'high'; return { importFiles: [file], sourceTables: [table], reportSections: [], environmentObservations: [], findingDefinitions: options.includeDefinition === false ? [] : [{ id: 'definition-1', canonicalKey: 'pingcastle:rule-1', title: 'Synthetic rule', frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp }], findingOccurrences: [{ id: occurrenceId, definitionId: 'definition-1', scanRunId: run.id, importFileId: fileId, sourceTableId: tableId, sourceIdentity: { tool: run.tool, originalRuleId: ruleId, canonicalRuleId: ruleId, originalTitle: 'Synthetic rule' }, sourcePosition: { rowIndex: 1 }, originalFields: [{ key: 'rule', label: 'Rule', value: ruleId, columnIndex: 0 }], fieldMappings: [{ targetRole: 'rule-id', sourceFieldKeys: ['rule'], normalizedValue: ruleId, provenance }], severity: { originalValue: severity, normalizedValue: severity, provenance }, status: { originalValue: 'Open', normalizedValue: 'open', provenance } }], evidence: [{ id: `evidence-${run.id}`, occurrenceId, kind: 'text', value: `Evidence ${run.sequence}`, sourceFieldKeys: ['evidence'] }], diagnostics: [{ id: `diagnostic-${run.id}`, importFileId: fileId, severity: 'info', code: 'synthetic', message: 'Synthetic import' }] }; } async function repositoryWithProject(): Promise { const repository = new LocalAssessorRepository(); await repository.saveProject(project); await repository.saveAssessment(assessment); return repository; } async function commitRun(repository: LocalAssessorRepository, run: ScanRun, data = importData(run)) { const session = await repository.beginImport({ sessionId: `session-${run.id}`, projectId: project.id, assessmentId: assessment.id, scanRun: run }); await session.stage(data); return session.commit(); } function review(revision: number, status: FindingReview['workflowStatus'] = 'open'): FindingReview { return { id: 'review-1', target: { kind: 'occurrence', occurrenceId: 'occurrence-run-1' }, workflowStatus: status, priority: 'high', includeInActionPlan: true, markers: [], createdAt: timestamp, updatedAt: timestamp, revision }; } describe('LocalAssessorRepository contract', () => { it('adopts an already imported newer scan as the auditable replacement without deleting data', async () => { const repository = await repositoryWithProject(); const original = scanRun(1); const replacement = { ...scanRun(2), importedAt: '2026-07-17T09:00:00.000Z' }; await commitRun(repository, original); await commitRun(repository, replacement, importData(replacement, { occurrenceId: 'occurrence-run-2' })); await repository.adoptScanRunReplacement(project.id, original.id, replacement.id, '2026-07-17T09:01:00.000Z'); await expect(repository.listProjectScanRuns(project.id)).resolves.toEqual([ expect.objectContaining({ id: replacement.id, supersedesScanRunId: original.id }) ]); const exported = await repository.exportProject(project.id); expect(exported.scanRuns).toEqual(expect.arrayContaining([ expect.objectContaining({ id: original.id, supersededByScanRunId: replacement.id }), expect.objectContaining({ id: replacement.id, supersedesScanRunId: original.id }) ])); expect(exported.findingOccurrences).toHaveLength(2); }); it('rejects adoption of a newer scan whose SHA-256 source differs', async () => { const repository = await repositoryWithProject(); const original = scanRun(1); const replacement = { ...scanRun(2), importedAt: '2026-07-17T09:00:00.000Z' }; await commitRun(repository, original); await commitRun(repository, replacement, importData(replacement, { occurrenceId: 'occurrence-run-2', sha256: 'b'.repeat(64) })); await expect(repository.adoptScanRunReplacement( project.id, original.id, replacement.id, '2026-07-17T09:01:00.000Z' )).rejects.toThrow('kryptografisch exakt derselbe Scanbericht'); }); it('blocks a canonical partial import that would mix incompatible processing contracts in one timeslot', async () => { const repository = await repositoryWithProject(); await commitRun(repository, scanRun(1)); const nextRun = scanRun(2); const nextData = importData(nextRun, { occurrenceId: 'occurrence-run-2' }); const sha256 = 'b'.repeat(64); nextData.importFiles[0] = { ...nextData.importFiles[0], sha256, processedWith: { ...nextData.importFiles[0].processedWith, profileVersion: '1.2.0', mappingVersion: '1.1.0', normalizationVersion: '1.1.0', presentationVersion: '1.2.0', compatibilityKey: 'pingcastle-canonical-v1.1', changeClass: 'presentation-only', reimportRequired: true } }; const model: CanonicalAssessmentModel = { schemaVersion: 1, project, assessment, scanRuns: [nextRun], reviews: [], ...nextData }; await expect(repository.commitCanonicalImport({ sessionId: 'canonical-incompatible', model, fingerprints: [{ sha256, fileName: nextData.importFiles[0].fileName, sizeBytes: 100, lastModified: 0 }] })).rejects.toThrow('inkompatible Verarbeitungsstände'); expect((await repository.loadAssessmentModel(assessment.id))?.scanRuns.map(run => run.id)).toEqual(['run-1']); }); it('keeps staged imports invisible and commits the complete validated batch atomically', async () => { const repository = await repositoryWithProject(); const run = scanRun(1); const session = await repository.beginImport({ sessionId: 'session-1', projectId: project.id, assessmentId: assessment.id, scanRun: run }); await session.stage(importData(run)); expect((await repository.loadAssessmentModel(assessment.id))?.scanRuns).toHaveLength(0); expect((await repository.findOccurrences({ assessmentId: assessment.id })).total).toBe(0); const committed = await session.commit(); expect(committed).toMatchObject({ importedFiles: 1, importedOccurrences: 1, scanRunId: run.id }); expect(session.status).toBe('committed'); const model = await repository.loadAssessmentModel(assessment.id); expect(model?.scanRuns).toHaveLength(1); expect(model?.findingOccurrences).toHaveLength(1); await expect(repository.findOccurrences({ severity: 'high', limit: 10 })).resolves.toMatchObject({ total: 1 }); await expect(repository.findOccurrences({ search: 'Synthetic rule' })).resolves.toMatchObject({ total: 1 }); await expect(repository.findOccurrences({ search: 'not-present' })).resolves.toMatchObject({ total: 0 }); }); it('persists scanner context and returns an assessment-scoped environment snapshot', async () => { const repository = await repositoryWithProject(); const run = scanRun(1); const data = importData(run); data.reportSections = [{ version: 1, id: 'section-run-1', scanRunId: run.id, importFileId: data.importFiles[0].id, tool: run.tool, sectionKey: 'sectionDomainInformation', label: 'Domain information', ordinal: 0, state: 'observed', prose: 'Synthetic context.', tables: [], source: { objectPath: 'html/sections/domain' }, extractorVersion: '2.0.0', attributes: {} }]; data.environmentObservations = [{ version: 1, id: 'observation-run-1', scanRunId: run.id, importFileId: data.importFiles[0].id, tool: run.tool, subject: { type: 'domain-information', identity: 'synthetic.example' }, property: 'domain.functional_level', label: 'Domain functional level', originalValue: '2016', normalizedValue: 2016, valueType: 'number', state: 'observed', sectionKey: 'sectionDomainInformation', source: { objectPath: 'html/sections/domain/table/0' }, mappingVersion: '2.0.0', attributes: {} }]; await commitRun(repository, run, data); await expect(repository.getEnvironmentSnapshot({ projectId: project.id, assessmentId: assessment.id })) .resolves.toMatchObject({ projectId: project.id, assessmentId: assessment.id, scanRunIds: [run.id], coverage: { observed: 2, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }, sections: [expect.objectContaining({ sectionKey: 'sectionDomainInformation' })], observations: [expect.objectContaining({ property: 'domain.functional_level', normalizedValue: 2016 })] }); await expect(repository.exportProject(project.id)).resolves.toMatchObject({ reportSections: [expect.objectContaining({ id: 'section-run-1' })], environmentObservations: [expect.objectContaining({ id: 'observation-run-1' })] }); }); it('rolls back explicitly and leaves no partial data after validation errors', async () => { const repository = await repositoryWithProject(); const first = scanRun(1); const rollback = await repository.beginImport({ sessionId: 'rollback', projectId: project.id, assessmentId: assessment.id, scanRun: first }); await rollback.stage(importData(first)); await rollback.rollback(); expect(rollback.status).toBe('rolled-back'); expect((await repository.loadAssessmentModel(assessment.id))?.scanRuns).toHaveLength(0); const invalidRun = scanRun(2); const invalid = await repository.beginImport({ sessionId: 'invalid', projectId: project.id, assessmentId: assessment.id, scanRun: invalidRun }); const invalidData = importData(invalidRun); invalidData.importFiles = []; await invalid.stage(invalidData); await expect(invalid.commit()).rejects.toBeInstanceOf(RepositoryValidationError); expect((await repository.loadAssessmentModel(assessment.id))?.scanRuns).toHaveLength(0); expect((await repository.findOccurrences()).total).toBe(0); await invalid.rollback(); }); it('rejects an import when the repository changed after the transaction began', async () => { const repository = await repositoryWithProject(); const run = scanRun(1); const session = await repository.beginImport({ sessionId: 'conflict', projectId: project.id, assessmentId: assessment.id, scanRun: run }); await session.stage(importData(run)); await repository.saveProfileRegistration({ profileId: 'pingcastle', profileVersion: '1.0.0', name: 'PingCastle', schemaVersion: 1, enabled: true }); await expect(session.commit()).rejects.toBeInstanceOf(RepositoryConflictError); expect((await repository.loadAssessmentModel(assessment.id))?.scanRuns).toHaveLength(0); await session.rollback(); }); it('protects review updates with optimistic revisions', async () => { const repository = await repositoryWithProject(); await commitRun(repository, scanRun(1)); await repository.saveReview(review(1), 0); await repository.saveReview(review(2, 'in-progress'), 1); await expect(repository.saveReview(review(3, 'resolved'), 1)).rejects.toBeInstanceOf(RepositoryConflictError); await expect(repository.listReviews(project.id)).resolves.toEqual([review(2, 'in-progress')]); }); it('stores profile metadata, confirmed mappings and comparison results through the same boundary', async () => { const repository = await repositoryWithProject(); await commitRun(repository, scanRun(1)); await commitRun(repository, scanRun(2), importData(scanRun(2), { occurrenceId: 'occurrence-run-2', severity: 'critical', includeDefinition: true })); await repository.saveProfileRegistration({ profileId: 'pingcastle', profileVersion: '1.0.0', name: 'PingCastle', schemaVersion: 1, enabled: true }); await repository.saveLocalComparisonRule(project.id, { version: 1, id: 'confirmed-rule-rename', left: { toolId: 'pingcastle', ruleId: 'legacy-1' }, right: { toolId: 'pingcastle', ruleId: 'rule-1' }, source: 'user-confirmed' }); const canonical = await repository.loadAssessmentModel(assessment.id); const comparison = compareScanRuns(canonical!, 'run-1', 'run-2'); await repository.saveComparison({ id: 'comparison-1', projectId: project.id, assessmentId: assessment.id, comparison }); await expect(repository.listProfileRegistrations()).resolves.toHaveLength(1); await expect(repository.listLocalComparisonRules(project.id)).resolves.toHaveLength(1); await expect(repository.loadComparison('comparison-1')).resolves.toMatchObject({ comparison: { summary: { persistent: 1, changedSeverity: 1 } } }); }); it('exports a complete project and deletes it only after exact cascade confirmation', async () => { const repository = await repositoryWithProject(); await commitRun(repository, scanRun(1)); await repository.saveReview(review(1), 0); await repository.saveLocalComparisonRule(project.id, { version: 1, id: 'mapping-1', left: { toolId: 'pingcastle', ruleId: 'old' }, right: { toolId: 'pingcastle', ruleId: 'new' }, source: 'user-confirmed' }); const exported = await repository.exportProject(project.id); expect(exported).toMatchObject({ version: 1, project: { id: project.id } }); expect(exported.findingOccurrences).toHaveLength(1); expect(exported.reviews).toHaveLength(1); expect(exported.localComparisonRules).toHaveLength(1); await expect(repository.deleteProject(project.id, { projectId: 'wrong-project', cascade: true })).rejects.toBeInstanceOf(RepositoryValidationError); await expect(repository.getProject(project.id)).resolves.not.toBeNull(); await expect(repository.deleteProject(project.id, { projectId: project.id, cascade: true })).resolves.toBe(true); await expect(repository.getProject(project.id)).resolves.toBeNull(); await expect(repository.loadAssessmentModel(assessment.id)).resolves.toBeNull(); await expect(repository.listLocalComparisonRules(project.id)).resolves.toEqual([]); }); it('deletes exactly one confirmed scan run and preserves the remaining project data', async () => { const repository = await repositoryWithProject(); await commitRun(repository, scanRun(1)); await commitRun(repository, scanRun(2), importData(scanRun(2), { occurrenceId: 'occurrence-run-2', severity: 'critical' })); await repository.saveReview(review(1), 0); const canonical = await repository.loadAssessmentModel(assessment.id); await repository.saveComparison({ id: 'comparison-delete', projectId: project.id, assessmentId: assessment.id, comparison: compareScanRuns(canonical!, 'run-1', 'run-2') }); await expect(repository.deleteScanRun(project.id, 'run-1', { projectId: project.id, scanRunId: 'wrong-run', cascade: true })).rejects.toBeInstanceOf(RepositoryValidationError); await expect(repository.deleteScanRun(project.id, 'run-1', { projectId: project.id, scanRunId: 'run-1', cascade: true })).resolves.toBe(true); const remaining = await repository.loadAssessmentModel(assessment.id); expect(remaining?.scanRuns.map(run => run.id)).toEqual(['run-2']); expect(remaining?.findingOccurrences.map(occurrence => occurrence.id)).toEqual(['occurrence-run-2']); await expect(repository.listReviews(project.id)).resolves.toEqual([]); await expect(repository.listComparisons(assessment.id)).resolves.toEqual([]); await expect(repository.getProject(project.id)).resolves.not.toBeNull(); await expect(repository.deleteScanRun(project.id, 'run-2', { projectId: project.id, scanRunId: 'run-2', cascade: true })).resolves.toBe(true); await expect(repository.loadAssessmentModel(assessment.id)).resolves.toBeNull(); await expect(repository.getProject(project.id)).resolves.not.toBeNull(); }); it('deletes one confirmed assessment timeslot while preserving its client', async () => { const repository = await repositoryWithProject(); await commitRun(repository, scanRun(1)); await expect(repository.deleteAssessment(project.id, assessment.id, { projectId: project.id, assessmentId: 'wrong-assessment', cascade: true })).rejects.toBeInstanceOf(RepositoryValidationError); await expect(repository.deleteAssessment(project.id, assessment.id, { projectId: project.id, assessmentId: assessment.id, cascade: true })).resolves.toBe(true); await expect(repository.loadAssessmentModel(assessment.id)).resolves.toBeNull(); await expect(repository.listProjectScanRuns(project.id)).resolves.toEqual([]); await expect(repository.listProjectImportFiles(project.id)).resolves.toEqual([]); await expect(repository.getProject(project.id)).resolves.not.toBeNull(); }); it('reuses one shared CVE enrichment across Defender findings from different tenants', async () => { const repository = await repositoryWithProject(); const secondProject = { ...project, id: 'project-2', name: 'Project 2' }; const secondAssessment = { ...assessment, id: 'assessment-2', projectId: secondProject.id }; await repository.saveProject(secondProject); await repository.saveAssessment(secondAssessment); const firstRun: ScanRun = { ...scanRun(1), id: 'run-defender-1', tool: { id: 'defender-for-iot-vulnerabilities', name: 'Defender for IoT' } }; const secondRun: ScanRun = { ...firstRun, id: 'run-defender-2', assessmentId: secondAssessment.id }; const defenderData = (run: ScanRun) => { const data = importData(run, { occurrenceId: `occurrence-${run.id}`, ruleId: 'CVE-2012-3037' }); data.findingDefinitions[0] = { ...data.findingDefinitions[0], canonicalKey: 'defender-for-iot-vulnerabilities:cve-2012-3037', title: 'CVE-2012-3037' }; data.importFiles[0] = { ...data.importFiles[0], detectedTool: run.tool, processedWith: { ...data.importFiles[0].processedWith, profileId: 'defender-for-iot-vulnerabilities' } }; data.findingOccurrences[0].sourceIdentity.tool = run.tool; return data; }; await commitRun(repository, firstRun, defenderData(firstRun)); const session = await repository.beginImport({ sessionId: 'session-defender-2', projectId: secondProject.id, assessmentId: secondAssessment.id, scanRun: secondRun }); await session.stage(defenderData(secondRun)); await session.commit(); await repository.saveCveEnrichment({ version: 1, cveId: 'CVE-2012-3037', source: 'nvd', sourceIdentifier: 'nvd@nist.gov', sourceUrl: 'https://nvd.nist.gov/vuln/detail/cve-2012-3037', apiVersion: '2.0', vulnerabilityStatus: 'Deferred', publishedAt: '2012-08-25T10:55:01.000Z', lastModifiedAt: '2025-04-11T00:51:21.963Z', fetchedAt: timestamp, description: 'Shared NVD description', metrics: [], weaknesses: [], references: [], affectedConfigurations: [], attributes: {} }); await expect(repository.queryUnifiedResults({ projectId: project.id })).resolves.toMatchObject({ items: [expect.objectContaining({ cveEnrichment: expect.objectContaining({ cveId: 'CVE-2012-3037', description: 'Shared NVD description' }) })] }); await expect(repository.queryUnifiedResults({ projectId: secondProject.id })).resolves.toMatchObject({ items: [expect.objectContaining({ cveEnrichment: expect.objectContaining({ cveId: 'CVE-2012-3037', description: 'Shared NVD description' }) })] }); expect((await repository.loadAssessmentModel(assessment.id))?.cveEnrichments).toHaveLength(1); expect((await repository.loadAssessmentModel(secondAssessment.id))?.cveEnrichments).toHaveLength(1); }); });