diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 7de1204..b4cb47c 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -2,7 +2,7 @@ ## 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. @@ -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. -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` (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. ## Results cluster foreground correction (2026-10-13, Phase 7 Presentation, 2.25.6) diff --git a/server/assessor-http-server.ts b/server/assessor-http-server.ts index 5bc2257..ef49395 100644 --- a/server/assessor-http-server.ts +++ b/server/assessor-http-server.ts @@ -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 { remediationReadPortFrom, type RemediationReadPort } from '../src/remediation/remediation-read-port'; 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 { summarizeWorkspaceResults, type DatahubTimeslotResultMetrics } from '../src/features/project-import/datahub-scan-analysis'; import { profileForSheet } from '../src/features/project-import/workspace-profile'; @@ -160,6 +163,8 @@ export interface AuditEvent { export interface AssessorHttpServerOptions { remediationStore?: RemediationStore | null; remediationPort?: RemediationReadPort | null; + /** Comparison Engine: declared read-only port; null switches the comparison route off. */ + comparisonPort?: ComparisonReadPort | null; artifactStore?: BackendArtifactStore; mitreCatalogService?: MitreCatalogService; repository: AssessorRepository; @@ -462,6 +467,7 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se const repository = options.repository; const remediationStore = options.remediationStore === undefined ? new MemoryRemediationStore() : options.remediationStore; 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, () => repository.getCommittedRevision?.() ?? Promise.resolve(repository.revision)); const reportPort = options.reportPort === undefined @@ -1169,6 +1175,52 @@ export function createAssessorHttpServer(options: AssessorHttpServerOptions): Se await remediationStore.save(key, result); 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') { // 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(); diff --git a/server/timeslot-comparison-input.ts b/server/timeslot-comparison-input.ts new file mode 100644 index 0000000..3b29fae --- /dev/null +++ b/server/timeslot-comparison-input.ts @@ -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; + loadAssessmentModel(assessmentId: string): Promise; + queryUnifiedResults(query?: UnifiedResultQuery): Promise; + listPolicyEvaluations(projectId: string): Promise; + listGpoBackupInstances(projectId: string): Promise; + getPolicyAnalysisOverview?(projectId: string): Promise>; +} + +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 { + 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 } : {}) }; +} diff --git a/src/api/assessor-api-client.ts b/src/api/assessor-api-client.ts index c830fe2..681d6f8 100644 --- a/src/api/assessor-api-client.ts +++ b/src/api/assessor-api-client.ts @@ -205,6 +205,17 @@ export class AssessorApiClient { return this.request('/projects'); } + /** Comparison Engine route. The engine owns and types its report; the Core client stays engine-free. */ + getTimeslotComparison(projectId: string, baseline: { assessmentIds: string[]; scanRunIds: string[] }, + target: { assessmentIds: string[]; scanRunIds: string[] }): Promise { + 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 { const query = new URLSearchParams(); assessmentIds.forEach(id => query.append('assessmentIds', id)); diff --git a/src/api/request-activity.ts b/src/api/request-activity.ts index cbc5931..c24637d 100644 --- a/src/api/request-activity.ts +++ b/src/api/request-activity.ts @@ -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('/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('/timeslot-comparison')) return { area: 'Comparison Engine', label: 'Comparing timeslots' }; const compliance = complianceActivity(route, method); 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' }; diff --git a/src/comparison/comparison-read-port.ts b/src/comparison/comparison-read-port.ts new file mode 100644 index 0000000..c345855 --- /dev/null +++ b/src/comparison/comparison-read-port.ts @@ -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; +export interface ComparisonReadPort extends ComparisonReadMethods { currentRevision(): Promise } + +export function comparisonReadPortFrom(repository: ComparisonReadMethods, revision: () => Promise): 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) + }); +} diff --git a/tests/comparison/timeslot-comparison-http.test.ts b/tests/comparison/timeslot-comparison-http.test.ts new file mode 100644 index 0000000..1d5b20d --- /dev/null +++ b/tests/comparison/timeslot-comparison-http.test.ts @@ -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'), '
synthetic app
'); +}); +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(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(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); + }); +});