Assessor_2.0/tests/postgres/scoped-risk-acceptance.inte...

92 lines
6.1 KiB
TypeScript

import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runDatabaseMigrations } from '../../server/postgres/migration-runner';
import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository';
import type { Assessment, AssessmentProject, ScanRun } from '../../src/domain/canonical-model';
import type { ImportStagingData } from '../../src/persistence/assessor-repository';
import { RepositoryConflictError } from '../../src/persistence/repository-errors';
import { riskCommand } from '../domain/risk-acceptance-fixture';
const connectionString = process.env.ASSESSOR_TEST_DATABASE_URL;
const describePostgres = connectionString ? describe : describe.skip;
const timestamp = '2026-09-21T08:00:00.000Z';
const project: AssessmentProject = { id: 'pg-scoped-risk-project', name: 'Scoped risk integration', createdAt: timestamp, updatedAt: timestamp, attributes: {} };
const assessment: Assessment = { id: 'pg-scoped-risk-assessment', projectId: project.id, name: 'Scoped risk assessment', createdAt: timestamp, updatedAt: timestamp };
const scanRun: ScanRun = { id: 'pg-scoped-risk-run', assessmentId: assessment.id, sequence: 1, label: 'Defender run', tool: { id: 'defender-for-iot-vulnerabilities', name: 'Microsoft Defender for IoT', version: '1.2.0' }, importedAt: timestamp };
const confidence = { level: 'high' as const, score: 1, reasons: ['synthetic'] };
const provenance = { decisionSource: 'source-native' as const, confidence, mappingVersion: '1.0.0' };
function data(): ImportStagingData {
const observations = (cve: string, rowIndex: number) => [{
name: 'Device A', ip: '192.0.2.10', site: 'Synthetic site', sensor: 'sensor-a', cve, severity: 'High', description: 'Synthetic',
additionalSourceFields: { 'Device ID': 'DEV-A' }, source: { tableIndex: 0, rowIndex }
}];
return {
importFiles: [{
id: 'pg-scoped-risk-file', scanRunId: scanRun.id, fileName: 'defender.csv', format: 'CSV', sizeBytes: 100,
importedAt: timestamp, detectedTool: scanRun.tool, detection: confidence,
processedWith: {
parserId: 'csv', parserVersion: '1.0.0', profileId: scanRun.tool.id, profileVersion: '1.2.0',
mappingVersion: '1.0.0', normalizationVersion: '1.1.0', presentationVersion: '1.0.0',
compatibilityKey: 'defender-for-iot-vulnerabilities-v2', changeClass: 'semantic-breaking', reimportRequired: true
},
sourceArtifact: { kind: 'not-retained', reason: 'Synthetic integration test' }, sha256: '9'.repeat(64)
}],
sourceTables: [{ id: 'pg-scoped-risk-table', importFileId: 'pg-scoped-risk-file', name: 'Vulnerabilities', index: 0, rowCount: 2, columnCount: 2, attributes: {} }],
findingDefinitions: ['CVE-2026-1001', 'CVE-2026-1002'].map((cve, index) => ({
id: `pg-scoped-risk-definition-${index + 1}`, canonicalKey: `${scanRun.tool.id}:${cve}`, title: cve,
frameworkReferences: [], createdAt: timestamp, updatedAt: timestamp
})),
findingOccurrences: ['CVE-2026-1001', 'CVE-2026-1002'].map((cve, index) => ({
id: `pg-scoped-risk-occurrence-${index + 1}`, definitionId: `pg-scoped-risk-definition-${index + 1}`,
scanRunId: scanRun.id, importFileId: 'pg-scoped-risk-file', sourceTableId: 'pg-scoped-risk-table',
sourceIdentity: { tool: scanRun.tool, originalRuleId: cve, canonicalRuleId: cve, originalTitle: cve },
sourcePosition: { rowIndex: index + 1 },
originalFields: [{ key: 'source-observations', label: 'Source Observations', columnIndex: 0, value: observations(cve, index + 1) }],
fieldMappings: [], severity: { originalValue: 'High', normalizedValue: 'high' as const, provenance },
status: { originalValue: 'Open', normalizedValue: 'open' as const, provenance }
}))
};
}
describePostgres('PostgreSQL scoped risk acceptance persistence', () => {
let pool: Pool;
let repository: PostgresAssessorRepository;
beforeAll(async () => {
pool = new Pool({ connectionString, max: 2, connectionTimeoutMillis: 5000 });
await runDatabaseMigrations(pool);
await pool.query('DELETE FROM assessor_projects WHERE id=$1', [project.id]);
repository = await PostgresAssessorRepository.create(pool);
}, 30000);
afterAll(async () => {
if (pool) {
await pool.query('DELETE FROM assessor_projects WHERE id=$1', [project.id]);
await pool.end();
}
});
it('persists, conflicts, revokes, exports, and cascades a grouped decision', async () => {
await repository.saveProject(project);
await repository.saveAssessment(assessment);
const session = await repository.beginImport({ sessionId: 'pg-scoped-risk-session', projectId: project.id, assessmentId: assessment.id, scanRun });
await session.stage(data());
await session.commit();
const accepted = await repository.recordScopedRiskAcceptance(project.id, {
target: { kind: 'device-snapshot', scanRunIds: [scanRun.id], deviceKey: 'device-id:dev-a' },
command: riskCommand('accept'), expectedRevision: 0
});
expect(accepted.target.members.map(item => item.cve)).toEqual(['CVE-2026-1001', 'CVE-2026-1002']);
await expect(repository.recordScopedRiskAcceptance(project.id, { acceptanceId: accepted.id, command: riskCommand('draft'), expectedRevision: 0 }))
.rejects.toBeInstanceOf(RepositoryConflictError);
const revoked = await repository.recordScopedRiskAcceptance(project.id, { acceptanceId: accepted.id, command: riskCommand('revoke'), expectedRevision: 1 });
expect(revoked.revisions.map(item => item.action)).toEqual(['accept', 'revoke']);
await expect(repository.listScopedRiskAcceptances(project.id)).resolves.toEqual([expect.objectContaining({ id: accepted.id, revision: 2 })]);
await expect(repository.exportProject(project.id)).resolves.toMatchObject({ scopedRiskAcceptances: [expect.objectContaining({ id: accepted.id })] });
await repository.deleteProject(project.id, { projectId: project.id, cascade: true });
await expect(pool.query('SELECT count(*)::integer AS count FROM assessor_scoped_risk_acceptances WHERE project_id=$1', [project.id]))
.resolves.toMatchObject({ rows: [{ count: 0 }] });
}, 30000);
});