Assessor_2.0/tests/postgres/policy-analysis.integration...

198 lines
11 KiB
TypeScript

import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Pool } from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { FileSystemBackendArtifactStore } from '../../server/artifacts/backend-artifact-store';
import { GpoBackupJobProcessor } from '../../server/gpo/gpo-backup-job-processor';
import { GpoBackupManagementService } from '../../server/gpo/gpo-backup-management-service';
import { GpoBackupSubmissionService } from '../../server/gpo/gpo-backup-submission-service';
import { GpoPolicySnapshotService } from '../../server/gpo/gpo-policy-snapshot-service';
import { GpoPolicyEvaluationService } from '../../server/gpo/gpo-policy-evaluation-service';
import { GpoPolicyFindingService } from '../../server/gpo/gpo-policy-finding-service';
import { directGpoBackupWorkerExecutor, directGpoInventoryExecutor } from '../../server/gpo/gpo-inventory-executor';
import { MicrosoftSecurityBaselineService } from '../../server/gpo/microsoft-security-baseline-service';
import { runDatabaseMigrations } from '../../server/postgres/migration-runner';
import { PostgresAssessorRepository } from '../../server/postgres/postgres-assessor-repository';
import { syntheticGpoBackupZip } from '../server/gpo-synthetic-fixture';
import { syntheticMicrosoftBaselineZip } from '../server/microsoft-baseline-synthetic-fixture';
const connectionString = process.env.ASSESSOR_TEST_DATABASE_URL;
const describePostgres = connectionString ? describe : describe.skip;
const timestamp = '2026-08-04T10:00:00.000Z';
describePostgres('PostgreSQL policy analysis parity', () => {
let pool: Pool;
let repository: PostgresAssessorRepository;
let directory: string;
let store: FileSystemBackendArtifactStore;
beforeAll(async () => {
pool = new Pool({ connectionString, max: 2, connectionTimeoutMillis: 5_000 });
await runDatabaseMigrations(pool);
repository = await PostgresAssessorRepository.create(pool);
directory = await mkdtemp(join(tmpdir(), 'assessor-policy-analysis-pg-'));
store = new FileSystemBackendArtifactStore(directory);
}, 30_000);
afterAll(async () => {
await pool?.end();
await rm(directory, { recursive: true, force: true });
});
it('persists GPO identities and baseline catalog settings through the same repository contract as local mode', async () => {
const project = { id: 'pg-policy-analysis-project', name: 'Synthetic policy analysis', createdAt: timestamp, updatedAt: timestamp, attributes: {} };
await pool.query('DELETE FROM assessor_projects WHERE id = $1', [project.id]);
await repository.saveProject(project);
const submitted = await new GpoBackupSubmissionService(
repository, store, directGpoInventoryExecutor, 1024 * 1024
).submit({ projectId: project.id, fileName: 'synthetic.zip', bytes: syntheticGpoBackupZip(), receivedAt: timestamp });
const processor = new GpoBackupJobProcessor(repository, store, directGpoBackupWorkerExecutor, {
maximumArtifactBytes: 1024 * 1024,
now: () => new Date(timestamp)
});
await expect(processor.processNext('pg-policy-worker')).resolves.toMatchObject({ status: 'succeeded' });
const policyAnalyses = await repository.listGpoPolicyAnalyses(project.id);
expect(policyAnalyses).toHaveLength(1);
expect(policyAnalyses[0].run.resolvedCount).toBeGreaterThan(0);
const resolutionCount = await pool.query<{ count: string }>(
'SELECT count(*)::text AS count FROM assessor_policy_identity_resolutions WHERE project_id = $1', [project.id]
);
expect(Number(resolutionCount.rows[0].count)).toBe(policyAnalyses[0].run.observationCount);
const snapshotService = new GpoPolicySnapshotService(repository, { now: () => new Date(timestamp) });
const profile = await snapshotService.createEnvironmentProfile(project.id, {
name: 'Synthetic Windows Server environment',
contexts: [{
id: 'windows-server-2022',
kind: 'operating-system',
productFamily: 'Windows Server',
productVersion: '2022',
role: 'Member Server',
scope: 'computer'
}]
});
const aggregation = await snapshotService.aggregate(project.id, {
environmentProfileId: profile.id,
sourceAnalysisRunIds: [policyAnalyses[0].run.id]
});
expect(aggregation.run).toMatchObject({
status: 'partial',
permitsFindingGeneration: false
});
expect(aggregation.states.length).toBeGreaterThan(0);
await expect(snapshotService.aggregate(project.id, {
environmentProfileId: profile.id,
sourceAnalysisRunIds: [policyAnalyses[0].run.id]
})).resolves.toEqual(aggregation);
const profileCount = await pool.query<{ count: string }>(
'SELECT count(*)::text AS count FROM assessor_environment_profiles WHERE project_id = $1', [project.id]
);
const stateCount = await pool.query<{ count: string }>(
'SELECT count(*)::text AS count FROM assessor_policy_snapshot_states WHERE project_id = $1', [project.id]
);
expect(Number(profileCount.rows[0].count)).toBe(1);
expect(Number(stateCount.rows[0].count)).toBe(aggregation.states.length);
const baselineService = new MicrosoftSecurityBaselineService(
repository, store, 1024 * 1024, directGpoBackupWorkerExecutor
);
const baseline = await baselineService.submit({
fileName: 'Windows Server 2022 Security Baseline.zip',
bytes: syntheticMicrosoftBaselineZip(),
receivedAt: timestamp
});
const analyses = await repository.listMicrosoftSecurityBaselineAnalyses(baseline.id);
expect(analyses).toHaveLength(1);
expect(analyses[0].analysis.settings).toHaveLength(1);
const settings = await pool.query<{ count: string }>(
'SELECT count(*)::text AS count FROM assessor_microsoft_security_baseline_settings WHERE baseline_id = $1', [baseline.id]
);
expect(Number(settings.rows[0].count)).toBe(1);
const evaluationService = new GpoPolicyEvaluationService(repository, { now: () => new Date(timestamp) });
const evaluation = await evaluationService.evaluate(project.id, {
aggregationRunId: aggregation.run.id,
baselineAnalysisIds: [analyses[0].analysis.id]
});
expect(evaluation.run).toMatchObject({
status: 'partial',
deviationCount: 0,
projectedFindingCount: 0,
ruleCatalog: { id: 'policy-evaluation-rule-catalog-1.0.0', status: 'released' },
});
expect(evaluation.results).toEqual(expect.arrayContaining([
expect.objectContaining({
classification: 'review_required',
baselineState: 'catalog-gap',
findingEligibility: 'ineligible',
effectiveState: 'unknown'
})
]));
await expect(evaluationService.evaluate(project.id, {
aggregationRunId: aggregation.run.id,
baselineAnalysisIds: [analyses[0].analysis.id]
})).resolves.toEqual(evaluation);
const evaluationCounts = await pool.query<{ runs: string; results: string; evidence: string }>(
`SELECT
(SELECT count(*)::text FROM assessor_policy_evaluation_runs WHERE project_id = $1) AS runs,
(SELECT count(*)::text FROM assessor_policy_evaluation_results r JOIN assessor_policy_evaluation_runs e ON e.id = r.evaluation_run_id WHERE e.project_id = $1) AS results,
(SELECT count(*)::text FROM assessor_policy_evaluation_evidence v JOIN assessor_policy_evaluation_results r ON r.id = v.evaluation_result_id JOIN assessor_policy_evaluation_runs e ON e.id = r.evaluation_run_id WHERE e.project_id = $1) AS evidence`,
[project.id]
);
expect(Number(evaluationCounts.rows[0].runs)).toBe(1);
expect(Number(evaluationCounts.rows[0].results)).toBe(evaluation.results.length);
expect(Number(evaluationCounts.rows[0].evidence)).toBe(evaluation.evidence.length);
const relationalEvaluation = await pool.query<{
rule_catalog_id: string;
rule_catalog_version: string;
classification: string;
baseline_state: string;
risk_rule_count: number;
}>(`SELECT e.rule_catalog_id, e.rule_catalog_version, r.classification, r.baseline_state,
cardinality(r.risk_rule_ids) AS risk_rule_count
FROM assessor_policy_evaluation_runs e
JOIN assessor_policy_evaluation_results r ON r.evaluation_run_id = e.id
WHERE e.project_id = $1`, [project.id]);
expect(relationalEvaluation.rows).toEqual(expect.arrayContaining([expect.objectContaining({
rule_catalog_id: 'policy-evaluation-rule-catalog-1.0.0', rule_catalog_version: '1.0.0',
classification: 'review_required', baseline_state: 'catalog-gap', risk_rule_count: 0
})]));
const findingService = new GpoPolicyFindingService(repository, { now: () => new Date(timestamp) });
const findingRun = await findingService.create(project.id, { evaluationRunId: evaluation.run.id });
expect(findingRun).toMatchObject({
run: { status: 'complete', eligibleResultCount: 0, findingCount: 0, rejectedResultCount: 0 },
occurrences: [],
rejections: []
});
await expect(findingService.create(project.id, { evaluationRunId: evaluation.run.id }))
.resolves.toEqual(findingRun);
const factoryCounts = await pool.query<{ runs: string; occurrences: string }>(
`SELECT
(SELECT count(*)::text FROM assessor_policy_finding_runs WHERE project_id = $1) AS runs,
(SELECT count(*)::text FROM assessor_policy_finding_occurrences o
JOIN assessor_policy_finding_runs r ON r.id = o.factory_run_id WHERE r.project_id = $1) AS occurrences`,
[project.id]
);
expect(factoryCounts.rows[0]).toEqual({ runs: '1', occurrences: '0' });
await expect(repository.queryUnifiedResults({ projectId: project.id, sourceKind: ['policy-evaluation'] }))
.resolves.toMatchObject({ total: 0, items: [] });
const management = new GpoBackupManagementService(repository, store, () => new Date(timestamp));
const firstProcessingRun = (await repository.listProcessingRuns(submitted.artifactId))[0];
await management.reprocess(project.id, submitted.artifactId, firstProcessingRun.id);
await expect(processor.processNext('pg-policy-reprocessing-worker')).resolves.toMatchObject({ status: 'succeeded' });
await expect(repository.listProcessingRuns(submitted.artifactId)).resolves.toHaveLength(2);
await expect(management.delete(project.id, submitted.artifactId))
.resolves.toMatchObject({ deleted: true, storageRemoval: 'complete' });
await expect(repository.listGpoPolicyAnalyses(project.id)).resolves.toEqual([]);
await expect(repository.listPolicySnapshotAggregations(project.id)).resolves.toEqual([]);
await expect(repository.listPolicyEvaluations(project.id)).resolves.toEqual([]);
await expect(repository.listPolicyFindingRuns(project.id)).resolves.toEqual([]);
await expect(repository.listEnvironmentProfiles(project.id)).resolves.toEqual([profile]);
await repository.deleteProject(project.id, { projectId: project.id, cascade: true });
await pool.query('DELETE FROM assessor_microsoft_security_baselines WHERE id = $1', [baseline.id]);
expect(submitted.status).toBe('queued');
}, 30_000);
});