185 lines
9.8 KiB
TypeScript
185 lines
9.8 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 { 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: [] });
|
|
|
|
|
|
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);
|
|
});
|