feat(comparison): serve the timeslot comparison through a read-only port

Add the Comparison Engine read port, a Core-owned adapter that loads each
timeslot as Work reads it (scoped scanner runs, rolled-up GPO verdicts, backup
creation time only) and GET /projects/:id/timeslot-comparison with validation,
a concurrent-change guard and an off switch. The API client gains an
engine-free getTimeslotComparison and the activity label.
This commit is contained in:
Binh 2026-10-13 13:42:30 +02:00
parent 3b8cb78717
commit c14396810e
7 changed files with 287 additions and 2 deletions

View File

@ -2,7 +2,7 @@
## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine) ## Comparison Engine: decision and staging (planned 2026-10-13, Phase 8 timeslot comparison as an engine)
Status: **steps 1, 2 and 3a done and verified (2026-10-13), steps 3b and 4 open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher). Status: **steps 1, 2 and 3a done and verified; step 3b partly done (2026-10-13); 3b extension-point wiring and step 4 open**. Explicit user decision 2026-10-13: the timeslot comparison of [TIMESLOT_COMPARISON_PLAN.md](TIMESLOT_COMPARISON_PLAN.md) (P1-P6) becomes a separately switchable **Comparison Engine** that owns its comparison logic. Its folders follow the other engines without an `-engine` suffix: `src/comparison/`, `server/comparison/`, `tests/comparison/`. It reads the Core only through a declared read-only port and never writes, overrides or re-derives a Core verdict, score, review, risk acceptance or Result DB row. All contracts, the acceptance matrix and the conservative rules of the timeslot plan stay binding unchanged (object/scope identity, no `absent = resolved` without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population, UI is a consumer, never its own matcher).
UI decision 2026-10-13 (mockups A and B in the local, git-ignored `artifacts/compare-engine/`; synthetic Timeslot A): entry is a Client Info tab `Compare` with A/B timeslot selection, change counts, Security Domain and source deltas, each count opening Work; Work receives a `Change` facet and change badges, and the Finding Reader a `Compare` section with A and B values side by side. A retained value change of a still deviating GPO setting is shown neutral (`Value changed`), neither better nor worse. Mockup C (better/worse bridge) is not selected. UI decision 2026-10-13 (mockups A and B in the local, git-ignored `artifacts/compare-engine/`; synthetic Timeslot A): entry is a Client Info tab `Compare` with A/B timeslot selection, change counts, Security Domain and source deltas, each count opening Work; Work receives a `Change` facet and change badges, and the Finding Reader a `Compare` section with A and B values side by side. A retained value change of a still deviating GPO setting is shown neutral (`Value changed`), neither better nor worse. Mockup C (better/worse bridge) is not selected.
@ -16,7 +16,9 @@ Steps 1-2 (Core release Governance, Core Tests, Evaluation, Store, Presentation)
Step 3a (engine paths only, no Core release): `src/comparison/timeslot-comparison-engine.ts` (result 1.1.0) separates new from regressed scanner deviations and accepts a timeslot without scanner runs on one side; new `gpo-timeslot-comparison.ts` pairs rolled-up GPO Work verdicts across timeslots; new `timeslot-comparison-report.ts` (`compareTimeslots`, 1.0.0) yields the UI change categories (resolved, unchanged, value-changed, unverified, not-comparable, not-applicable, new, regressed; target-side deviations of a one-sided source are not comparable) and the A/B score basis over comparable sources only. Contract details in TIMESLOT_COMPARISON_PLAN.md P2/P4. Open for 3b: a read port that builds both timeslots' models and GPO Work results (a timeslot can hold several assessments; the GPO backup time comes from `backupCreatedAt`), Security Domain deltas, the backend route and the UI. 15 synthetic engine tests. Verified: import gate 528/528; main suite 2,462 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` tile-radius check); local-only and design-language checks pass; both production builds pass. No product version change: no visible behaviour. Step 3a (engine paths only, no Core release): `src/comparison/timeslot-comparison-engine.ts` (result 1.1.0) separates new from regressed scanner deviations and accepts a timeslot without scanner runs on one side; new `gpo-timeslot-comparison.ts` pairs rolled-up GPO Work verdicts across timeslots; new `timeslot-comparison-report.ts` (`compareTimeslots`, 1.0.0) yields the UI change categories (resolved, unchanged, value-changed, unverified, not-comparable, not-applicable, new, regressed; target-side deviations of a one-sided source are not comparable) and the A/B score basis over comparable sources only. Contract details in TIMESLOT_COMPARISON_PLAN.md P2/P4. Open for 3b: a read port that builds both timeslots' models and GPO Work results (a timeslot can hold several assessments; the GPO backup time comes from `backupCreatedAt`), Security Domain deltas, the backend route and the UI. 15 synthetic engine tests. Verified: import gate 528/528; main suite 2,462 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` tile-radius check); local-only and design-language checks pass; both production builds pass. No product version change: no visible behaviour.
Change log 2026-10-13: recorded the Comparison Engine decision, naming and staging; relocated the Core Phase 8 scan-run comparison and registered the Comparison Engine with its invariance test; step 3a engine comparison of scanners, GPO settings and score basis. Step 3b, first part (Core release Presentation, Platform): engine read port `src/comparison/comparison-read-port.ts`; Platform adapter `server/timeslot-comparison-input.ts` builds each timeslot as Work reads it (scanner models scoped to the selected runs, GPO verdicts through `currentPolicyEvaluationsForWork` and `buildPolicyWorkResults`, GPO observation time only from every contributing backup's `backupCreatedAt`); route `GET /api/v1/projects/:id/timeslot-comparison` (baseline/target assessment and scan-run IDs; 400 for a missing timeslot, a shared scan run or explicit dates in the wrong order, 404 for a foreign assessment, 409 on a concurrent change, 503 when switched off; read-only); `AssessorApiClient.getTimeslotComparison<T>` (engine-free) and the activity label `Comparing timeslots`. Engine UI written and synthetically tested but **parked, not in the repository** (git-ignored `artifacts/compare-engine/parked-ui/`): `ComparisonTab` (A/B selection, B defaults to the open timeslot, A to the one before it, only earlier timeslots as A; Overall Score A/B with a labelled delta over comparable sources; change facts that call `onOpenChange`; source table with comparison basis), `useTimeslotComparison` (timeslots grouped like Data Hub) and its four interaction tests. It cannot live in `src/comparison/`, which `tests/local-only-check.mjs` keeps platform-neutral (no React); like the Context Engine's `src/features/context-layer/`, it needs a registered engine UI path (proposed `src/features/comparison/`, a Governance change to the area map). Security Domain deltas are not in it yet. Not done, needs `Governance, Presentation, Core Tests`: the engine UI path, the Engines popup entry (`tests/components/engines-control.test.tsx` pins the engine list), the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section (each a new pinned extension point in `tests/architecture/core-engine-boundary.test.ts`), and the Data Flow engine diagram (`tests/features/db-structure-flow.test.tsx`). No version change while unwired. Verified: 18 engine tests (3 route tests among them); import gate 528/528; main suite 2,465 passed, 18 optional skipped, 1 failed (the same pre-existing, unrelated `db-structure-layout.test.ts` check); local-only and design-language checks pass; both production builds pass.
Change log 2026-10-13: recorded the Comparison Engine decision, naming and staging; relocated the Core Phase 8 scan-run comparison and registered the Comparison Engine with its invariance test; step 3a engine comparison of scanners, GPO settings and score basis; step 3b read port, timeslot route and API client; Compare tab parked.
## Security finding reading hierarchy (2026-10-11, Phase 7 Presentation) ## Security finding reading hierarchy (2026-10-11, Phase 7 Presentation)

View File

@ -68,6 +68,9 @@ import { ASSESSMENT_CONTEXT_CATALOG } from '../src/assessment-context/context-ca
import { buildWorkPackages, policyFindingKey, scannerFindingKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION, type RemediationFinding } from '../src/remediation/remediation-engine'; import { buildWorkPackages, policyFindingKey, scannerFindingKey, REMEDIATION_ENGINE_VERSION, REMEDIATION_CATALOG_VERSION, type RemediationFinding } from '../src/remediation/remediation-engine';
import { remediationReadPortFrom, type RemediationReadPort } from '../src/remediation/remediation-read-port'; import { remediationReadPortFrom, type RemediationReadPort } from '../src/remediation/remediation-read-port';
import { MemoryRemediationStore, type RemediationStore } from './remediation/remediation-store'; import { MemoryRemediationStore, type RemediationStore } from './remediation/remediation-store';
import { comparisonReadPortFrom, type ComparisonReadPort } from '../src/comparison/comparison-read-port';
import { compareTimeslots } from '../src/comparison/timeslot-comparison-report';
import { loadTimeslotComparisonInput, type TimeslotSelection } from './timeslot-comparison-input';
import { loadCanonicalProjectWorkspace } from '../src/features/project-import/load-canonical-project-workspace'; import { loadCanonicalProjectWorkspace } from '../src/features/project-import/load-canonical-project-workspace';
import { summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from '../src/features/project-import/datahub-scan-analysis'; import { summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from '../src/features/project-import/datahub-scan-analysis';
import { profileForSheet } from '../src/features/project-import/workspace-profile'; import { profileForSheet } from '../src/features/project-import/workspace-profile';
@ -160,6 +163,8 @@ export interface AuditEvent {
export interface AssessorHttpServerOptions { export interface AssessorHttpServerOptions {
remediationStore?: RemediationStore | null; remediationStore?: RemediationStore | null;
remediationPort?: RemediationReadPort | null; remediationPort?: RemediationReadPort | null;
/** Comparison Engine: declared read-only port; null switches the comparison route off. */
comparisonPort?: ComparisonReadPort | null;
artifactStore?: BackendArtifactStore; artifactStore?: BackendArtifactStore;
mitreCatalogService?: MitreCatalogService; mitreCatalogService?: MitreCatalogService;
repository: AssessorRepository; repository: AssessorRepository;
@ -462,6 +467,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
const repository = options.repository; const repository = options.repository;
const remediationStore = options.remediationStore === undefined ? new MemoryRemediationStore() : options.remediationStore; const remediationStore = options.remediationStore === undefined ? new MemoryRemediationStore() : options.remediationStore;
const remediationPort = options.remediationPort === undefined ? remediationReadPortFrom(repository, async () => repository.revision) : options.remediationPort; const remediationPort = options.remediationPort === undefined ? remediationReadPortFrom(repository, async () => repository.revision) : options.remediationPort;
const comparisonPort = options.comparisonPort === undefined ? comparisonReadPortFrom(repository, async () => repository.revision) : options.comparisonPort;
const clientInformation = clientInformationReadPortFrom(repository, const clientInformation = clientInformationReadPortFrom(repository,
() => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision)); () => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision));
const reportPort = options.reportPort === undefined const reportPort = options.reportPort === undefined
@ -1169,6 +1175,52 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se
await remediationStore.save(key, result); await remediationStore.save(key, result);
status = 200; sendJson(response, status, { status: 'ready', result }, requestId); return; status = 200; sendJson(response, status, { status: 'ready', result }, requestId); return;
} }
if (segments[0] === 'projects' && segments[1] && segments[2] === 'timeslot-comparison' && segments.length === 3 && method === 'GET') {
// Comparison Engine: explicit A/B comparison of two timeslots through its declared read-only port.
if (!comparisonPort) throw new HttpError(503, 'comparison_unavailable', 'Comparison Engine is not available.');
const projectId = segments[1];
if (!await comparisonPort.getProject(projectId)) throw new RepositoryNotFoundError(`Projekt ${projectId} ist nicht vorhanden.`);
const selection = (side: 'baseline' | 'target'): TimeslotSelection => ({
assessmentIds: [...new Set(url.searchParams.getAll(`${side}AssessmentIds`))].sort(),
scanRunIds: [...new Set(url.searchParams.getAll(`${side}ScanRunIds`))].sort()
});
const baseline = selection('baseline');
const target = selection('target');
if (!baseline.assessmentIds.length || !target.assessmentIds.length) {
throw new HttpError(400, 'missing_timeslot_scope', 'Both timeslots require at least one assessment ID.');
}
if (baseline.scanRunIds.some(id => target.scanRunIds.includes(id))) {
throw new HttpError(400, 'timeslot_overlap', 'The two timeslots must not share a scan run.');
}
const revision = await comparisonPort.currentRevision();
const reader = {
listAssessments: (id: string) => comparisonPort.listAssessments(id),
loadAssessmentModel: (id: string) => comparisonPort.loadAssessmentModel(id),
queryUnifiedResults: (query?: UnifiedResultQuery) => comparisonPort.queryUnifiedResults(query),
listPolicyEvaluations: (id: string) => comparisonPort.listPolicyEvaluations(id),
listGpoBackupInstances: (id: string) => comparisonPort.listGpoBackupInstances(id),
...(options.gpoPolicyAnalysisOverviewService ? { getPolicyAnalysisOverview: (id: string) => options.gpoPolicyAnalysisOverviewService!.get(id) } : {})
};
const [a, b] = await Promise.all([loadTimeslotComparisonInput(reader, projectId, baseline), loadTimeslotComparisonInput(reader, projectId, target)]);
// A must lie before B where both state an explicit assessment date; import times are no order.
// Without dates the engine's newer-observation rules still block every progress claim.
const datedTimes = (items: typeof a.assessments, other: string[]) => items.filter(item => !other.includes(item.id))
.flatMap(item => item.effectiveAt && Number.isFinite(Date.parse(item.effectiveAt)) ? [Date.parse(item.effectiveAt)] : []);
const before = datedTimes(a.assessments, target.assessmentIds);
const after = datedTimes(b.assessments, baseline.assessmentIds);
if (before.length && after.length && Math.max(...before) >= Math.min(...after)) {
throw new HttpError(400, 'timeslot_order_invalid', 'Timeslot A must lie before timeslot B.');
}
const report = compareTimeslots({
projectId, models: [...a.models, ...b.models], baselineRunIds: a.scanRunIds, targetRunIds: b.scanRunIds,
gpo: {
baseline: { results: a.gpoResults, ...(a.gpoObservedAt ? { observedAt: a.gpoObservedAt } : {}) },
target: { results: b.gpoResults, ...(b.gpoObservedAt ? { observedAt: b.gpoObservedAt } : {}) }
}
});
if (await comparisonPort.currentRevision() !== revision) throw new HttpError(409, 'comparison_state_changed', 'The data changed while comparing. Reload the timeslots.');
status = 200; sendJson(response, status, report, requestId); return;
}
if (segments[0] === 'projects' && segments[1] && segments[2] === 'assessment-context' && segments.length === 3 && method === 'GET') { if (segments[0] === 'projects' && segments[1] && segments[2] === 'assessment-context' && segments.length === 3 && method === 'GET') {
// Context engine (Phase 13E): read-only second reading through the same read-only port as reports. // Context engine (Phase 13E): read-only second reading through the same read-only port as reports.
const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort(); const assessmentIds = [...new Set(url.searchParams.getAll('assessmentIds'))].sort();

View File

@ -0,0 +1,83 @@
import type { Assessment, CanonicalAssessmentModel } from '../src/domain/canonical-model';
import type { StoredGpoBackupInstance } from '../src/domain/gpo-analysis-persistence';
import type { StoredPolicyEvaluation } from '../src/domain/policy-evaluation';
import type { PolicyAnalysisOverview } from '../src/domain/policy-analysis-overview';
import type { UnifiedResultQuery, UnifiedResultQueryResult } from '../src/domain/unified-result';
import { queryAllUnifiedResults } from '../src/exports/unified-result-jsonl';
import { buildPolicyWorkResults, type PolicyWorkResult } from '../src/evaluation/policy-work-results';
import { canonicalModelForScanRuns, currentPolicyEvaluationsForWork } from '../src/features/project-import/load-canonical-project-workspace';
import { RepositoryNotFoundError } from '../src/persistence/repository-errors';
/** One timeslot as Data Hub groups it: its assessments and the scanner runs selected inside them. */
export interface TimeslotSelection {
assessmentIds: string[];
scanRunIds: string[];
}
export interface TimeslotInputReader {
listAssessments(projectId: string): Promise<Assessment[]>;
loadAssessmentModel(assessmentId: string): Promise<CanonicalAssessmentModel | null>;
queryUnifiedResults(query?: UnifiedResultQuery): Promise<UnifiedResultQueryResult>;
listPolicyEvaluations(projectId: string): Promise<StoredPolicyEvaluation[]>;
listGpoBackupInstances(projectId: string): Promise<StoredGpoBackupInstance[]>;
getPolicyAnalysisOverview?(projectId: string): Promise<Pick<PolicyAnalysisOverview, 'current'>>;
}
export interface TimeslotComparisonInput {
assessments: Assessment[];
models: CanonicalAssessmentModel[];
scanRunIds: string[];
gpoResults: PolicyWorkResult[];
/** Latest GPO backup creation time; undefined unless every contributing backup states one. */
gpoObservedAt?: string;
}
/**
* Core-owned adapter: builds one timeslot exactly as Work reads it (scanner models scoped to the
* selected runs, GPO settings through the same current-evaluation selection and Work verdict
* builder). Only the resulting records reach the Comparison Engine. Import times are never used as
* observation times.
*/
export async function loadTimeslotComparisonInput(
reader: TimeslotInputReader,
projectId: string,
selection: TimeslotSelection
): Promise<TimeslotComparisonInput> {
const projectAssessments = await reader.listAssessments(projectId);
const assessments = selection.assessmentIds.map(id => {
const assessment = projectAssessments.find(item => item.id === id);
if (!assessment) throw new RepositoryNotFoundError(`Assessment ${id} is not in this project.`);
return assessment;
});
const loaded = await Promise.all(assessments.map(async assessment => {
const model = await reader.loadAssessmentModel(assessment.id);
if (!model || model.project.id !== projectId) throw new RepositoryNotFoundError(`Assessment ${assessment.id} is not in this project.`);
return model;
}));
const models = loaded.map(model => canonicalModelForScanRuns(model, selection.scanRunIds))
.filter((model): model is CanonicalAssessmentModel => model !== null);
const availableRuns = new Set(models.flatMap(model => model.scanRuns.map(run => run.id)));
const missingRun = selection.scanRunIds.find(id => !availableRuns.has(id));
if (missingRun) throw new RepositoryNotFoundError(`Scan run ${missingRun} is not in the selected assessments.`);
const observations = await queryAllUnifiedResults(query => reader.queryUnifiedResults(query), {
audience: 'internal-analysis', projectId, assessmentIds: selection.assessmentIds, sourceKind: ['policy-observation']
});
let gpoResults: PolicyWorkResult[] = [];
let gpoObservedAt: string | undefined;
if (observations.length) {
const [evaluations, overview, backups] = await Promise.all([
reader.listPolicyEvaluations(projectId),
reader.getPolicyAnalysisOverview?.(projectId),
reader.listGpoBackupInstances(projectId)
]);
gpoResults = buildPolicyWorkResults(observations, currentPolicyEvaluationsForWork(evaluations,
new Set(observations.map(item => item.sourceResultId)), overview ? new Set(overview.current.evaluationRunIds) : undefined));
const backupIds = new Set(observations.flatMap(item => item.policyBackupInstanceId ?? []));
const times = backups.filter(item => backupIds.has(item.instance.id)).map(item => item.instance.backupCreatedAt);
if (backupIds.size && times.length === backupIds.size && times.every(time => time && Number.isFinite(Date.parse(time)))) {
gpoObservedAt = new Date(Math.max(...times.map(time => Date.parse(time!)))).toISOString();
}
}
return { assessments, models, scanRunIds: [...selection.scanRunIds], gpoResults, ...(gpoObservedAt ? { gpoObservedAt } : {}) };
}

View File

@ -205,6 +205,17 @@ export class AssessorApiClient {
return this.request('/projects'); return this.request('/projects');
} }
/** Comparison Engine route. The engine owns and types its report; the Core client stays engine-free. */
getTimeslotComparison<T>(projectId: string, baseline: { assessmentIds: string[]; scanRunIds: string[] },
target: { assessmentIds: string[]; scanRunIds: string[] }): Promise<T> {
const query = new URLSearchParams();
baseline.assessmentIds.forEach(id => query.append('baselineAssessmentIds', id));
baseline.scanRunIds.forEach(id => query.append('baselineScanRunIds', id));
target.assessmentIds.forEach(id => query.append('targetAssessmentIds', id));
target.scanRunIds.forEach(id => query.append('targetScanRunIds', id));
return this.request(`/projects/${encodeURIComponent(projectId)}/timeslot-comparison?${query.toString()}`);
}
getWorkPackages(projectId: string, assessmentIds: string[], scanRunIds: string[] = []): Promise<import('./assessor-api-contract').WorkPackagesResponse> { getWorkPackages(projectId: string, assessmentIds: string[], scanRunIds: string[] = []): Promise<import('./assessor-api-contract').WorkPackagesResponse> {
const query = new URLSearchParams(); const query = new URLSearchParams();
assessmentIds.forEach(id => query.append('assessmentIds', id)); assessmentIds.forEach(id => query.append('assessmentIds', id));

View File

@ -5,6 +5,7 @@ export function requestActivity(path: string, method = 'GET'): { area: string; l
if (route.includes('/work-packages')) return { area: 'Remediation Engine', label: 'Preparing work packages' }; if (route.includes('/work-packages')) return { area: 'Remediation Engine', label: 'Preparing work packages' };
if (route.includes('/assessment-context')) return { area: 'Context Engine', label: 'Loading and calculating assessment context' }; if (route.includes('/assessment-context')) return { area: 'Context Engine', label: 'Loading and calculating assessment context' };
if (route.includes('/report-runs')) return { area: 'Report Engine', label: 'Generating assessment report' }; if (route.includes('/report-runs')) return { area: 'Report Engine', label: 'Generating assessment report' };
if (route.includes('/timeslot-comparison')) return { area: 'Comparison Engine', label: 'Comparing timeslots' };
const compliance = complianceActivity(route, method); const compliance = complianceActivity(route, method);
if (compliance) return { area: 'Compliance Engine', label: compliance }; if (compliance) return { area: 'Compliance Engine', label: compliance };
if (route.includes('/mitre-attack')) return { area: 'Core Engine', label: route.endsWith('/source') ? 'Downloading MITRE source' : route.endsWith('/refresh') ? 'Updating MITRE catalog' : 'Loading MITRE catalog' }; if (route.includes('/mitre-attack')) return { area: 'Core Engine', label: route.endsWith('/source') ? 'Downloading MITRE source' : route.endsWith('/refresh') ? 'Updating MITRE catalog' : 'Loading MITRE catalog' };

View File

@ -0,0 +1,18 @@
import type { AssessorRepository } from '../persistence/assessor-repository';
/** Declared read-only port of the Comparison Engine: it reads the Core and never writes to it. */
export type ComparisonReadMethods = Pick<AssessorRepository, 'getProject' | 'listAssessments' | 'loadAssessmentModel'
| 'queryUnifiedResults' | 'listPolicyEvaluations' | 'listGpoBackupInstances'>;
export interface ComparisonReadPort extends ComparisonReadMethods { currentRevision(): Promise<number> }
export function comparisonReadPortFrom(repository: ComparisonReadMethods, revision: () => Promise<number>): ComparisonReadPort {
return Object.freeze({
currentRevision: revision,
getProject: repository.getProject.bind(repository),
listAssessments: repository.listAssessments.bind(repository),
loadAssessmentModel: repository.loadAssessmentModel.bind(repository),
queryUnifiedResults: repository.queryUnifiedResults.bind(repository),
listPolicyEvaluations: repository.listPolicyEvaluations.bind(repository),
listGpoBackupInstances: repository.listGpoBackupInstances.bind(repository)
});
}

View File

@ -0,0 +1,118 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import type { CanonicalFindingStatus, FindingOccurrence, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model';
import type { EnvironmentObservation } from '../../src/domain/environment-context';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report';
// The Comparison Engine route reads two synthetic timeslots through its read-only port.
const stamp = '2026-01-01T08:00:00.000Z';
const tool = { id: 'cis-cat', name: 'CIS-CAT Pro' };
const provenance = { decisionSource: 'source-native' as const, confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] }, mappingVersion: '1' };
let directory: string;
let server: Server | undefined;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-comparison-http-'));
await writeFile(join(directory, 'index.html'), '<main>synthetic app</main>');
});
afterEach(async () => {
await new Promise(resolve => server ? server.close(resolve) : resolve(undefined));
server = undefined;
await rm(directory, { recursive: true, force: true });
});
function importFile(runId: string, importedAt: string): ImportFileRecord {
return {
id: `file-${runId}`, scanRunId: runId, fileName: `${runId}.html`, format: 'HTML', sizeBytes: 10, importedAt,
detectedTool: tool, detection: { level: 'high', score: 1, reasons: ['synthetic'] },
processedWith: { parserId: 'html', parserVersion: '1', profileId: 'cis-cat', profileVersion: '1.4.0', mappingVersion: '1.3.0',
normalizationVersion: '1.3.0', presentationVersion: '1.3.0', compatibilityKey: 'cis-cat-canonical-v1' },
sourceArtifact: { kind: 'not-retained', reason: 'synthetic' }
};
}
function observation(runId: string, property: string, value: string): EnvironmentObservation {
return {
version: 1, id: `${runId}-${property}`, scanRunId: runId, importFileId: `file-${runId}`, tool, subject: { type: 'host' },
property, label: property, originalValue: value, normalizedValue: value,
valueType: property === 'report.generated_at' ? 'datetime' : 'string', state: 'observed', source: {}, mappingVersion: '1', attributes: {}
};
}
function occurrence(runId: string, ruleId: string, status: CanonicalFindingStatus): FindingOccurrence {
const outcome = status === 'passed' ? 'pass' : 'fail';
return {
id: `${runId}-${ruleId}`, definitionId: `definition-${ruleId}`, scanRunId: runId, importFileId: `file-${runId}`,
sourceIdentity: { tool, canonicalRuleId: ruleId }, sourcePosition: { rowIndex: 1 },
originalFields: [{ key: 'rule', label: 'Rule', columnIndex: 0, value: ruleId }], fieldMappings: [],
severity: { originalValue: 'high', normalizedValue: 'high', provenance },
status: { originalValue: status, normalizedValue: status, provenance },
outcome: { originalValue: outcome, normalizedValue: outcome, provenance }
};
}
async function timeslot(repository: LocalAssessorRepository, name: string, observedAt: string, findings: Array<[string, CanonicalFindingStatus]>) {
const assessmentId = `assessment-${name}`;
await repository.saveAssessment({ id: assessmentId, projectId: 'dummy-project', name, createdAt: observedAt, updatedAt: observedAt, effectiveAt: observedAt });
const scanRun: ScanRun = { id: `run-${name}`, assessmentId, sequence: 1, label: name, tool, importedAt: observedAt };
const session = await repository.beginImport({ sessionId: `session-${name}`, projectId: 'dummy-project', assessmentId, scanRun });
await session.stage({
importFiles: [importFile(scanRun.id, observedAt)],
environmentObservations: [observation(scanRun.id, 'host.name', 'server-a'), observation(scanRun.id, 'benchmark.name', 'benchmark-a'),
observation(scanRun.id, 'report.generated_at', observedAt), observation(scanRun.id, 'report.engine_version', '4.0.0')],
findingDefinitions: findings.map(([ruleId]) => ({ id: `definition-${ruleId}`, canonicalKey: `cis-cat:${ruleId}`, title: ruleId,
frameworkReferences: [], createdAt: stamp, updatedAt: stamp })),
findingOccurrences: findings.map(([ruleId, status]) => occurrence(scanRun.id, ruleId, status))
});
await session.commit();
}
async function start() {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
await timeslot(repository, 'a', '2026-01-01T10:00:00.000Z', [['1.1.1', 'open'], ['1.1.2', 'open'], ['5.1', 'passed']]);
await timeslot(repository, 'b', '2026-02-01T10:00:00.000Z', [['1.1.1', 'passed'], ['1.1.2', 'open'], ['5.1', 'open']]);
server = createAssessorHttpServer({ repository, staticRoot: directory });
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
return { repository, base: `http://127.0.0.1:${port}/api/v1/projects/dummy-project/timeslot-comparison` };
}
const query = (a: string, b: string) => `?baselineAssessmentIds=assessment-${a}&baselineScanRunIds=run-${a}&targetAssessmentIds=assessment-${b}&targetScanRunIds=run-${b}`;
describe('Comparison Engine timeslot route', () => {
it('compares two stored timeslots read-only', async () => {
const { repository, base } = await start();
const revision = repository.revision;
const response = await fetch(base + query('a', 'b'));
expect(response.status).toBe(200);
const report = await response.json() as TimeslotComparisonReport;
expect(report.summary).toMatchObject({ resolved: 1, unchanged: 1, regressed: 1, new: 0 });
expect(report.score.sources.find(item => item.sourceId === 'cis-cat')).toMatchObject({ baselineScore: 100 / 3, targetScore: 100 / 3, change: 0 });
expect(repository.revision).toBe(revision);
});
it('rejects a reversed order, a missing timeslot and an assessment of another project', async () => {
const { base } = await start();
expect((await fetch(base + query('b', 'a'))).status).toBe(400);
expect((await fetch(`${base}?baselineAssessmentIds=assessment-a`)).status).toBe(400);
const foreign = await fetch(`${base}?baselineAssessmentIds=assessment-x&targetAssessmentIds=assessment-b`);
expect(foreign.status).toBe(404);
});
it('can be switched off', async () => {
const repository = new LocalAssessorRepository();
await repository.saveProject({ id: 'dummy-project', name: 'Dummy Customer', createdAt: stamp, updatedAt: stamp, attributes: {} });
server = createAssessorHttpServer({ repository, staticRoot: directory, comparisonPort: null });
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(`http://127.0.0.1:${port}/api/v1/projects/dummy-project/timeslot-comparison${query('a', 'b')}`);
expect(response.status).toBe(503);
});
});