574 lines
32 KiB
TypeScript
574 lines
32 KiB
TypeScript
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';
|
|
import { riskCommand } from '../domain/risk-acceptance-fixture';
|
|
import { riskAcceptanceState } from '../../src/domain/risk-acceptance';
|
|
|
|
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<ImportStagingData> {
|
|
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' }]
|
|
};
|
|
}
|
|
|
|
function defenderRun(sequence: number): ScanRun {
|
|
return { ...scanRun(sequence), tool: { id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', version: '1.2.0' } };
|
|
}
|
|
|
|
function defenderImportData(run: ScanRun, entries: Array<{ definitionId: string; occurrenceId: string; cve: string; devices: string[] }>): Required<ImportStagingData> {
|
|
const base = importData(run, { occurrenceId: entries[0].occurrenceId, sha256: `${run.sequence}`.repeat(64).slice(0, 64) });
|
|
const importFileId = base.importFiles[0].id;
|
|
const sourceTableId = base.sourceTables[0].id;
|
|
return {
|
|
...base,
|
|
importFiles: [{ ...base.importFiles[0], detectedTool: run.tool, processedWith: { ...base.importFiles[0].processedWith, profileId: run.tool.id } }],
|
|
findingDefinitions: entries.map(entry => ({
|
|
id: entry.definitionId, canonicalKey: `${run.tool.id}:${entry.cve}`, title: entry.cve,
|
|
frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp
|
|
})),
|
|
findingOccurrences: entries.map((entry, index) => ({
|
|
id: entry.occurrenceId, definitionId: entry.definitionId, scanRunId: run.id, importFileId, sourceTableId,
|
|
sourceIdentity: { tool: run.tool, originalRuleId: entry.cve, canonicalRuleId: entry.cve, originalTitle: entry.cve },
|
|
sourcePosition: { rowIndex: index + 1 },
|
|
originalFields: [{ key: 'source-observations', label: 'Source Observations', columnIndex: 0, value: entry.devices.map((deviceId, deviceIndex) => ({
|
|
name: `Device ${deviceId}`, ip: `192.0.2.${deviceIndex + 10}`, site: 'Synthetic site', sensor: 'sensor-a', cve: entry.cve,
|
|
severity: 'High', description: 'Synthetic finding', additionalSourceFields: { 'Device ID': deviceId },
|
|
source: { tableIndex: 0, rowIndex: index + deviceIndex + 1 }
|
|
})) }],
|
|
fieldMappings: [], severity: { originalValue: 'High', normalizedValue: 'high', provenance },
|
|
status: { originalValue: 'Open', normalizedValue: 'open', provenance }
|
|
})),
|
|
evidence: [], diagnostics: []
|
|
};
|
|
}
|
|
|
|
async function repositoryWithProject(): Promise<LocalAssessorRepository> {
|
|
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,
|
|
workflowLabel: status === 'in-progress' ? 'In Review' : status === 'resolved' ? 'Done' : 'Open',
|
|
priority: 'high',
|
|
includeInActionPlan: true,
|
|
owner: 'Synthetic Security Team',
|
|
dueAt: '2026-10-15',
|
|
prerequisites: 'Synthetic prerequisite',
|
|
requiredAccess: 'Synthetic role',
|
|
nextStep: 'Synthetic next step',
|
|
acceptanceEvidence: 'Synthetic acceptance evidence',
|
|
comment: 'Synthetic comment',
|
|
markers: ['question-needed', 'quick-win'],
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
revision
|
|
};
|
|
}
|
|
|
|
describe('LocalAssessorRepository contract', () => {
|
|
it('stores immutable device-finding and server-derived Defender device snapshots', async () => {
|
|
const repository = await repositoryWithProject();
|
|
const run = defenderRun(1);
|
|
await commitRun(repository, run, defenderImportData(run, [
|
|
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-cve-1', cve: 'CVE-2026-1001', devices: ['DEV-A', 'DEV-B'] },
|
|
{ definitionId: 'definition-cve-2', occurrenceId: 'occurrence-cve-2', cve: 'CVE-2026-1002', devices: ['DEV-A'] }
|
|
]));
|
|
|
|
const pair = await repository.recordScopedRiskAcceptance(project.id, {
|
|
target: { kind: 'device-finding', occurrenceId: 'occurrence-cve-1', deviceKey: 'device-id:dev-a' },
|
|
command: riskCommand('accept'), expectedRevision: 0
|
|
});
|
|
expect(pair.target).toMatchObject({ kind: 'device-finding', device: { key: 'device-id:dev-a' }, members: [{ findingDefinitionId: 'definition-cve-1', cve: 'CVE-2026-1001' }] });
|
|
expect(pair.target.members[0].capturedOccurrences).toEqual([expect.objectContaining({ occurrenceId: 'occurrence-cve-1', scanRunId: run.id })]);
|
|
|
|
const snapshot = await repository.recordScopedRiskAcceptance(project.id, {
|
|
target: { kind: 'device-snapshot', scanRunIds: [run.id], deviceKey: 'device-id:dev-a' },
|
|
command: riskCommand('accept'), expectedRevision: 0
|
|
});
|
|
expect(snapshot.target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
|
|
await expect(repository.recordScopedRiskAcceptance(project.id, {
|
|
acceptanceId: snapshot.id, command: riskCommand('draft'), expectedRevision: 0
|
|
})).rejects.toBeInstanceOf(RepositoryConflictError);
|
|
await expect(repository.recordScopedRiskAcceptance(project.id, {
|
|
acceptanceId: snapshot.id,
|
|
target: { kind: 'device-snapshot', scanRunIds: [run.id], deviceKey: 'device-id:dev-b' },
|
|
command: riskCommand('draft'), expectedRevision: snapshot.revision
|
|
})).rejects.toBeInstanceOf(RepositoryValidationError);
|
|
await expect(repository.recordScopedRiskAcceptance(project.id, {
|
|
target: { kind: 'device-finding', occurrenceId: 'occurrence-cve-1', deviceKey: 'device-id:missing' },
|
|
command: riskCommand('accept'), expectedRevision: 0
|
|
})).rejects.toBeInstanceOf(RepositoryValidationError);
|
|
await expect(repository.recordScopedRiskAcceptance('other-project', {
|
|
target: { kind: 'device-finding', occurrenceId: 'occurrence-cve-1', deviceKey: 'device-id:dev-a' },
|
|
command: riskCommand('accept'), expectedRevision: 0
|
|
})).rejects.toThrow();
|
|
|
|
const revoked = await repository.recordScopedRiskAcceptance(project.id, {
|
|
acceptanceId: snapshot.id, command: riskCommand('revoke'), expectedRevision: snapshot.revision
|
|
});
|
|
expect(revoked).toMatchObject({ id: snapshot.id, revision: 2 });
|
|
expect(revoked.revisions.map(item => item.action)).toEqual(['accept', 'revoke']);
|
|
expect((await repository.listScopedRiskAcceptances(project.id))).toHaveLength(2);
|
|
expect((await repository.exportProject(project.id)).scopedRiskAcceptances).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ id: pair.id }), expect.objectContaining({ id: snapshot.id, revision: 2 })
|
|
]));
|
|
});
|
|
|
|
it('rejects accepting a device absent from the selected same-CVE occurrence', async () => {
|
|
const repository = await repositoryWithProject();
|
|
const run = defenderRun(1);
|
|
const data = defenderImportData(run, [
|
|
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-a', cve: 'CVE-2026-1001', devices: ['DEV-A'] },
|
|
{ definitionId: 'definition-cve-1', occurrenceId: 'occurrence-b', cve: 'CVE-2026-1001', devices: ['DEV-B'] }
|
|
]);
|
|
data.findingDefinitions = data.findingDefinitions.slice(0, 1);
|
|
await commitRun(repository, run, data);
|
|
|
|
await expect(repository.recordScopedRiskAcceptance(project.id, {
|
|
target: { kind: 'device-finding', occurrenceId: 'occurrence-a', deviceKey: 'device-id:dev-b' },
|
|
command: riskCommand('accept'), expectedRevision: 0
|
|
})).rejects.toBeInstanceOf(RepositoryValidationError);
|
|
const accepted = await repository.recordScopedRiskAcceptance(project.id, {
|
|
target: { kind: 'device-finding', occurrenceId: 'occurrence-b', deviceKey: 'device-id:dev-b' },
|
|
command: riskCommand('accept'), expectedRevision: 0
|
|
});
|
|
expect(accepted.target.members[0].capturedOccurrences.map(item => item.occurrenceId)).toEqual(['occurrence-b']);
|
|
});
|
|
|
|
it('stores risk decisions atomically with optimistic concurrency, export and reimport history', async () => {
|
|
const repository = await repositoryWithProject();
|
|
await commitRun(repository, scanRun(1));
|
|
const source = (await repository.loadAssessmentModel(assessment.id))!.findingOccurrences;
|
|
const accepted = await repository.recordRiskAcceptance(project.id, 'occurrence-run-1', riskCommand(), 0);
|
|
expect(accepted.riskAcceptance?.revisions).toHaveLength(1);
|
|
await expect(repository.recordRiskAcceptance(project.id, 'occurrence-run-1', riskCommand('draft'), 0)).rejects.toBeInstanceOf(RepositoryConflictError);
|
|
await expect(repository.recordRiskAcceptance('other-project', 'occurrence-run-1', riskCommand(), 1)).rejects.toThrow();
|
|
await expect(repository.saveReview({ ...accepted, riskAcceptance: undefined, revision: 2 }, 1)).rejects.toThrow();
|
|
expect((await repository.loadAssessmentModel(assessment.id))!.findingOccurrences).toEqual(source);
|
|
expect((await repository.exportProject(project.id)).reviews[0].riskAcceptance).toEqual(accepted.riskAcceptance);
|
|
const replacement = { ...scanRun(2), importedAt: '2026-07-18T08:00:00.000Z' };
|
|
await commitRun(repository, replacement, importData(replacement, { occurrenceId: 'occurrence-run-2' }));
|
|
await repository.adoptScanRunReplacement(project.id, 'run-1', 'run-2', replacement.importedAt);
|
|
const transferred = (await repository.listReviews(project.id)).find(review => review.target.kind === 'occurrence' && review.target.occurrenceId === 'occurrence-run-2')!;
|
|
expect(transferred.riskAcceptance).toEqual(accepted.riskAcceptance);
|
|
expect(riskAcceptanceState(transferred.riskAcceptance, 'occurrence-run-2')).toBe('Review required');
|
|
});
|
|
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.saveReview({ ...review(1, 'accepted'), workflowLabel: 'Accepted Risk' }, 0);
|
|
|
|
await expect(repository.adoptScanRunReplacement(project.id, original.id, replacement.id, '2026-07-17T09:01:00.000Z'))
|
|
.resolves.toEqual({ transferred: 1, retainedByStableIdentity: 0, unresolved: 0 });
|
|
|
|
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);
|
|
expect(exported.reviews).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
target: { kind: 'occurrence', occurrenceId: 'occurrence-run-2' },
|
|
workflowStatus: 'accepted', workflowLabel: 'Accepted Risk', owner: 'Synthetic Security Team',
|
|
dueAt: '2026-10-15', comment: 'Synthetic comment', acceptanceEvidence: 'Synthetic acceptance evidence',
|
|
transferredFromReviewId: 'review-1'
|
|
})
|
|
]));
|
|
expect(exported.importFiles.find(file => file.scanRunId === replacement.id)?.reimportReviewTransfer)
|
|
.toEqual({ replacesScanRunId: original.id, transferred: 1, retainedByStableIdentity: 0, unresolved: 0 });
|
|
});
|
|
|
|
it('moves a canonical import behind a superseded run that still holds the claimed sequence', async () => {
|
|
// Regression 2026-09-30: the server derives the next sequence from a model without superseded runs,
|
|
// so a new import claimed a sequence a superseded run still held and PostgreSQL rejected it.
|
|
const repository = await repositoryWithProject();
|
|
await commitRun(repository, scanRun(1));
|
|
const replacement = scanRun(2);
|
|
await repository.commitCanonicalImport({
|
|
sessionId: 'canonical-replacement', replacesScanRunId: 'run-1',
|
|
model: { schemaVersion: 1, project, assessment, scanRuns: [replacement], reviews: [], ...importData(replacement, { occurrenceId: 'occurrence-run-2' }) },
|
|
fingerprints: [{ sha256: 'a'.repeat(64), fileName: 'run-2.csv', sizeBytes: 100, lastModified: 0 }]
|
|
});
|
|
const next = { ...scanRun(3), sequence: 1 };
|
|
const nextData = importData(next, { occurrenceId: 'occurrence-run-3', sha256: 'c'.repeat(64) });
|
|
|
|
await repository.commitCanonicalImport({
|
|
sessionId: 'canonical-after-superseded',
|
|
model: { schemaVersion: 1, project, assessment, scanRuns: [next], reviews: [], ...nextData },
|
|
fingerprints: [{ sha256: 'c'.repeat(64), fileName: 'run-3.csv', sizeBytes: 100, lastModified: 0 }]
|
|
});
|
|
const exported = await repository.exportProject(project.id);
|
|
expect(exported.scanRuns.map(run => [run.id, run.sequence]).sort()).toEqual([['run-1', 1], ['run-2', 2], ['run-3', 3]]);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|