From c26216a4f7fb588519199a8dfa6b6482ab8bba4a Mon Sep 17 00:00:00 2001 From: Binh Date: Tue, 13 Oct 2026 14:13:57 +0200 Subject: [PATCH] feat(comparison): Compare tab, Work Change facet and Finding Reader section (2.26.0) Register src/features/comparison as the Comparison Engine UI path and wire its extension points: the Engines popup entry, the Client Info Compare tab (A/B selection, score delta over comparable sources, change counts, source table), the Work Change facet before Source: Scan (only after the tab loaded a comparison of the open timeslot) and a collapsed Finding Reader Compare section with both states and the retained GPO values side by side. The engine now carries those values. The Data Flow diagram shows the engine; product version 2.26.0. --- .claude/hooks/core-areas.mjs | 2 +- AGENTS.md | 2 +- docs/IMPLEMENTATION_PLAN.md | 6 +- docs/ONBOARDING.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/app/engine-preferences.ts | 7 +- src/comparison/gpo-timeslot-comparison.ts | 12 +- src/comparison/timeslot-comparison-report.ts | 4 +- .../actionlist/ActionlistControls.tsx | 4 +- src/features/comparison/ComparisonTab.tsx | 157 ++++++++++++++++++ src/features/comparison/FindingComparison.tsx | 65 ++++++++ .../comparison/comparison-work-facets.ts | 104 ++++++++++++ src/features/comparison/comparison.css | 44 +++++ .../comparison/use-timeslot-comparison.ts | 148 +++++++++++++++++ src/features/dashboard/DashboardPanel.tsx | 14 ++ src/features/db-structure/EngineDataFlow.tsx | 8 +- src/features/finding-reader/FindingReader.tsx | 3 + .../search-filter/SearchFilterBar.tsx | 6 +- src/features/search-filter/filter-records.ts | 9 +- .../search-filter/use-filtered-records.ts | 6 +- src/features/search-filter/work-facets.ts | 5 +- .../sidebar/SidebarSecurityDomains.tsx | 15 +- src/styles/datahub.css | 6 +- tests/app/app-version.test.ts | 2 +- .../architecture/core-engine-boundary.test.ts | 18 +- tests/comparison/comparison-tab.test.tsx | 116 +++++++++++++ .../comparison/comparison-work-facet.test.ts | 56 +++++++ tests/comparison/finding-comparison.test.tsx | 88 ++++++++++ .../gpo-timeslot-comparison.test.ts | 4 + tests/components/engines-control.test.tsx | 20 +-- tests/features/db-structure-flow.test.tsx | 29 ++-- tests/server/assessor-http-server.test.ts | 2 +- 33 files changed, 906 insertions(+), 64 deletions(-) create mode 100644 src/features/comparison/ComparisonTab.tsx create mode 100644 src/features/comparison/FindingComparison.tsx create mode 100644 src/features/comparison/comparison-work-facets.ts create mode 100644 src/features/comparison/comparison.css create mode 100644 src/features/comparison/use-timeslot-comparison.ts create mode 100644 tests/comparison/comparison-tab.test.tsx create mode 100644 tests/comparison/comparison-work-facet.test.ts create mode 100644 tests/comparison/finding-comparison.test.tsx diff --git a/.claude/hooks/core-areas.mjs b/.claude/hooks/core-areas.mjs index 4488678..91d16e8 100644 --- a/.claude/hooks/core-areas.mjs +++ b/.claude/hooks/core-areas.mjs @@ -17,7 +17,7 @@ export const ENGINE_PATHS = [ ['Report Engine', ['src/reporting/', 'server/reporting/', 'tests/reporting/', 'tests/server/report-archive.test.ts', 'tests/postgres/report-read-only-runtime.test.ts']], ['Compliance Engine', ['src/compliance/', 'server/compliance/', 'tests/compliance/']], - ['Comparison Engine', ['src/comparison/', 'server/comparison/', 'tests/comparison/']], + ['Comparison Engine', ['src/comparison/', 'src/features/comparison/', 'server/comparison/', 'tests/comparison/']], ]; export const CORE_AREAS = [ diff --git a/AGENTS.md b/AGENTS.md index 73a6174..a835da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ - Explicit user decision 2026-10-01: ASSESSOR with all its existing functions is the **Core Engine**. The context enrichment (Phase 13E, "context engine"/"Kontext-Engine" in earlier text) is the **Context Engine**. The read-only Report Engine (Phase 7, `src/reporting/`, `server/reporting/`) is an engine in the same sense. Future features arrive as further engines that provide a capability without changing the Core. The UI labels `Context`/`Kontext` stay unchanged. - Explicit user decision 2026-10-05: the regulatory control mapping (Phase 13F, ISO mapping) is the **Compliance Engine** (`src/compliance/`, `server/compliance/`, `tests/compliance/`). It is framework-neutral by intent: ISO/IEC 27001/27002 is its first corpus, later frameworks such as BSI or DORA arrive inside this engine without a Core change. It reads the Core only through `ComplianceReadPort` (single Result DB row by project, filtered Result DB pages, project, assessment and scan-run lists; explicit user decision 2026-10-05: additionally the internal-analysis `policy-evaluation` rows of the same project and timeslot, used solely to read the GPO technical setting ID that the Result DB withholds, so a GPO setting counts once like its rolled-up Work record and the Work ISO facets match it; that ID is never displayed or exported and no other internal field is read) and stores mapping runs, accept/reject decisions, batches and AI overview runs only through its own `ComplianceStore`. Explicit user decision 2026-10-05: all AI integration belongs to this engine. The AI overview (service, model, Client Info panel) moved from the Core into it, and the Result DB `Export JSON` is the engine's 1:1 copy of what its AI features receive: the exact AI overview input built by the same function the overview sends to the model, and every distinct ISO mapping query with the Result DB rows that produce it (the retrieved candidate controls depend on the index and are not exported). The shared model client `server/ai/litellm-client.ts` stays Core platform code. The existing tables of migrations 025 (AI overview runs) and 026 keep their names and data and are owned by the engine; its writes never advance the Core repository revision. Because the Core deletes scan runs and assessments without knowing the engine, the engine removes AI overview runs of a deleted timeslot from its own table on start and on read. The Core project export receives the engine data, including succeeded AI overview runs, beside the Core export at the HTTP route. Its extension points are the Finding Reader `Map ISO controls` action and its window, the Finding Reader `ISO controls` section, the Result DB `Compliance mapping` batch action (formerly `Map all ISO controls`; ISO and BSI) and `Export JSON` action, the Data Hub ISO corpus tile, the Client Info `Compliance` tab (formerly the `Compliance` row and before that the `ISO controls` section) and `AI Overview` tab, and the Work `ISO control` and `ISO theme` facets, and for BSI the Finding Reader `BSI requirements` section, the Client Info `BSI Grundschutz++` tile, the Work `BSI requirement` and `BSI practice` facets and the Data Hub BSI catalog tile. Switched off, the engine also removes the AI overview tab and its requests and the `Export JSON` action. Explicit user decision 2026-10-05: `Map ISO controls` opens a window like `Accepted Risk` with Accept and Reject per control (no third decision value). The Finding Reader lists the controls whose latest decision is accepted in an `ISO controls` section read like `Description`, after the summary fields. Client Info shows the accepted controls of the selected timeslot directly below the Context heatmap in the same tile layout without severity colour, each with its ISO 27002 theme and the number of deviation findings (a GPO setting counts once); a tile opens Work with the `Deviation` status and that control selected in the `ISO control` facet, like a Context heatmap cell. Explicit user decision 2026-10-06 supersedes only the tile layout of that Client Info section, after feedback that it grows too large: the accepted controls are grouped by ISO 27002 theme in ISO order (Organizational, People, Physical, Technological; a theme without hits is omitted). Each full-width theme heading states its number of controls and of distinct findings (a finding counts once per theme) and opens Work with the `Deviation` status and that theme selected in the `ISO theme` facet. Below it, its controls flow in columns of twice the compact tile width, one compact row per control sorted by finding count, showing the Annex A ID, the complete title (wrapped, never truncated) and the authoritative count, without an amount bar (removed the same day because it cost title width); a row opens Work exactly like the former tile. A theme shows its top six controls; a keyboard-accessible `Show all N controls` button below expands that theme and `Show top 6` collapses it again, and every theme starts collapsed for a new timeslot. The theme heading still opens all of its controls in Work. Explicit user decision 2026-10-06, later the same day, supersedes the in-page list and its top-six cap: Client Info shows a `Compliance` row below the Context heatmap with one tile per framework, in the exact environment tile geometry and fact grid (its own `client-compliance-grid` with the same tile rules; the environment row keeps exactly its six tiles), so later frameworks such as BSI or DORA add a tile instead of a section. The `ISO 27001/27002` tile shows `Controls` (accepted controls), `Findings` (distinct findings) and the two themes with the most findings, each with its finding count (fewer when fewer themes have hits; an em dash while loading, never zero). Its `Results` button opens the shared Results window with the complete grouped list described above, without a cap; a theme heading or row there closes the window and opens Work as described. Explicit user decision 2026-10-06, again later the same day, supersedes the two-theme selection of the tile: after `Controls` and `Findings` the tile shows all four themes in ISO order (Organizational, People, Physical, Technological), each with its finding count (`0 findings` without hits, an em dash while loading). A theme with hits is one click target over its whole fact cell and opens Work with the `Deviation` status and that theme selected in the `ISO theme` facet; a theme without hits is not clickable, because the facet offers only values with hits. Hover changes only the cell surface; keyboard focus shows the blue focus ring. All six facts stay visible in this tile at every width, unlike the environment tiles, which show two facts below their measured four-fact width. Explicit user decision 2026-10-05: Work offers the Compliance Engine facets `ISO control` (one value per accepted control, `A.8.20 · Networks security`) and `ISO theme` (Organizational, People, Physical, Technological) in mixed Work and in both GPO entry paths, directly before the permanent final `Source: Scan`; this extends the GPO-only facet list. They read the accepted controls of every mappable finding of the timeslot (scanner findings by occurrence, GPO settings by scope and technical identity), offer only values with hits, count against the other active filters, keep an active selection while loading, and disappear with their filter cleared when the engine is off. Decisions stay global per scanner rule. Provisional explicit user decision 2026-10-05 (may change later), narrowed by explicit user decision 2026-10-09 for ISO and BSI: after a mapping succeeds, single or batch, the engine accepts at most the first two strong proposals in the model's order automatically, a strong proposal being `primary` with `high` or `medium` confidence; weaker proposals wait for a person and without a strong proposal nothing is accepted. The comment is `Accepted automatically: one of the first two strong proposals (primary, high or medium confidence) of the mapping.`; a control that already has a decision keeps it, so a manual reject is never overridden, and a failure of this step never fails the mapping. Explicit user decision 2026-10-08: the Data Hub ISO corpus tile carries the switch `Map new scans automatically` (default off), stored only in the engine's own settings file `regulatory-corpora/auto-mapping.json`, never in a Core table. While it is on, an engine-side backend watcher (`server/compliance/regulatory-auto-mapping.ts`) checks every 10 seconds through `ComplianceReadPort` only: the active (not superseded) scan runs and, because GPO backups create no scan run, the GPO deviation rows of every timeslot as filtered Result DB pages. For each new scan run it starts the existing ISO batch over that run's deviations, and for each timeslot with new GPO deviation rows a batch over that timeslot's GPO deviations, each with a stable idempotency key. Rules with a succeeded or active mapping of the current pipeline versions are reused, so only new rules reach the model; failed mappings are retried, and the provisional auto-acceptance applies unchanged. What exists when the watcher starts or is switched on is the baseline and is not mapped retroactively; new imports wait while mapping is unavailable. Switching the Compliance Engine off in the Engines popup also switches this server-wide setting off (once, from any view); switching the engine on again leaves it off. Already started batches are never cancelled. Explicit user decision 2026-10-08 supersedes only the placement of the 2026-10-06 Client Info `Compliance` row: it is its own Client Info tab `Compliance`, after Overview (and Microsoft Defender for IoT) and before `AI Overview`, shown while the engine is on and the backend can deliver the timeslot; the Overview no longer contains it. The tab keeps the framework tile row exactly as decided, including each tile's `Results` button and window, so later frameworks add a tile there. Explicit user decision 2026-10-08, later the same day (supersedes the timeslot-only, batch-only history first agreed): because mappings and decisions are global per rule, the tab heading carries a `Last mapping` button showing the newest mapping of any client (`Last mapping: · Automatic|Manual|Single finding`, followed by `Other timeslot`, `Other client` or `Unknown` when it is not this timeslot and by the status while not completed, or plain `No mapping yet`). It opens the window `Mapping history` (formerly `ISO mapping history`) with at most 50 entries, newest first: every mapping batch of every client and every single finding mapping (`Map ISO controls` and `Map BSI requirements` in the Finding Reader); explicit user decision 2026-10-09 adds the direct BSI mappings, so the button names a BSI mapping with `· BSI` after the date and an ISO mapping keeps its label. Columns: Started, Framework (`ISO 27001/27002` or `BSI Grundschutz++`), Trigger, Scope, Status and the content-free counts Findings (a batch's own eligible findings; an em dash for a single mapping), Sent to model, Reused, Succeeded and Failed. Entries carry no client, rule, scope ID, filter or key. A batch is Automatic when the watcher started it (idempotency key prefixes `auto:`/`auto-gpo:`), otherwise Manual. Explicit user correction 2026-10-08: Scope names where an entry came from relative to the shown timeslot: `This timeslot` (a batch whose scope shares an assessment or scan run with it, or a single mapping started there), `Other timeslot` (same client, another timeslot), `Other client`, or `Unknown` (a single mapping without a recorded origin). `Map ISO controls` sends the timeslot the Finding Reader shows; the server validates it against the client and stores only SHA-256 origin tokens of the client and of each assessment and scan run, never their ids, because mapping runs are global and exported with every client; an invalid timeslot leaves only the client as origin. Every mapping run records its trigger (`single` or `batch`) in the engine's own run data; runs from before 2.21.0 record none and are not listed as single mappings, because they cannot be told apart from batch runs. While a listed entry runs, the tab refreshes every five seconds without falling back to em dashes. Returning to the tab shows the last overview of that client and timeslot at once from a bounded in-memory cache of at most eight timeslots and always requests a fresh one, which replaces it; a failed refresh keeps the shown values. - Explicit user decision 2026-10-08: BSI Grundschutz++ is the second framework of the Compliance Engine, limited to the 901 Kernel requirements (class `BSI-Stand-der-Technik-Kernel-G0`) of the BSI Stand-der-Technik-Bibliothek; Methodik and risk-management requirements stay out. Like the MITRE catalogs, the catalog and the official BSI mapping ISO/IEC 27001:2022 Annex A -> Grundschutz++ are compact, commit-pinned snapshots in `server/compliance/data/` with source URL, source SHA-256 and the CC BY-SA 4.0 attribution, generated by `server/compliance/update-bsi-assets.mjs`. Explicit user decision 2026-10-08, later the same day: like the MITRE matrix tile, a Data Hub tile `BSI GRUNDSCHUTZ++ KERNEL` (below the ISO corpus tile, shown while the engine is on) names the active catalog date, mapping version and origin (`Bundled` or `Updated`) and offers `Update from BSI`, which fetches only the two fixed official files from the BSI repository `main` branch in the backend, validates them fail-closed, never activates a state older than the active or bundled one, and stores the compact result with source URL and SHA-256 only in the engine folder `regulatory-corpora/bsi/` (see `SECURITY.md`); `Use bundled catalog`, offered only after an update, returns to the pinned snapshots. Derived requirements follow the active catalog at once. Stage 1 derives BSI requirements from the accepted ISO controls of a rule through that mapping, without a model call or migration: each derived requirement names its ISO control and the mapping relationship, is labelled as derived, is never stored as a decision and never changes an ISO decision, and a mapping target missing from the catalog is reported, never reinterpreted. The Finding Reader `BSI requirements` section follows `ISO controls`; the Client Info `Compliance` tab shows a `BSI Grundschutz++` tile beside the ISO tile (`Requirements`, `Findings` and the practices with most findings, as many as the square tile fits, at least four and at most ten (explicit user decision 2026-10-12); its `Results` window lists every requirement by practice in catalog order), whose rows and practices open Work with the `Deviation` status and the `BSI requirement` or `BSI practice` facet; Work offers these two facets after the ISO facets and before `Source: Scan`, only while a requirement is derived. BSI texts may be shown with attribution. Explicit user decision 2026-10-09 (stage 2, migration 029 after a verified backup): the model also maps a rule directly to Kernel requirements from its own BSI corpus (`corpusId` `bsi-grundschutz-kernel` in the shared Qdrant/BGE-M3 path, built from the active catalog from the Data Hub BSI tile via `Build search index`/`Rebuild search index` or on the first BSI mapping), started from `Map BSI requirements` in the Finding Reader `BSI requirements` section or from the BSI part of the Result DB `Compliance mapping` window. Mappings and decisions carry a framework (`iso27001`, `bsi-kernel`; ISO objects keep their earlier shape) and the same auto-acceptance rule applies. The Client Info `Compliance` tab tile `ISO ↔ BSI review` compares per rule the directly accepted BSI requirements with those derived from the accepted ISO controls: `Consistent` (one direct requirement in the derived set), `Inconsistent` (reachable but none derived) or `No ISO reference` (none reachable through the BSI mapping); its Results window lists the rules by a Result DB row, offers Accept/Reject through the normal decision path and `Mark reviewed` for one fingerprint of the compared sets (engine table `assessor_regulatory_consistency_reviews`). The comparison is derived only and never changes, rejects or blocks a decision. Explicit user decision 2026-10-09: ISO and BSI retrieval are hybrid: the model restates the public query once in German, and dense English, dense German and German keyword (BM25) rankings are fused by reciprocal rank; BSI leaves out the BES, DLS, GEB and PERS practices and sends 16 candidates, and each ISO candidate carries its full ISO/IEC 27002 control text (never sent to the browser). The automatic mapping of new scans does not cover BSI. -- Explicit user decision 2026-10-13: the timeslot comparison of `docs/TIMESLOT_COMPARISON_PLAN.md` (P1-P6) is the **Comparison Engine** (`src/comparison/`, `server/comparison/`, `tests/comparison/`). It compares two explicitly selected timeslots A and B of the same client and owns its comparison logic; every contract and the acceptance matrix of that plan stay binding (object/scope identity, absence is never a remediation without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population). It never changes, re-derives or overrides a Core verdict, score, review, risk acceptance or Result DB row. A retained value change of a still deviating setting is shown neutral, neither better nor worse. Planned extension points (separate Core change): a Client Info `Compare` tab as entry, a Work `Change` facet with change badges and a Finding Reader `Compare` section with A and B side by side. The Core Phase 8 scan-run comparison (`compareScanRuns`, local comparison rules, `assessor_comparisons`) stays Core in `src/evaluation/scan-run-comparison/`. Engine storage for carried-forward obligations needs separate approval, backup and migration. +- Explicit user decision 2026-10-13: the timeslot comparison of `docs/TIMESLOT_COMPARISON_PLAN.md` (P1-P6) is the **Comparison Engine** (`src/comparison/` platform-neutral logic, `src/features/comparison/` its UI like the Context Engine's `src/features/context-layer/`, `server/comparison/`, `tests/comparison/`). It compares two explicitly selected timeslots A and B of the same client and owns its comparison logic; every contract and the acceptance matrix of that plan stay binding (object/scope identity, absence is never a remediation without a declared profile negative-evidence contract, a missing source keeps its open deviations as unverified, score delta only over the shared population). It never changes, re-derives or overrides a Core verdict, score, review, risk acceptance or Result DB row. A retained value change of a still deviating setting is shown neutral, neither better nor worse. Planned extension points (separate Core change): a Client Info `Compare` tab as entry, a Work `Change` facet with change badges and a Finding Reader `Compare` section with A and B side by side. The Core Phase 8 scan-run comparison (`compareScanRuns`, local comparison rules, `assessor_comparisons`) stays Core in `src/evaluation/scan-run-comparison/`. Engine storage for carried-forward obligations needs separate approval, backup and migration. - Engines may read the Core without restriction but never write to, change or override it. They read only through a declared read-only port and the read-only database access, never through Core tables or internal modules directly. What an engine shows or exports still obeys the Core's privacy and Result DB disclosure rules. - Dependencies point one way: an engine depends on the Core, the Core never depends on an engine. The Core works unchanged with every engine switched off; each engine keeps a permanent on/off invariance test like the Context layer's. Engines do not build on each other except through another engine's published results. - An engine that needs storage gets its own schema or tables after separate approval with backup and migration, never columns in Core tables. An engine cannot create findings: new scanner profiles, importers and parsers write Core data and are always Core changes. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 1ed0271..3ee9316 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; 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). +Status: **steps 1, 2, 3a and 3b done (2026-10-13); step 4 (engine storage) and Security Domain deltas 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. @@ -18,7 +18,9 @@ Step 3a (engine paths only, no Core release): `src/comparison/timeslot-compariso 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. +Step 3b, second part (Core release Governance, Presentation, Core Tests, Platform; the Data Flow diagram part finished under a renewed Presentation, Core Tests, Platform release after an earlier release had expired mid-turn): the engine UI path `src/features/comparison/` is registered (area map, AGENTS.md, onboarding) and holds the formerly parked `ComparisonTab`, `useTimeslotComparison`, `comparison-work-facets` and `FindingComparison`. Extension points (pinned in the boundary test): Engines popup entry `Comparison Engine` (default on); Client Info tab `Compare` after `Compliance`; Work facet `Change` before `Source: Scan` in the shared filter pipeline (filter-records, work-facets, use-filtered-records, SearchFilterBar, ActionlistControls, SidebarSecurityDomains), present only after the Compare tab loaded a comparison whose B is the open timeslot, so opening Work never starts a comparison; a change count opens Work with that facet value (`Resolved` with status All, all others Deviation) and is clickable only when timeslot B holds a record for it; Finding Reader section `Compare` after Context, collapsed, with both states and, for GPO settings, both retained configured values as Work displays them (the engine now carries them). The Data Flow engine diagram shows the Comparison Engine and its two relationships. Product version 2.26.0 (backward-compatible capability). Synthetic test client `compare test` (2026-06-10 and 2026-09-15) created through the regular import API from generated synthetic CIS-CAT reports (resolved, unchanged, regressed, new and absent rules) plus the synthetic Maester fixture in B only; the generated files are in git-ignored `artifacts/compare-engine/test-client/`. Verified: import gate 528/528; main suite 2,474 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. Not yet verified in the running app: it was started outside this session and still runs 2.25.0. + +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, API client, Compare tab, Work Change facet, Finding Reader section and engine diagram; version 2.26.0. ## Security finding reading hierarchy (2026-10-11, Phase 7 Presentation) diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 11d1143..104bf57 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -259,7 +259,7 @@ Documentation under `docs/` is not gated. | **Context Engine** | `src/assessment-context/`, `src/features/context-layer/` | Gives every deviation a context cluster, in a fixed order of tiers: *catalogued* (catalogue rule, CIS section, declared ATT&CK technique), *recognized* (distinctive terms, technical anchors) or *estimated* (local TF-IDF similarity, category fallback). Deterministic, no AI. Shown as badges, the `Context Cluster` facet, the Finding Reader section and the Client Info heatmap. A context statement says "favours", never "exploitable". | none (in memory) | | **Report Engine** | `src/reporting/`, `server/reporting/` | Builds the Management Report (German, offline, no LLM, requires published Context results) and the Technical Report from one shared projection. | own file archive, separate read-only DB pool | | **Compliance Engine** | `src/compliance/`, `server/compliance/` | Maps findings to regulatory controls; ISO/IEC 27001/27002 is the first framework. Owns **all AI integration**: ISO mapping and the AI overview. | own tables in PostgreSQL, Qdrant index | -| **Comparison Engine** | `src/comparison/`, `server/comparison/` | Compares two timeslots A and B of one client: verified resolved, still open, unverified, new, regressed and neutral value changes (see `docs/TIMESLOT_COMPARISON_PLAN.md`). In development; no UI yet. | none yet (own schema planned) | +| **Comparison Engine** | `src/comparison/`, `src/features/comparison/`, `server/comparison/` | Compares two timeslots A and B of one client: verified resolved, still open, unverified, new, regressed and neutral value changes (see `docs/TIMESLOT_COMPARISON_PLAN.md`). Shown in the Client Info `Compare` tab. | none yet (own schema planned) | **Engine rules**: diff --git a/package-lock.json b/package-lock.json index f41524a..5eb92b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "assessor", - "version": "2.25.0", + "version": "2.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "assessor", - "version": "2.25.0", + "version": "2.26.0", "dependencies": { "@fontsource-variable/inter": "^5.3.0", "jsdom": "^29.1.1", diff --git a/package.json b/package.json index cdfbcbd..09eda8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assessor", - "version": "2.25.0", + "version": "2.26.0", "private": true, "type": "module", "scripts": { diff --git a/src/app/engine-preferences.ts b/src/app/engine-preferences.ts index 171c321..04bf557 100644 --- a/src/app/engine-preferences.ts +++ b/src/app/engine-preferences.ts @@ -6,11 +6,12 @@ export const ENGINES = [ { id: 'context', label: 'Context Engine', required: false }, { id: 'report', label: 'Report Engine', required: false }, { id: 'remediation', label: 'Remediation Engine', required: false }, - { id: 'compliance', label: 'Compliance Engine', required: false } + { id: 'compliance', label: 'Compliance Engine', required: false }, + { id: 'comparison', label: 'Comparison Engine', required: false } ] as const; export type EngineId = typeof ENGINES[number]['id']; export const ENGINE_PREFERENCES_KEY = 'ASSESSOR.engines.v1'; -const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true }); +const defaults = Object.freeze({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true }); type Preferences = Readonly>; let snapshot: Preferences | undefined; const listeners = new Set<() => void>(); @@ -20,7 +21,7 @@ export function readEnginePreferences(): Preferences { try { const saved = JSON.parse(window.localStorage.getItem(ENGINE_PREFERENCES_KEY) ?? '{}'); snapshot = Object.freeze({ core: true, context: saved?.context !== false, report: saved?.report !== false, remediation: saved?.remediation !== false, - compliance: saved?.compliance !== false }); + compliance: saved?.compliance !== false, comparison: saved?.comparison !== false }); } catch { snapshot = defaults; } return snapshot; } diff --git a/src/comparison/gpo-timeslot-comparison.ts b/src/comparison/gpo-timeslot-comparison.ts index c3aa37e..5f0a040 100644 --- a/src/comparison/gpo-timeslot-comparison.ts +++ b/src/comparison/gpo-timeslot-comparison.ts @@ -21,6 +21,8 @@ interface TimeslotSetting { verdict?: SettingVerdict; baselineIds: string; actualValues: string; + /** The retained configured values as Work displays them; empty when none is retained. */ + displayValues: string[]; resultIds: string[]; ambiguous: boolean; } @@ -38,6 +40,9 @@ export interface GpoSettingDecision { reasons: string[]; baselineResultIds: string[]; targetResultIds: string[]; + /** Retained configured values of both timeslots as Work displays them, for a side-by-side reading. */ + baselineValues: string[]; + targetValues: string[]; } export interface GpoTargetChange { @@ -82,6 +87,10 @@ function rollUp(results: PolicyWorkResult[]): Map { baselineIds: canonicalJson([...new Set(verdicts.flatMap(item => item.comparison?.baselineId ?? []))].sort()), actualValues: canonicalJson([...new Set(decisive.flatMap(item => item.comparison?.comparator.actual === undefined ? [] : [canonicalJson(item.comparison.comparator.actual)]))].sort()), + displayValues: [...new Set(decisive.flatMap(item => { + const actual = item.comparison?.comparator.actual; + return actual === undefined ? [] : [typeof actual === 'object' && actual !== null ? JSON.stringify(actual) : String(actual)]; + }))].sort(), resultIds: group.map(item => item.id).sort(), ambiguous: new Set(group.flatMap(item => item.settingKey ?? [])).size > 1 }); @@ -112,7 +121,8 @@ export function compareGpoTimeslots(baseline: GpoTimeslotInput, target: GpoTimes valueChanged = false): GpoSettingDecision => ({ settingKey: item.key, scope: item.scope, identity: item.identity, baselineVerdict: 'Fail', ...(other?.verdict ? { targetVerdict: other.verdict } : {}), state, valueChanged, reasons, - baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? [] + baselineResultIds: item.resultIds, targetResultIds: other?.resultIds ?? [], + baselineValues: item.displayValues, targetValues: other?.displayValues ?? [] }); if (sourceReasons.length) return { version, state: 'not-comparable', reasons: sourceReasons, diff --git a/src/comparison/timeslot-comparison-report.ts b/src/comparison/timeslot-comparison-report.ts index 069d8a2..690da9e 100644 --- a/src/comparison/timeslot-comparison-report.ts +++ b/src/comparison/timeslot-comparison-report.ts @@ -30,6 +30,8 @@ export interface TimeslotChangeEntry { counterpartIds: string[]; identityKey?: string; reasons: string[]; + /** GPO settings only: retained configured values of A and B as Work displays them. */ + values?: { baseline: string[]; target: string[] }; } export interface TimeslotSourceScore { @@ -103,7 +105,7 @@ function gpoChanges(gpo: GpoTimeslotComparison, target?: GpoTimeslotInput): Time sourceId: GPO_SOURCE_ID, change: item.state === 'confirmed-open' && item.valueChanged ? 'value-changed' : changeOf[item.state], side: 'baseline', recordIds: item.baselineResultIds, counterpartIds: item.targetResultIds, - identityKey: item.settingKey, reasons: item.reasons + identityKey: item.settingKey, reasons: item.reasons, values: { baseline: item.baselineValues, target: item.targetValues } })), ...gpo.newDeviations.map((item): TimeslotChangeEntry => ({ sourceId: GPO_SOURCE_ID, change: 'new', side: 'target', recordIds: item.targetResultIds, counterpartIds: [], diff --git a/src/features/actionlist/ActionlistControls.tsx b/src/features/actionlist/ActionlistControls.tsx index 09e767b..613ed46 100644 --- a/src/features/actionlist/ActionlistControls.tsx +++ b/src/features/actionlist/ActionlistControls.tsx @@ -14,6 +14,7 @@ import { WorkPackagesWindow } from '../../remediation/WorkPackagesWindow'; import { UNASSIGNED_WORK_PACKAGE, WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine'; import { numberedClusterLabel, publishedClusterColour } from '../sidebar/context-cluster-display'; import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets'; +import { useComparisonWorkFacets } from '../comparison/comparison-work-facets'; export function ActionlistControls() { const app = useApp(); @@ -24,6 +25,7 @@ export function ActionlistControls() { const workPackages = remediation.status === 'ready' ? remediation.index : undefined; const packageOverview = remediation.status === 'ready' ? remediation.overview : undefined; const complianceFacets = useComplianceWorkFacets(); + const comparisonFacets = useComparisonWorkFacets(); const [failed, setFailed] = useState(false); const [packagesOpen, setPackagesOpen] = useState(false); const exportView = async (format: 'csv' | 'md' | 'html') => { @@ -31,7 +33,7 @@ export function ActionlistControls() { const activity = beginActivity('Core Engine', `Preparing Work ${format.toUpperCase()} export`); try { await showActivityFrame(); - const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets }); + const facets = selectWorkFacets(app.activeSheet, { ...app, contextClusters, workPackages, complianceFacets, comparisonFacets }); const statusLabel = app.activeProfile.presentation.statusFilters.find(filter => filter.value === app.statusFilter)?.label ?? app.statusFilter; const filters = Object.entries(app.facetFilters).map(([id, filter]) => { const facet = facets.find(item => item.id === id); diff --git a/src/features/comparison/ComparisonTab.tsx b/src/features/comparison/ComparisonTab.tsx new file mode 100644 index 0000000..46f3702 --- /dev/null +++ b/src/features/comparison/ComparisonTab.tsx @@ -0,0 +1,157 @@ +import { useId } from 'react'; +import { toolProfileDefinitions } from '../../profiles/profile-definition-catalog'; +import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report'; +import { useTimeslotComparison, type ComparableTimeslot } from './use-timeslot-comparison'; +import { changesWithWorkRecords } from './comparison-work-facets'; +import './comparison.css'; + +interface Props { + projectId?: string; + /** Assessments of the timeslot open in Client Info; it is the default timeslot B. */ + assessmentIds?: readonly string[]; + /** Opens Work with the change facet; the extension point decides how. */ + onOpenChange?(change: TimeslotChange, baseline: ComparableTimeslot, target: ComparableTimeslot): void; +} + +/** Display order of the change facts; better first, then worse, then neutral and unclear states. */ +export const CHANGE_FACTS: Array<{ change: TimeslotChange; label: string; tone: 'better' | 'worse' | 'neutral'; glyph: string }> = [ + { change: 'resolved', label: 'Resolved', tone: 'better', glyph: '▼' }, + { change: 'new', label: 'New', tone: 'worse', glyph: '▲' }, + { change: 'regressed', label: 'Regressed', tone: 'worse', glyph: '▲' }, + { change: 'value-changed', label: 'Value changed', tone: 'neutral', glyph: '≠' }, + { change: 'unchanged', label: 'Unchanged', tone: 'neutral', glyph: '=' }, + { change: 'unverified', label: 'Unverified, still open', tone: 'neutral', glyph: '?' }, + { change: 'not-comparable', label: 'Not comparable', tone: 'neutral', glyph: '○' } +]; + +const REASONS: Record = { + 'baseline-source-missing': 'Not in timeslot A', + 'target-source-missing': 'Not in timeslot B', + 'baseline-source-ambiguous': 'Several runs in timeslot A', + 'target-source-ambiguous': 'Several runs in timeslot B', + 'no-comparable-scan-pair': 'No comparable scan pair', + 'scan-scope-changed': 'Assessed scope changed', + 'comparison-scope-changed': 'Assessed scope changed', + 'processing-contract-incompatible': 'Processed with different import versions', + 'processing-contract-unknown': 'Import version unknown', + 'newer-observation-not-verified': 'No newer scan or backup time', + 'no-shared-decided-settings': 'No setting decided in both timeslots', + 'comparison-profile-unavailable': 'Scanner not yet comparable', + 'comparison-profile-missing': 'Scanner not yet comparable' +}; +export const reasonText = (reasons: string[]) => [...new Set(reasons.map(reason => REASONS[reason] ?? reason))].join(' · '); + +export function sourceName(sourceId: string): string { + if (sourceId === GPO_SOURCE_ID) return 'GPOs'; + return toolProfileDefinitions.find(item => item.id === sourceId)?.name ?? sourceId; +} + +export const date = (value: string) => { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); +}; +const rounded = (value?: number) => value === undefined ? undefined : Math.round(value); + +function Delta({ before, after }: { before?: number; after?: number }) { + if (before === undefined || after === undefined) return —; + const change = after - before; + if (change === 0) return = No change; + return change > 0 + ? ▲ +{change} Better + : ▼ −{Math.abs(change)} Worse; +} + +function sourceRows(report: TimeslotComparisonReport) { + return report.sources.map(source => { + const changes = report.changes.filter(item => item.sourceId === source.sourceId); + const count = (change: TimeslotChange) => changes.filter(item => item.change === change).length; + const score = report.score.sources.find(item => item.sourceId === source.sourceId); + return { source, count, score }; + }); +} + +/** Client Info `Compare` tab content of the Comparison Engine (mockup A, user decision 2026-10-13). */ +export function ComparisonTab({ projectId, assessmentIds = [], onOpenChange }: Props) { + const id = useId(); + const comparison = useTimeslotComparison(projectId, assessmentIds); + const { state, baseline, target, earlier, timeslots, targetIsOpen } = comparison; + const report = state.status === 'ready' ? state.report : undefined; + // Work shows the open timeslot; only changes with a record there can open Work. + const openable = report && targetIsOpen && onOpenChange ? changesWithWorkRecords(report) : new Set(); + const option = (item: ComparableTimeslot) => ; + + return
+
+

Compare

+ {timeslots.length > 1 && target &&
+ + + +
} + Read-only comparison · verdicts and scores stay unchanged +
+ + {state.status === 'loading' &&

Comparing timeslots…

} + {state.status === 'error' &&

{state.message}

} + {state.status === 'unavailable' &&

+ {state.reason === 'single-timeslot' ? 'This client has only one timeslot. A comparison needs two.' : 'There is no earlier timeslot to compare with.'} +

} + + {report && baseline && target && <> +
+
+

Overall Score

+
+
A · {date(baseline.date)}
{rounded(report.score.baselineScore) ?? '—'}
+
B · {date(target.date)}
{rounded(report.score.targetScore) ?? '—'}
+
+ +

{report.score.change === undefined + ? 'No source has a comparable score in both timeslots.' + : `Comparable sources only: ${report.score.sources.filter(item => item.change !== undefined).map(item => sourceName(item.sourceId)).join(', ')}.`}

+
+ +
+

Changes

+ {!targetIsOpen &&

Open timeslot B in Client Info to show these changes in Work.

} +
+ {CHANGE_FACTS.map(fact => { + const count = fact.change === 'not-comparable' + ? report.summary['not-comparable'] + report.summary.targetNotComparable : report.summary[fact.change]; + return ; + })} +
+
+
+ +
+

Sources

+
+ + + + {sourceRows(report).map(({ source, count, score }) => + + + + + + + )} +
SourceResolvedNewRegressedValue changedUnverifiedNot comparableScore AScore BChangeComparison basis
{sourceName(source.sourceId)}{count('resolved')}{count('new')}{count('regressed')}{count('value-changed')}{count('unverified')}{count('not-comparable')}{rounded(score?.baselineScore) ?? '—'}{rounded(score?.targetScore) ?? '—'}{source.state === 'compared' ? (score?.reasons.length ? reasonText(score.reasons) : 'Compared') : reasonText(source.reasons)}
+
+
+ } +
; +} diff --git a/src/features/comparison/FindingComparison.tsx b/src/features/comparison/FindingComparison.tsx new file mode 100644 index 0000000..b4df68d --- /dev/null +++ b/src/features/comparison/FindingComparison.tsx @@ -0,0 +1,65 @@ +import { Fragment } from 'react'; +import { TriangleIcon } from '../../components/TriangleIcon'; +import type { FindingRecord } from '../../domain/finding'; +import { policyComparisonDisplayLines } from '../../profiles/group-policy/presentation'; +import type { TimeslotChange, TimeslotChangeEntry } from '../../comparison/timeslot-comparison-report'; +import { useOptionalApp } from '../../app/AppProvider'; +import { useEnginePreferences } from '../../app/engine-preferences'; +import { comparisonWorkFacetIndex } from './comparison-work-facets'; +import { usePublishedComparison } from './use-timeslot-comparison'; +import { CHANGE_FACTS, date, reasonText } from './ComparisonTab'; + +/** What each change says about timeslot A and B; never a re-derived verdict, only the comparison's statement. */ +const STATES: Record = { + resolved: { a: 'Deviation', b: 'Passed' }, + unchanged: { a: 'Deviation', b: 'Deviation' }, + 'value-changed': { a: 'Deviation', b: 'Deviation' }, + unverified: { a: 'Deviation', b: 'Not decisive' }, + 'not-applicable': { a: 'Deviation', b: 'Not applicable' }, + 'not-comparable': { a: 'Not comparable', b: 'Not comparable' }, + new: { a: 'No deviation', b: 'Deviation' }, + regressed: { a: 'Passed', b: 'Deviation' } +}; + +function Values({ values }: { values: string[] }) { + if (!values.length) return <>—; + return <>{values.map((value, index) =>

{policyComparisonDisplayLines(value).map((line, lineIndex) => + {lineIndex > 0 && '\n'}{line.emphasized ? {line.text} : line.text})}

)}; +} + +/** + * Finding Reader `Compare` section of the Comparison Engine (mockup B, user decision 2026-10-13): the change of + * this finding against timeslot A, both states and, for a GPO setting, both retained values side by side. + * Collapsed like the Context section; present only while a comparison of the open timeslot is loaded. + */ +export function FindingComparison({ record }: { record: FindingRecord }) { + // Like the Context section, the reader also renders without AppProvider; then no comparison applies. + const app = useOptionalApp(); + const enabled = useEnginePreferences().comparison; + const comparison = usePublishedComparison(app?.targetProjectId, app?.activeAssessmentIds ?? []); + const index = enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined; + const entry: TimeslotChangeEntry | undefined = index?.entryOf(record); + if (!index || !entry) return null; + const fact = CHANGE_FACTS.find(item => item.change === entry.change); + const { baseline, target } = index.comparison; + const state = STATES[entry.change]; + const reasons = entry.reasons.length ? reasonText(entry.reasons) : ''; + return
+ Compare A · {date(baseline.date)} → B · {date(target.date)} +

+ {fact?.label ?? entry.change} + {entry.change === 'value-changed' && Still a deviation; the configured value differs from timeslot A.} +

+
+ + + + + {entry.values && } + +
A · {date(baseline.date)}B · {date(target.date)}
Result{state.a}{state.b}
Configured value
+
+ {reasons &&

{reasons}

} +

The comparison reads both timeslots and changes no verdict or score.

+
; +} diff --git a/src/features/comparison/comparison-work-facets.ts b/src/features/comparison/comparison-work-facets.ts new file mode 100644 index 0000000..dda4545 --- /dev/null +++ b/src/features/comparison/comparison-work-facets.ts @@ -0,0 +1,104 @@ +import { useApp } from '../../app/AppProvider'; +import { useEnginePreferences } from '../../app/engine-preferences'; +import type { FindingRecord } from '../../domain/finding'; +import { gpoWorkSettingIdentity } from '../results-browser/work-record-lookup'; +import type { DynamicFacet } from '../search-filter/dynamic-facets'; +import { GPO_SOURCE_ID, type TimeslotChange, type TimeslotChangeEntry, type TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report'; +import { usePublishedComparison, type PublishedComparison } from './use-timeslot-comparison'; + +/** Work facet of the Comparison Engine; present only while a comparison of the open timeslot B is loaded. */ +export const COMPARISON_CHANGE_FACET_ID = 'comparison:change'; +const SOURCE_SCAN_FACET_ID = 'provenance:source-scan'; + +/** Facet order: better first, then worse, then neutral and unclear states. */ +export const CHANGE_LABELS: Array<{ change: TimeslotChange; label: string }> = [ + { change: 'resolved', label: 'Resolved' }, + { change: 'new', label: 'New' }, + { change: 'regressed', label: 'Regressed' }, + { change: 'value-changed', label: 'Value changed' }, + { change: 'unchanged', label: 'Unchanged' }, + { change: 'unverified', label: 'Unverified, still open' }, + { change: 'not-applicable', label: 'Not applicable' }, + { change: 'not-comparable', label: 'Not comparable' } +]; + +export interface ComparisonWorkFacetIndex { + facets: DynamicFacet[]; + /** The change of a Work record of timeslot B, if the comparison names one. */ + changeOf(record: FindingRecord): TimeslotChange | undefined; + /** The full comparison entry behind that change, for the Finding Reader. */ + entryOf(record: FindingRecord): TimeslotChangeEntry | undefined; + comparison: PublishedComparison; +} + +const gpoKey = (scope: unknown, identity: unknown) => JSON.stringify([scope, identity]); + +/** + * Maps the report onto Work records of timeslot B: scanner records by occurrence (a current record, or the + * B counterpart of an A deviation), GPO settings by scope and technical identity, like Result DB drill-downs. + * A deviation of A without a B record has no Work row in B and is therefore not offered. + */ +function entryIndex(report: TimeslotComparisonReport) { + const byOccurrence = new Map(); + const bySetting = new Map(); + for (const entry of report.changes) { + if (entry.sourceId === GPO_SOURCE_ID) { + if (entry.identityKey && (entry.side === 'target' || entry.counterpartIds.length)) bySetting.set(entry.identityKey, entry); + continue; + } + for (const id of entry.side === 'target' ? entry.recordIds : entry.counterpartIds) byOccurrence.set(id, entry); + } + return (record: FindingRecord): TimeslotChangeEntry | undefined => { + if (record.findingOccurrenceId) return byOccurrence.get(record.findingOccurrenceId); + const identity = gpoWorkSettingIdentity(record.id); + return identity ? bySetting.get(gpoKey(identity.scope, identity.settingId)) : undefined; + }; +} + +/** Changes that have at least one Work record in timeslot B, so the Compare tab can open them in Work. */ +export function changesWithWorkRecords(report: TimeslotComparisonReport): Set { + return new Set(report.changes.filter(entry => entry.side === 'target' || entry.counterpartIds.length).map(entry => entry.change)); +} + +const indexes = new WeakMap(); +export function comparisonWorkFacetIndex(comparison: PublishedComparison): ComparisonWorkFacetIndex { + const existing = indexes.get(comparison); + if (existing) return existing; + const entryOf = entryIndex(comparison.report); + const changeOf = (record: FindingRecord) => entryOf(record)?.change; + const date = new Date(comparison.baseline.date); + const label = Number.isNaN(date.getTime()) ? comparison.baseline.date + : date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + const index: ComparisonWorkFacetIndex = { + comparison, + changeOf, + entryOf, + facets: [{ + id: COMPARISON_CHANGE_FACET_ID, + label: 'Change', + source: 'derived', + kind: 'category', + description: `Comparison Engine: change of each finding and GPO setting against timeslot A (${label}). It does not change results.`, + values: CHANGE_LABELS.map(item => ({ value: item.change, label: item.label, count: 0 })), + valuesFor: record => { const change = changeOf(record); return change ? [change] : []; } + }] + }; + indexes.set(comparison, index); + return index; +} + +/** Adds the `Change` facet directly before the permanent final `Source: Scan` control. */ +export function withComparisonFacets(facets: DynamicFacet[], index: ComparisonWorkFacetIndex | undefined): DynamicFacet[] { + if (!index) return facets; + const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID); + return sourceScan < 0 ? [...facets, ...index.facets] + : [...facets.slice(0, sourceScan), ...index.facets, ...facets.slice(sourceScan)]; +} + +/** The facet index for Work, or undefined while the engine is off or no comparison of the open timeslot is loaded. */ +export function useComparisonWorkFacets(): ComparisonWorkFacetIndex | undefined { + const app = useApp(); + const enabled = useEnginePreferences().comparison; + const comparison = usePublishedComparison(app.targetProjectId, app.activeAssessmentIds ?? []); + return enabled && comparison ? comparisonWorkFacetIndex(comparison) : undefined; +} diff --git a/src/features/comparison/comparison.css b/src/features/comparison/comparison.css new file mode 100644 index 0000000..6d36352 --- /dev/null +++ b/src/features/comparison/comparison.css @@ -0,0 +1,44 @@ +/* Comparison Engine UI. Shared tokens only; better and worse always pair their colour with a glyph and text. */ + +.comparison-tab { display: grid; gap: var(--segment-gap); margin-block: 0 var(--space-8); color: var(--text-primary); min-width: 0; } +.comparison-tab__header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); } +.comparison-tab__header > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } +.comparison-tab__timeslots { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); } +.comparison-tab__timeslots label { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--type-table-body-size); color: var(--text-secondary); } +.comparison-tab__select { height: var(--btn-h); padding: 0 var(--space-2); border: var(--bw-hairline) solid var(--border-default); border-radius: var(--btn-radius); background: var(--surface-input); color: var(--text-primary); font: var(--fw-semibold) var(--type-button-size) var(--font-ui); } +.comparison-tab__select:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-hairline); } +.comparison-tab__arrow { color: var(--text-secondary); } +.comparison-tab__note { margin-inline-start: auto; font-size: var(--type-meta-size); color: var(--text-secondary); } +.comparison-tab__status { margin: 0; font-size: var(--type-body-size); color: var(--text-secondary); } + +.comparison-tab__grid { display: grid; grid-template-columns: minmax(var(--tile-environment-min-width), 1fr) minmax(0, 2fr); gap: var(--segment-gap); align-items: stretch; } +@media (max-width: 900px) { .comparison-tab__grid { grid-template-columns: minmax(0, 1fr); } } +.comparison-tile { min-width: 0; padding: var(--tile-inset); border: var(--bw-hairline) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); } +.comparison-tile > h3 { margin: 0 0 var(--space-3); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } +.comparison-tile__note { margin: var(--space-3) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); } + +.comparison-score { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0 0 var(--space-2); } +.comparison-score > div { padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); } +.comparison-score dt { font-size: var(--type-table-body-size); color: var(--text-secondary); } +.comparison-score dd { margin: 0; font-size: var(--type-h2-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; } + +.comparison-delta { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; white-space: nowrap; } +.comparison-delta.is-better, .comparison-fact__value.is-better { color: var(--outcome-pass); } +.comparison-delta.is-worse, .comparison-fact__value.is-worse { color: var(--outcome-fail); } +.comparison-delta.is-neutral, .comparison-fact__value.is-neutral { color: var(--text-primary); } + +.comparison-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } +.comparison-fact { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-divider); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.comparison-fact:hover:not(:disabled) { background: var(--tile-surface-hover); } +.comparison-fact:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(-1 * var(--bw-emphasis)); } +.comparison-fact:disabled { cursor: default; } +.comparison-fact__label { font-size: var(--type-table-body-size); color: var(--text-secondary); } +.comparison-fact__value { font-size: var(--type-body-size); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; } + +.comparison-table-scroll { overflow-x: auto; } +.comparison-table td { font-variant-numeric: tabular-nums; } + +/* Finding Reader `Compare` section, collapsed like Context. */ +.finding-comparison__scope { margin-inline-start: var(--space-2); font-size: var(--type-meta-size); font-weight: var(--fw-regular); color: var(--text-secondary); } +.finding-comparison__change { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-3); margin: var(--space-2) 0; } +.finding-comparison__note { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); color: var(--text-secondary); } diff --git a/src/features/comparison/use-timeslot-comparison.ts b/src/features/comparison/use-timeslot-comparison.ts new file mode 100644 index 0000000..fec30d8 --- /dev/null +++ b/src/features/comparison/use-timeslot-comparison.ts @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { AssessorApiClient } from '../../api/assessor-api-client'; +import { groupProjectImportFiles, type ScanImportGroup } from '../project-import/datahub-import-groups'; +import type { TimeslotComparisonReport } from '../../comparison/timeslot-comparison-report'; + +const api = new AssessorApiClient(); + +export interface ComparableTimeslot { + id: string; + date: string; + assessmentIds: string[]; + scanRunIds: string[]; +} + +export type TimeslotComparisonState = + | { status: 'loading' } + | { status: 'error'; message: string } + | { status: 'unavailable'; reason: 'single-timeslot' | 'no-earlier-timeslot' } + | { status: 'ready'; report: TimeslotComparisonReport }; + +/** The comparison the Compare tab last loaded for the timeslot open in Client Info; Work reads it for its facet. */ +export interface PublishedComparison { + projectId: string; + /** Sorted assessment IDs of timeslot B, the open timeslot. */ + targetAssessmentIds: string[]; + baseline: ComparableTimeslot; + target: ComparableTimeslot; + report: TimeslotComparisonReport; +} + +const key = (values: readonly string[]) => [...values].sort().join('\u0000'); +const sameSet = (left: readonly string[], right: readonly string[]) => key(left) === key(right); + +const listeners = new Set<() => void>(); +let storeVersion = 0; +const notify = () => { storeVersion++; listeners.forEach(listener => listener()); }; +const version = () => storeVersion; +const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; +// Timeslot A chosen per client and timeslot B, shared by the Compare tab and Work. +const baselineChoices = new Map(); +let published: PublishedComparison | undefined; + +export function publishedComparison(): PublishedComparison | undefined { return published; } +/** The published comparison while it belongs to this client and open timeslot; never a comparison of another scope. */ +export function usePublishedComparison(projectId: string | undefined, openAssessmentIds: readonly string[]): PublishedComparison | undefined { + const current = useSyncExternalStore(subscribe, publishedComparison, publishedComparison); + return current && current.projectId === projectId && sameSet(current.targetAssessmentIds, openAssessmentIds) ? current : undefined; +} + +/** Test support. */ +export function resetTimeslotComparisonRequestsForTests(): void { + reports.clear(); timeslotRequests.clear(); baselineChoices.clear(); published = undefined; notify(); +} + +function comparable(group: ScanImportGroup): ComparableTimeslot { + return { id: group.id, date: group.fileDate, assessmentIds: [...group.assessmentIds].sort(), scanRunIds: [...group.scanRunIds].sort() }; +} + +const timeslotRequests = new Map>(); +/** Oldest first, the order in which a comparison reads A before B. Reloaded for every newly opened scope. */ +function loadTimeslots(projectId: string, scope: string): Promise { + const requestKey = `${projectId}\u0000${scope}`; + let request = timeslotRequests.get(requestKey); + if (!request) { + request = Promise.all([ + api.listProjectImportFiles(projectId), api.listProjectAssessmentHistory(projectId), api.listProjectGpoBackupStatus(projectId) + ]).then(([files, history, gpo]) => groupProjectImportFiles(files, history.assessments, history.scanRuns, gpo.records) + .filter(group => group.assessmentIds.length) + .map(comparable) + .sort((left, right) => Date.parse(left.date) - Date.parse(right.date) || left.id.localeCompare(right.id))); + timeslotRequests.set(requestKey, request); + while (timeslotRequests.size > 8) timeslotRequests.delete(timeslotRequests.keys().next().value!); + request.catch(() => timeslotRequests.delete(requestKey)); + } + return request; +} + +const reports = new Map>(); +function requestReport(projectId: string, baseline: ComparableTimeslot, target: ComparableTimeslot) { + const reportKey = JSON.stringify([projectId, baseline.assessmentIds, baseline.scanRunIds, target.assessmentIds, target.scanRunIds]); + let request = reports.get(reportKey); + if (!request) { + request = api.getTimeslotComparison(projectId, baseline, target); + reports.set(reportKey, request); + while (reports.size > 8) reports.delete(reports.keys().next().value!); + request.catch(() => reports.delete(reportKey)); + } + return request; +} + +/** + * Timeslots of the open client and the explicit A/B report. B defaults to the timeslot open in Client + * Info, A to the timeslot directly before it; both stay selectable, A always before B. A report whose B is + * the open timeslot is published for the Work `Change` facet. + */ +export function useTimeslotComparison(projectId: string | undefined, openAssessmentIds: readonly string[]) { + const openKey = key(openAssessmentIds); + const [timeslots, setTimeslots] = useState<{ scope: string; items?: ComparableTimeslot[]; error?: string }>(); + const [targetChoice, setTargetChoice] = useState<{ scope: string; id: string }>(); + const [result, setResult] = useState<{ key: string; state: TimeslotComparisonState }>(); + const scope = `${projectId}\u0000${openKey}`; + useSyncExternalStore(subscribe, version, version); + + useEffect(() => { + if (!projectId) return; + let current = true; + loadTimeslots(projectId, openKey).then(items => { if (current) setTimeslots({ scope, items }); }, + error => { if (current) setTimeslots({ scope, error: error instanceof Error ? error.message : 'Timeslots could not be loaded.' }); }); + return () => { current = false; }; + }, [scope]); // eslint-disable-line react-hooks/exhaustive-deps + + const items = timeslots?.scope === scope ? timeslots.items : undefined; + const open = useMemo(() => items?.find(item => sameSet(item.assessmentIds, openAssessmentIds)), [items, openKey]); // eslint-disable-line react-hooks/exhaustive-deps + const target = items?.find(item => targetChoice?.scope === scope && item.id === targetChoice.id) ?? open ?? items?.[items.length - 1]; + const earlier = items && target ? items.slice(0, items.indexOf(target)) : []; + const choiceKey = target ? JSON.stringify([projectId, target.id]) : ''; + const baseline = earlier.find(item => item.id === baselineChoices.get(choiceKey)) ?? earlier[earlier.length - 1]; + const requestKey = projectId && baseline && target ? JSON.stringify([projectId, baseline.id, target.id]) : ''; + + useEffect(() => { + if (!projectId || !baseline || !target) return; + let current = true; + requestReport(projectId, baseline, target).then(report => { + if (!current) return; + setResult({ key: requestKey, state: { status: 'ready', report } }); + if (target === open) { + published = { projectId, targetAssessmentIds: [...target.assessmentIds], baseline, target, report }; + notify(); + } + }, error => { if (current) setResult({ key: requestKey, state: { status: 'error', message: error instanceof Error ? error.message : 'The comparison could not be loaded.' } }); }); + return () => { current = false; }; + }, [requestKey]); // eslint-disable-line react-hooks/exhaustive-deps + + let state: TimeslotComparisonState; + if (timeslots?.scope === scope && timeslots.error) state = { status: 'error', message: timeslots.error }; + else if (!items) state = { status: 'loading' }; + else if (items.length < 2) state = { status: 'unavailable', reason: 'single-timeslot' }; + else if (!baseline) state = { status: 'unavailable', reason: 'no-earlier-timeslot' }; + else state = result?.key === requestKey ? result.state : { status: 'loading' }; + + return { + state, timeslots: items ?? [], baseline, target, earlier, + /** True while B is the timeslot open in Client Info, so Work shows the same records. */ + targetIsOpen: Boolean(target && target === open), + selectBaseline: (id: string) => { if (choiceKey) { baselineChoices.set(choiceKey, id); notify(); } }, + selectTarget: (id: string) => setTargetChoice({ scope, id }) + }; +} diff --git a/src/features/dashboard/DashboardPanel.tsx b/src/features/dashboard/DashboardPanel.tsx index 02bdb4e..090eccf 100644 --- a/src/features/dashboard/DashboardPanel.tsx +++ b/src/features/dashboard/DashboardPanel.tsx @@ -4,6 +4,8 @@ import { ClientSecurityPanel, type SecurityFindingGroup } from './ClientSecurity import { managementCategoryTexts } from '../../reporting/management-report-texts'; import { ClientContextHeatmap } from '../context-layer/ContextHeatmap'; import { ClientIsoControls, preloadClientComplianceOverview } from '../../compliance/ui/ClientIsoControls'; +import { ComparisonTab } from '../comparison/ComparisonTab'; +import { COMPARISON_CHANGE_FACET_ID } from '../comparison/comparison-work-facets'; import { BSI_PRACTICE_FACET_ID, BSI_REQUIREMENT_FACET_ID, ISO_CONTROL_FACET_ID, ISO_MAPPING_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets'; import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet'; import { useAssessmentContext } from '../context-layer/use-assessment-context'; @@ -1244,6 +1246,17 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { setView('columns'); onOpenWork(); }; + // Comparison Engine tab (user decision 2026-10-13): the open timeslot is B, an earlier timeslot A. + const comparisonContent = engines.comparison && app.backendStatus === 'connected' && app.targetProjectId + ?
jump(() => { + const targetSheet = sourceScanNavigationSheet(dataset, activeSheet); + if (targetSheet) app.setActiveSheet(targetSheet.id); + // A resolved finding is Passed in timeslot B; every other change keeps the standard Deviation status. + setStatusFilter(change === 'resolved' ? 'all' : 'deviation'); + setFacetFilter(COMPARISON_CHANGE_FACET_ID, { values: [change] }); + }, false)}/>
+ : null; // Compliance Engine tab: one tile per framework; a Results window opens a control or theme in Work. const complianceContent = showCompliance ? jump(() => { @@ -1483,6 +1496,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) { dataset={dataset} sheetId={activeSheet.id} toolId={activeSheet.toolId} contextStatus={impactContext.status} {...securityView}/> }]), ...(defenderContent ? [{ id: 'defender', label: 'Microsoft Defender for IoT', content: defenderContent, lazy: true }] : []), ...(complianceContent ? [{ id: 'compliance', label: 'Compliance', content: complianceContent, lazy: true }] : []), + ...(comparisonContent ? [{ id: 'compare', label: 'Compare', content: comparisonContent, lazy: true }] : []), ...(showAiOverview ? [{ id: 'ai', label: 'AI Overview', content: , lazy: true }] : [])]} /> ); } diff --git a/src/features/db-structure/EngineDataFlow.tsx b/src/features/db-structure/EngineDataFlow.tsx index afb1bc5..780b592 100644 --- a/src/features/db-structure/EngineDataFlow.tsx +++ b/src/features/db-structure/EngineDataFlow.tsx @@ -20,7 +20,9 @@ const nodes = [ { id: 'compliance', engine: true, name: 'Compliance Engine', caption: 'Regulatory control mapping · optional', description: 'Maps findings and GPO baseline comparisons to regulatory controls; ISO/IEC 27001/27002 is the first framework. Reads Core results through the read-only Core port and writes only its own tables. Proposals never change verdicts, scores or client records.' }, { id: 'compliance-tables', name: 'Compliance tables', caption: 'Mappings · decisions · batches', description: 'Mapping runs, accept/reject decisions and batches in three engine-owned tables of the canonical PostgreSQL. Written through a separate small pool; engine writes never advance the Core revision.' }, { id: 'retrieval', name: 'Retrieval & model', caption: 'Qdrant index · LiteLLM', description: 'The rebuildable Qdrant index of the uploaded control corpus and the configured LiteLLM model. They receive privacy-safe finding text and corpus evidence, never assets or customer values.' }, - { id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' } + { id: 'compliance-ui', name: 'ISO mapping in the UI', caption: 'Finding Reader · Result DB · Data Hub', description: 'Control proposals, accept/reject decisions, filtered batches and the ISO corpus upload appear through existing UI extension points. A proposal indicates relevance, not proof of compliance.' }, + { id: 'comparison', engine: true, name: 'Comparison Engine', caption: 'Timeslot comparison · optional', description: 'Compares two timeslots A and B of one client: verified resolved, still open, unverified, new and regressed findings, neutral GPO value changes and a score delta over sources comparable in both. Reads both timeslots as Work does through the read-only Core port; stores nothing yet. Verdicts, scores, reviews and Result DB stay unchanged.' }, + { id: 'comparison-ui', name: 'Comparison in the UI', caption: 'Client Info · Work · Finding Reader', description: 'The Client Info Compare tab, the Work Change facet and the Finding Reader Compare section appear through existing UI extension points. They never override technical results or client records.' } ]; const relationships = [ { from: 'input', to: 'core', label: 'Import', detail: 'Import and evaluation', path: 'M240 340 H310' }, @@ -39,6 +41,8 @@ const relationships = [ { from: 'port', to: 'compliance', label: 'Read only', detail: 'Single Result DB row by client, filtered Result DB pages, client, assessment and scan-run lists', path: 'M710 620 V750' }, { from: 'compliance', to: 'compliance-tables', label: 'Own data', detail: 'Mapping runs, decisions and batches; never Core tables', path: 'M600 800 H530', bidirectional: true }, { from: 'compliance', to: 'retrieval', label: 'Retrieval', detail: 'Privacy-safe query, retrieved control evidence and model proposals', path: 'M820 800 H890', bidirectional: true }, + { from: 'port', to: 'comparison', label: 'Read only', detail: 'Scanner runs and rolled-up GPO verdicts of both selected timeslots, read as Work reads them', path: 'M810 620 V710 H1160 V1050 H1180' }, + { from: 'comparison', to: 'comparison-ui', label: 'Changes', detail: 'Change categories and score delta through existing UI extension points', path: 'M1180 1080 H1110' }, { from: 'compliance', to: 'compliance-ui', label: 'Proposals', detail: 'Grounded control proposals and decisions through existing UI extension points', path: 'M710 850 V900 H130 V850' } ]; const nodeName = (id: string) => nodes.find(node => node.id === id)!.name; @@ -53,7 +57,7 @@ export function EngineDataFlow() {

Engines & relationships

Solid: implemented · Dashed: planned
-

{highlightText(field.label, search)}

{resolvedToolId === 'maester' && normalizeLabel(field.label) === 'id' ?

{highlightText(field.value, search)}

: resolvedToolId === 'maester' && normalizeLabel(field.label) === 'test details' diff --git a/src/features/search-filter/SearchFilterBar.tsx b/src/features/search-filter/SearchFilterBar.tsx index c7160b9..56d6fe2 100644 --- a/src/features/search-filter/SearchFilterBar.tsx +++ b/src/features/search-filter/SearchFilterBar.tsx @@ -7,6 +7,7 @@ import { DynamicFilterControls } from './DynamicFilterControls'; import { selectWorkFacets } from './work-facets'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; import { COMPLIANCE_FACET_IDS, useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets'; +import { useComparisonWorkFacets } from '../comparison/comparison-work-facets'; import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet'; import { useWorkPackages } from '../../remediation/use-work-packages'; import { WORK_PACKAGE_FACET_ID } from '../../remediation/remediation-engine'; @@ -98,11 +99,12 @@ export function SearchFilterBar({ hiddenFacetLabels = [] }: SearchFilterBarProps const remediation = useWorkPackages(); const workPackages = remediation.status === 'ready' ? remediation.index : undefined; const complianceFacets = useComplianceWorkFacets(); + const comparisonFacets = useComparisonWorkFacets(); const countedFacets = useMemo( () => measureNavigationStep('filter-counts', () => selectWorkFacets(activeSheet, { - activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets + activeProfile, search, statusFilter, facetFilters, dataset, actionOnly, reviews, pathFindingIds, contextClusters, workPackages, complianceFacets, comparisonFacets })), - [actionOnly, activeProfile, activeSheet, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter] + [actionOnly, activeProfile, activeSheet, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, statusFilter] ); const visibleFacets = useMemo( () => countedFacets.filter(facet => !hiddenFacetLabels.includes(facet.label)), diff --git a/src/features/search-filter/filter-records.ts b/src/features/search-filter/filter-records.ts index f2a667c..ef335e1 100644 --- a/src/features/search-filter/filter-records.ts +++ b/src/features/search-filter/filter-records.ts @@ -14,6 +14,7 @@ import { createSheetDerivationCache } from '../../shared/sheet-derivation-cache' import { withContextClusterFacet, type ContextClusterIndex } from '../context-layer/context-cluster-facet'; import { withBusinessImpactFacet } from './business-impact-controls'; import { withComplianceFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets'; +import { withComparisonFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets'; import { readEnginePreferences } from '../../app/engine-preferences'; import { withWorkPackageFacet, type WorkPackageIndex } from '../../remediation/work-package-index'; @@ -33,6 +34,8 @@ export interface RecordFilterOptions { workPackages?: WorkPackageIndex; /** Compliance Engine ISO control and ISO theme facets; absent while the engine is off. */ complianceFacets?: ComplianceWorkFacetIndex; + /** Comparison Engine `Change` facet; absent while the engine is off or no comparison is loaded. */ + comparisonFacets?: ComparisonWorkFacetIndex; } const cachedRecords = createSheetDerivationCache(); @@ -42,7 +45,7 @@ export function recordFilterKeys(options: RecordFilterOptions): readonly unknown return [options.activeProfile, options.dataset, options.reviews, options.search, options.sortColumn, options.sortDirection, options.statusFilter, options.actionOnly, options.pathFindingIds?.length ? options.pathFindingIds : undefined, - JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, readEnginePreferences()]; + JSON.stringify(options.facetFilters ?? {}), options.contextClusters, options.workPackages, options.complianceFacets, options.comparisonFacets, readEnginePreferences()]; } export function filterAndSortRecords(sheet: DatasetSheet | undefined, options: RecordFilterOptions): FindingRecord[] { @@ -58,9 +61,9 @@ function deriveFilteredRecords(sheet: DatasetSheet, options: RecordFilterOptions const normalizedSearch = options.search.trim().toLocaleLowerCase('en-US'); const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews); const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews); - const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' + const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' ? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy') - : baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets); + : baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets); const pathFindingIds = options.pathFindingIds?.length ? new Set(options.pathFindingIds) : undefined; const filtered = sheet.analysis.records.filter(record => { if (pathFindingIds && !pathFindingIds.has(record.id)) return false; diff --git a/src/features/search-filter/use-filtered-records.ts b/src/features/search-filter/use-filtered-records.ts index 026a88f..bf81a60 100644 --- a/src/features/search-filter/use-filtered-records.ts +++ b/src/features/search-filter/use-filtered-records.ts @@ -5,6 +5,7 @@ import { useContextClusterIndex } from '../context-layer/use-context-cluster-ind import { useWorkPackages } from '../../remediation/use-work-packages'; import { useRelatedFindings } from '../related-findings/RelatedFindings'; import { useComplianceWorkFacets } from '../../compliance/ui/compliance-work-facets'; +import { useComparisonWorkFacets } from '../comparison/comparison-work-facets'; type App = ReturnType; function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: string): [App['activeSheet'] | undefined, RecordFilterOptions] { @@ -25,6 +26,7 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str const remediation = useWorkPackages(); const workPackages = remediation.status === 'ready' ? remediation.index : undefined; const complianceFacets = useComplianceWorkFacets(); + const comparisonFacets = useComparisonWorkFacets(); const effectiveStatusFilter = statusFilterOverride ?? statusFilter; const options = useMemo(() => ({ activeProfile: activeProfile!, @@ -37,8 +39,8 @@ function useRecordFilterOptions(app: App | undefined, statusFilterOverride?: str pathFindingIds, actionOnly, reviews, - contextClusters, workPackages, complianceFacets - } as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]); + contextClusters, workPackages, complianceFacets, comparisonFacets + } as RecordFilterOptions), [activeProfile, actionOnly, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, effectiveStatusFilter, facetFilters, pathFindingIds, reviews, search, sortColumn, sortDirection]); return [activeSheet, options]; } diff --git a/src/features/search-filter/work-facets.ts b/src/features/search-filter/work-facets.ts index c07e405..3b2e956 100644 --- a/src/features/search-filter/work-facets.ts +++ b/src/features/search-filter/work-facets.ts @@ -12,6 +12,7 @@ import { withBusinessImpactFacet } from './business-impact-controls'; import { withWorkPackageFacet, workPackageCode } from '../../remediation/work-package-index'; import { comparePublishedClusters, numberedClusterLabel } from '../sidebar/context-cluster-display'; import { withComplianceFacets } from '../../compliance/ui/compliance-work-facets'; +import { withComparisonFacets } from '../comparison/comparison-work-facets'; export type WorkFacetOptions = Omit; const cache = createSheetDerivationCache(); @@ -25,9 +26,9 @@ export function selectWorkFacets(sheet: DatasetSheet | undefined, options: WorkF return cache(sheet, recordFilterKeys(query), () => { const baseFacets = deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews); const contextToolId = selectedFacetContextToolId(sheet, baseFacets, options.facetFilters, options.reviews); - const facets = withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' + const facets = withComparisonFacets(withComplianceFacets(withWorkPackageFacet(withBusinessImpactFacet(withContextClusterFacet(contextToolId === 'group-policy' ? deriveDynamicFacets(sheet, options.dataset, options.activeProfile, options.reviews, 'group-policy') - : baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets); + : baseFacets, options.contextClusters), options.contextClusters), options.workPackages), options.complianceFacets), options.comparisonFacets); const records = filterAndSortRecords(sheet, { ...query, statusFilter: 'all', facetFilters: emptyFilters }); // The engine retains its complete vocabulary; the Work control offers only reachable clusters. const selectableFacets = facets.map(facet => facet.derivedKind === 'context-cluster' diff --git a/src/features/sidebar/SidebarSecurityDomains.tsx b/src/features/sidebar/SidebarSecurityDomains.tsx index daaafa9..ebf25cc 100644 --- a/src/features/sidebar/SidebarSecurityDomains.tsx +++ b/src/features/sidebar/SidebarSecurityDomains.tsx @@ -11,6 +11,7 @@ import { selectWorkFacets } from '../search-filter/work-facets'; import type { ContextClusterIndex } from '../context-layer/context-cluster-facet'; import { useContextClusterIndex } from '../context-layer/use-context-cluster-index'; import { useComplianceWorkFacets, type ComplianceWorkFacetIndex } from '../../compliance/ui/compliance-work-facets'; +import { useComparisonWorkFacets, type ComparisonWorkFacetIndex } from '../comparison/comparison-work-facets'; import { businessImpactControls, BUSINESS_IMPACT_FACET_ID } from '../search-filter/business-impact-controls'; import { useEnginePreferences } from '../../app/engine-preferences'; import { useWorkPackages } from '../../remediation/use-work-packages'; @@ -32,6 +33,7 @@ interface SecurityDomainOptions { contextClusters?: ContextClusterIndex; workPackages?: WorkPackageIndex; complianceFacets?: ComplianceWorkFacetIndex; + comparisonFacets?: ComparisonWorkFacetIndex; } export interface SidebarSecurityDomain { @@ -47,11 +49,11 @@ export interface SidebarSecurityDomainSummary { /** Uses the exact shared Client Info facet, ordering and self-excluding counts. */ export function summarizeSecurityDomains(options: SecurityDomainOptions): SidebarSecurityDomainSummary | undefined { - const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets } = options; + const { sheet, dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets } = options; if (!sheet) return undefined; const facets = selectWorkFacets(sheet, { - dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets + dataset, activeProfile, reviews, statusFilter, facetFilters, pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets }); const domainFacet = facets.find(facet => facet.id === SECURITY_DOMAIN_FACET_ID); if (!domainFacet) return undefined; @@ -86,15 +88,16 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi const remediation = useWorkPackages(); const workPackages = remediation.status === 'ready' ? remediation.index : undefined; const complianceFacets = useComplianceWorkFacets(); + const comparisonFacets = useComparisonWorkFacets(); const engines = useEnginePreferences(); const impactCounts = useMemo(() => { if (!engines.report || !engines.context || !contextClusters) return undefined; const facet = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter: 'deviation', facetFilters, - pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID); + pathFindingIds, search, actionOnly, contextClusters, workPackages, complianceFacets, comparisonFacets }).find(item => item.id === BUSINESS_IMPACT_FACET_ID); if (!facet) return undefined; const counts = new Map(facet.values.map(value => [value.value, value.count])); return businessImpactControls.map(theme => ({ ...theme, count: counts.get(theme.id) ?? 0 })); - }, [engines.report, engines.context, complianceFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]); + }, [engines.report, engines.context, complianceFacets, comparisonFacets, contextClusters, workPackages, sheet, dataset, activeProfile, reviews, facetFilters, pathFindingIds, search, actionOnly]); const summary = useMemo(() => measureNavigationStep('security-domain-summary', () => summarizeSecurityDomains({ sheet, dataset, @@ -105,8 +108,8 @@ export function SidebarSecurityDomains({ sheet }: { sheet: DatasetSheet | undefi pathFindingIds, search, actionOnly, - contextClusters, workPackages, complianceFacets - })), [actionOnly, activeProfile, complianceFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]); + contextClusters, workPackages, complianceFacets, comparisonFacets + })), [actionOnly, activeProfile, complianceFacets, comparisonFacets, contextClusters, workPackages, dataset, facetFilters, pathFindingIds, reviews, search, sheet, statusFilter]); if (!summary) return null; const facets = selectWorkFacets(sheet, { dataset, activeProfile, reviews, statusFilter, facetFilters, diff --git a/src/styles/datahub.css b/src/styles/datahub.css index 32cf06a..e8b5444 100644 --- a/src/styles/datahub.css +++ b/src/styles/datahub.css @@ -1952,7 +1952,7 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .db-engine-flow__heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-3); } .db-engine-flow__heading h3 { margin: 0; color: var(--heading); font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } .db-engine-flow__heading > span { color: var(--muted); font-size: var(--type-table-body-size); } -.db-engine-flow__map { position: relative; width: 1450px; height: 940px; margin: 0 auto; } +.db-engine-flow__map { position: relative; width: 1450px; height: 1130px; margin: 0 auto; } .db-engine-flow__edges { position: absolute; inset: 0; overflow: visible; pointer-events: none; } .db-engine-flow__edges > path { fill: none; stroke: var(--line-strong); stroke-width: 2; } .db-engine-flow__edges > path.is-related { stroke: var(--marking-color); } @@ -1983,6 +1983,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .db-engine-flow__node[data-node="remediation"] { left: 1180px; top: 290px; } .db-engine-flow__node[data-node="remediation-tables"] { left: 1180px; top: 60px; } .db-engine-flow__node[data-node="remediation-ui"] { left: 1180px; top: 750px; } +.db-engine-flow__node[data-node="comparison"] { left: 1180px; top: 1000px; } +.db-engine-flow__node[data-node="comparison-ui"] { left: 890px; top: 1000px; } .db-engine-flow__edge-label[data-edge="input-core"] { left: 275px; top: 312px; } .db-engine-flow__edge-label[data-edge="core-database"] { left: 420px; top: 455px; } .db-engine-flow__edge-label[data-edge="database-port"] { left: 565px; top: 540px; } @@ -2000,6 +2002,8 @@ button.client-overall-ring:focus-visible { outline: var(--bw-emphasis) solid var .db-engine-flow__edge-label[data-edge="context-remediation"] { left: 1000px; top: 250px; } .db-engine-flow__edge-label[data-edge="remediation-remediation-tables"] { left: 1290px; top: 225px; } .db-engine-flow__edge-label[data-edge="remediation-remediation-ui"] { left: 1430px; top: 570px; } +.db-engine-flow__edge-label[data-edge="port-comparison"] { left: 1160px; top: 930px; } +.db-engine-flow__edge-label[data-edge="comparison-comparison-ui"] { left: 1145px; top: 1060px; } .db-engine-flow__inspector { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-5); padding: var(--tile-inset); background: var(--tile-surface); border: var(--border-hair) solid var(--tile-border); } .db-engine-flow__inspector h4 { margin: 0 0 var(--space-2); font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } .db-engine-flow__inspector p { margin: 0; font-size: var(--type-body-size); line-height: 1.5; } diff --git a/tests/app/app-version.test.ts b/tests/app/app-version.test.ts index a91fa04..e7ef23f 100644 --- a/tests/app/app-version.test.ts +++ b/tests/app/app-version.test.ts @@ -9,7 +9,7 @@ const components = readFileSync(new URL('../../src/styles/components.css', impor describe('ASSESSOR version in the brand header', () => { it('shows the centrally maintained product version and opens About from the brand', () => { - expect(ASSESSOR_PRODUCT_VERSION).toBe('2.25.0'); + expect(ASSESSOR_PRODUCT_VERSION).toBe('2.26.0'); expect(app).toContain("'db-structure': 'Data Flow'"); expect(app).toContain("['db-structure', 'Data Flow']"); expect(app).not.toContain("'DB Structure'"); diff --git a/tests/architecture/core-engine-boundary.test.ts b/tests/architecture/core-engine-boundary.test.ts index b063642..7dc0e39 100644 --- a/tests/architecture/core-engine-boundary.test.ts +++ b/tests/architecture/core-engine-boundary.test.ts @@ -54,27 +54,27 @@ const allSources = () => [...sourceFiles(join(root, 'src')), ...sourceFiles(join * Pinned Core -> Engine dependencies: the documented extension points ( * `Context Cluster` Work facet, Finding Reader `Context` section, Pathfinder and sidebar cluster * readers; Finding Reader ISO mapping, Result DB ISO batch and `Export JSON`, Data Hub ISO corpus upload, - * the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets), the + * the Client Info `AI Overview` tab and the Work `ISO control`/`ISO theme` facets; the Client Info `Compare` tab, the Work `Change` facet and the Finding Reader `Compare` section), the * type-only API contract and client, and the server wiring of the engines. */ const EXTENSION_POINTS: Record = { 'src/features/related-findings/RelatedFindings.tsx': { engine: 'Remediation Engine' }, 'src/features/search-filter/business-impact-controls.ts': { engine: ['Report Engine', 'Context Engine'] }, - 'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine'] }, + 'src/features/dashboard/DashboardPanel.tsx': { engine: ['Context Engine', 'Report Engine', 'Compliance Engine', 'Comparison Engine'] }, 'src/api/assessor-api-contract.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'], typeOnly: true }, 'src/api/assessor-api-client.ts': { engine: 'Compliance Engine', typeOnly: true }, - 'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine'] }, + 'src/features/finding-reader/FindingReader.tsx': { engine: ['Remediation Engine', 'Context Engine', 'Compliance Engine', 'Comparison Engine'] }, 'src/features/results-browser/ResultsBrowser.tsx': { engine: 'Compliance Engine' }, 'src/features/project-import/DataHub.tsx': { engine: 'Compliance Engine' }, - 'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] }, + 'src/features/actionlist/ActionlistControls.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] }, 'src/features/finding-reader/FindingContext.tsx': { engine: 'Context Engine' }, 'src/features/mitre-graph/graph-filter-bridge.ts': { engine: 'Context Engine' }, 'src/features/mitre-graph/ObsidianGraphView.tsx': { engine: 'Context Engine' }, - 'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] }, - 'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] }, - 'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] }, - 'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] }, - 'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine'] }, + 'src/features/search-filter/filter-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] }, + 'src/features/search-filter/SearchFilterBar.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] }, + 'src/features/search-filter/use-filtered-records.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] }, + 'src/features/search-filter/work-facets.ts': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] }, + 'src/features/sidebar/SidebarSecurityDomains.tsx': { engine: ['Context Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine'] }, 'server/assessor-http-server.ts': { engine: 'any' }, 'server/index.ts': { engine: ['Report Engine', 'Remediation Engine', 'Compliance Engine'] } }; diff --git a/tests/comparison/comparison-tab.test.tsx b/tests/comparison/comparison-tab.test.tsx new file mode 100644 index 0000000..242916c --- /dev/null +++ b/tests/comparison/comparison-tab.test.tsx @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import type { ImportFileRecord, ScanRun, Assessment } from '../../src/domain/canonical-model'; +import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report'; +import { ComparisonTab } from '../../src/features/comparison/ComparisonTab'; +import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison'; + +// Client Info `Compare` tab content (mockup A): A/B selection with B as the open timeslot, score with a +// labelled delta, change facts that open Work and a source table. Synthetic data only. +const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt }); +const run = (id: string, assessmentId: string, at: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: at }); +const file = (runId: string, at: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1, + importedAt: at, lastModifiedAt: at, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord; +const dates = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z', c: '2026-03-10T10:00:00.000Z' }; + +const report: TimeslotComparisonReport = { + version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-b'], targetRunIds: ['run-c'], + sources: [{ sourceId: 'cis-cat', state: 'compared', reasons: [] }, { sourceId: 'maester', state: 'not-comparable', reasons: ['baseline-source-missing'] }], + changes: [ + { sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['b-1'], counterpartIds: ['c-1'], reasons: [] }, + { sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['c-3'], counterpartIds: [], reasons: [] }, + { sourceId: 'maester', change: 'not-comparable', side: 'target', recordIds: ['m-1'], counterpartIds: [], reasons: ['baseline-source-missing'] } + ], + summary: { resolved: 1, unchanged: 0, 'value-changed': 0, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 1, targetNotComparable: 1 }, + score: { + baselineScore: 52.4, targetScore: 55.2, change: 2.8, + sources: [ + { sourceId: 'cis-cat', weight: 1, method: 'paired-scans', reasons: [], baselineScore: 52.4, targetScore: 55.2, change: 2.8 }, + { sourceId: 'maester', weight: 1, method: 'paired-scans', reasons: ['baseline-source-missing'] } + ], + excludedSources: [{ sourceId: 'maester', reasons: ['baseline-source-missing'] }] + }, + scanner: {} as TimeslotComparisonReport['scanner'] +}; + +let host: HTMLDivElement; +let root: Root; +beforeEach(() => { + resetTimeslotComparisonRequestsForTests(); + vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a), file('run-b', dates.b), file('run-c', dates.c)]); + vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ + assessments: [assessment('assessment-a', dates.a), assessment('assessment-b', dates.b), assessment('assessment-c', dates.c)], + scanRuns: [run('run-a', 'assessment-a', dates.a), run('run-b', 'assessment-b', dates.b), run('run-c', 'assessment-c', dates.c)] + } as never); + vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.restoreAllMocks(); +}); + +const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); }); + +describe('Comparison Engine Compare tab', () => { + it('compares the open timeslot with the one before it and opens Work from a change', async () => { + const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); + const onOpenChange = vi.fn(); + await act(async () => root.render()); + await settle(); + expect(request).toHaveBeenCalledWith('dummy-project', + expect.objectContaining({ assessmentIds: ['assessment-b'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] })); + const selects = [...host.querySelectorAll('select')]; + expect(selects.map(select => select.value)).toEqual(['timeslot-2026-02-10', 'timeslot-2026-03-10']); + expect([...host.querySelectorAll('.comparison-score dd')].map(item => item.textContent)).toEqual(['52', '55']); + expect(host.querySelector('.comparison-tile .comparison-delta')?.textContent).toBe('▲ +3 Better'); + const facts = [...host.querySelectorAll('.comparison-fact')]; + const fact = (label: string) => facts.find(item => item.querySelector('.comparison-fact__label')?.textContent === label)!; + expect(fact('Regressed').querySelector('.comparison-fact__value')?.className).toContain('is-worse'); + expect(fact('Not comparable').textContent).toContain('1'); + expect(fact('New').disabled).toBe(true); + await act(async () => fact('Resolved').click()); + expect(onOpenChange).toHaveBeenCalledWith('resolved', expect.objectContaining({ id: 'timeslot-2026-02-10' }), expect.objectContaining({ id: 'timeslot-2026-03-10' })); + const maester = [...host.querySelectorAll('.comparison-table tbody tr')].find(row => row.textContent?.includes('Maester')); + expect(maester?.textContent).toContain('Not in timeslot A'); + }); + + it('explains that the oldest timeslot has nothing before it', async () => { + const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison'); + await act(async () => root.render()); + await settle(); + expect(host.querySelectorAll('select')[1].value).toBe('timeslot-2026-01-10'); + expect(host.textContent).toContain('no earlier timeslot'); + expect(request).not.toHaveBeenCalled(); + }); + + it('offers only earlier timeslots as A and requests the new pair on change', async () => { + const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); + await act(async () => root.render()); + await settle(); + const [baseline] = [...host.querySelectorAll('select')]; + expect([...baseline.options].map(option => option.value)).toEqual(['timeslot-2026-01-10', 'timeslot-2026-02-10']); + await act(async () => { baseline.value = 'timeslot-2026-01-10'; baseline.dispatchEvent(new Event('change', { bubbles: true })); }); + await settle(); + expect(request).toHaveBeenLastCalledWith('dummy-project', + expect.objectContaining({ assessmentIds: ['assessment-a'] }), expect.objectContaining({ assessmentIds: ['assessment-c'] })); + }); + + it('explains a client with a single timeslot instead of comparing', async () => { + vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', dates.a)]); + vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ + assessments: [assessment('assessment-a', dates.a)], scanRuns: [run('run-a', 'assessment-a', dates.a)] + } as never); + const request = vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison'); + await act(async () => root.render()); + await settle(); + expect(host.textContent).toContain('only one timeslot'); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/comparison/comparison-work-facet.test.ts b/tests/comparison/comparison-work-facet.test.ts new file mode 100644 index 0000000..1ca258d --- /dev/null +++ b/tests/comparison/comparison-work-facet.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { FindingRecord } from '../../src/domain/finding'; +import type { DynamicFacet } from '../../src/features/search-filter/dynamic-facets'; +import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report'; +import { + changesWithWorkRecords, + comparisonWorkFacetIndex, + COMPARISON_CHANGE_FACET_ID, + withComparisonFacets +} from '../../src/features/comparison/comparison-work-facets'; +import type { PublishedComparison } from '../../src/features/comparison/use-timeslot-comparison'; + +// The Work `Change` facet maps the comparison onto Work records of timeslot B. Synthetic records only. +const gpoRecordId = (identity: string) => + `gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`; +const record = (id: string, findingOccurrenceId?: string) => ({ id, ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as FindingRecord; + +const report = { + changes: [ + { sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] }, + { sourceId: 'cis-cat', change: 'unverified', side: 'baseline', recordIds: ['a-2'], counterpartIds: [], reasons: [] }, + { sourceId: 'cis-cat', change: 'regressed', side: 'target', recordIds: ['b-3'], counterpartIds: [], reasons: [] }, + { sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'], + identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: [] }, + { sourceId: 'group-policy', change: 'new', side: 'target', recordIds: ['gb-2'], counterpartIds: [], + identityKey: JSON.stringify(['computer', 'SeEnableDelegationPrivilege']), reasons: [] } + ] +} as unknown as TimeslotComparisonReport; +const timeslot = (id: string, date: string) => ({ id, date, assessmentIds: [id], scanRunIds: [] }); +const published: PublishedComparison = { + projectId: 'dummy-project', targetAssessmentIds: ['b'], baseline: timeslot('a', '2026-01-10'), target: timeslot('b', '2026-02-10'), report +}; + +describe('Comparison Engine Work Change facet', () => { + it('assigns each timeslot B record its change and leaves unrelated records without one', () => { + const index = comparisonWorkFacetIndex(published); + expect(index.changeOf(record('work-b-1', 'b-1'))).toBe('resolved'); + expect(index.changeOf(record('work-b-3', 'b-3'))).toBe('regressed'); + expect(index.changeOf(record(gpoRecordId('MinimumPasswordLength')))).toBe('value-changed'); + expect(index.changeOf(record(gpoRecordId('SeEnableDelegationPrivilege')))).toBe('new'); + expect(index.changeOf(record('work-other', 'b-9'))).toBeUndefined(); + expect(index.facets[0].valuesFor?.(record('work-b-1', 'b-1'))).toEqual(['resolved']); + expect(index.facets[0]).toMatchObject({ id: COMPARISON_CHANGE_FACET_ID, label: 'Change' }); + }); + + it('offers only changes with a Work record in timeslot B', () => { + expect([...changesWithWorkRecords(report)].sort()).toEqual(['new', 'regressed', 'resolved', 'value-changed']); + }); + + it('places the facet directly before the permanent final Source: Scan control', () => { + const facet = (id: string) => ({ id, label: id, source: 'derived', kind: 'category', values: [] }) as unknown as DynamicFacet; + const facets = withComparisonFacets([facet('status'), facet('provenance:source-scan')], comparisonWorkFacetIndex(published)); + expect(facets.map(item => item.id)).toEqual(['status', COMPARISON_CHANGE_FACET_ID, 'provenance:source-scan']); + expect(withComparisonFacets([facet('status')], undefined).map(item => item.id)).toEqual(['status']); + }); +}); diff --git a/tests/comparison/finding-comparison.test.tsx b/tests/comparison/finding-comparison.test.tsx new file mode 100644 index 0000000..9e25af4 --- /dev/null +++ b/tests/comparison/finding-comparison.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { AssessorApiClient } from '../../src/api/assessor-api-client'; +import type { Assessment, ImportFileRecord, ScanRun } from '../../src/domain/canonical-model'; +import type { FindingRecord } from '../../src/domain/finding'; +import type { TimeslotComparisonReport } from '../../src/comparison/timeslot-comparison-report'; +import { ComparisonTab } from '../../src/features/comparison/ComparisonTab'; +import { FindingComparison } from '../../src/features/comparison/FindingComparison'; +import { resetTimeslotComparisonRequestsForTests } from '../../src/features/comparison/use-timeslot-comparison'; +import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences'; + +// Finding Reader `Compare` section: present once the Compare tab loaded a comparison of the open timeslot B, +// with both states and the GPO values side by side; absent for an unrelated record or with the engine off. +vi.mock('../../src/app/AppProvider', () => ({ + useOptionalApp: () => ({ targetProjectId: 'dummy-project', activeAssessmentIds: ['assessment-b'] }) +})); + +const at = { a: '2026-01-10T10:00:00.000Z', b: '2026-02-10T10:00:00.000Z' }; +const assessment = (id: string, effectiveAt: string): Assessment => ({ id, projectId: 'dummy-project', name: id, createdAt: effectiveAt, updatedAt: effectiveAt, effectiveAt }); +const run = (id: string, assessmentId: string, time: string): ScanRun => ({ id, assessmentId, sequence: 1, label: id, tool: { id: 'cis-cat', name: 'CIS-CAT Pro' }, importedAt: time }); +const file = (runId: string, time: string) => ({ id: `file-${runId}`, scanRunId: runId, fileName: 'synthetic.html', format: 'HTML', sizeBytes: 1, + importedAt: time, lastModifiedAt: time, detectedTool: { id: 'cis-cat', name: 'CIS-CAT Pro' } }) as unknown as ImportFileRecord; +const gpoRecordId = (identity: string) => `gpo-work-setting:${encodeURIComponent(JSON.stringify(['dummy-project', 'aggregation-b', 'computer', identity]))}`; +const record = (id: string, findingOccurrenceId?: string) => ({ id, fields: [], ...(findingOccurrenceId ? { findingOccurrenceId } : {}) }) as unknown as FindingRecord; + +const report = { + version: '1.0.0', projectId: 'dummy-project', baselineRunIds: ['run-a'], targetRunIds: ['run-b'], sources: [], + changes: [ + { sourceId: 'cis-cat', change: 'resolved', side: 'baseline', recordIds: ['a-1'], counterpartIds: ['b-1'], reasons: [] }, + { sourceId: 'group-policy', change: 'value-changed', side: 'baseline', recordIds: ['ga-1'], counterpartIds: ['gb-1'], + identityKey: JSON.stringify(['computer', 'MinimumPasswordLength']), reasons: ['same-setting-current-deviation'], + values: { baseline: ['{"kind":"number","value":7}'], target: ['{"kind":"number","value":10}'] } } + ], + summary: { resolved: 1, unchanged: 0, 'value-changed': 1, unverified: 0, 'not-comparable': 0, 'not-applicable': 0, new: 0, regressed: 0, targetNotComparable: 0 }, + score: { sources: [], excludedSources: [] }, scanner: {} +} as unknown as TimeslotComparisonReport; + +let host: HTMLDivElement; +let root: Root; +beforeEach(() => { + localStorage.clear(); resetEnginePreferencesForTests(); resetTimeslotComparisonRequestsForTests(); + vi.spyOn(AssessorApiClient.prototype, 'listProjectImportFiles').mockResolvedValue([file('run-a', at.a), file('run-b', at.b)]); + vi.spyOn(AssessorApiClient.prototype, 'listProjectAssessmentHistory').mockResolvedValue({ + assessments: [assessment('assessment-a', at.a), assessment('assessment-b', at.b)], + scanRuns: [run('run-a', 'assessment-a', at.a), run('run-b', 'assessment-b', at.b)] + } as never); + vi.spyOn(AssessorApiClient.prototype, 'listProjectGpoBackupStatus').mockResolvedValue({ records: [] } as never); + vi.spyOn(AssessorApiClient.prototype, 'getTimeslotComparison').mockResolvedValue(report); + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { + await act(async () => root.unmount()); host.remove(); + vi.restoreAllMocks(); resetEnginePreferencesForTests(); +}); +const settle = () => act(async () => { for (let index = 0; index < 5; index++) await Promise.resolve(); }); + +it('shows the comparison of a finding and of a GPO setting after the Compare tab loaded it', async () => { + await act(async () => root.render(<> + +
+
+
+ )); + await settle(); + const section = (id: string) => host.querySelector(`[data-testid="${id}"] .finding-comparison`); + expect(section('scanner')?.hasAttribute('open')).toBe(false); + expect(section('scanner')?.textContent).toContain('Resolved'); + expect(section('scanner')?.querySelector('tbody tr')?.textContent).toBe('ResultDeviationPassed'); + const gpo = section('gpo')!; + expect(gpo.textContent).toContain('Value changed'); + expect(gpo.textContent).toContain('Still a deviation'); + const valueRow = [...gpo.querySelectorAll('tbody tr')].find(row => row.textContent?.startsWith('Configured value')); + expect(valueRow?.querySelectorAll('td')[1].textContent).toContain('7'); + expect(valueRow?.querySelectorAll('td')[2].textContent).toContain('10'); + expect(section('other')).toBeNull(); +}); + +it('renders nothing while the Comparison Engine is off', async () => { + setEngineEnabled('comparison', false); + await act(async () => root.render(<> + +
+ )); + await settle(); + expect(host.querySelector('.finding-comparison')).toBeNull(); +}); diff --git a/tests/comparison/gpo-timeslot-comparison.test.ts b/tests/comparison/gpo-timeslot-comparison.test.ts index b18e601..76ed389 100644 --- a/tests/comparison/gpo-timeslot-comparison.test.ts +++ b/tests/comparison/gpo-timeslot-comparison.test.ts @@ -38,6 +38,10 @@ describe('GPO timeslot comparison', () => { ); const byIdentity = Object.fromEntries(result.decisions.map(item => [item.identity, item])); expect(byIdentity.MinimumPasswordLength).toMatchObject({ state: 'confirmed-open', valueChanged: true, targetResultIds: ['b-1'] }); + // Both retained values travel as Work displays them, for the Finding Reader's side-by-side reading. + expect(byIdentity.MinimumPasswordLength).toMatchObject({ + baselineValues: ['{"kind":"number","value":7}'], targetValues: ['{"kind":"number","value":10}'] }); + expect(byIdentity.PasswordComplexity.targetValues).toEqual([]); expect(byIdentity.LockoutBadCount).toMatchObject({ state: 'confirmed-open', valueChanged: false }); expect(byIdentity.NoLMHash).toMatchObject({ state: 'verified-remediated', reasons: ['same-setting-explicit-pass'] }); // A setting missing from the newer backup set is not proof of a removed configuration. diff --git a/tests/components/engines-control.test.tsx b/tests/components/engines-control.test.tsx index 9352a83..56b3d18 100644 --- a/tests/components/engines-control.test.tsx +++ b/tests/components/engines-control.test.tsx @@ -25,11 +25,11 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb await act(async () => trigger.click()); const dialog = document.querySelector('[role="dialog"]')!; const checks = [...dialog.querySelectorAll('input')]; - expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true]); - expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false]); - await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); }); - expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false }); - checks[4].focus(); + expect(checks.map(input => input.checked)).toEqual([true, true, true, true, true, true]); + expect(checks.map(input => input.disabled)).toEqual([true, false, false, false, false, false]); + await act(async () => { checks[0].click(); checks[1].click(); checks[4].click(); checks[5].click(); }); + expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: false, comparison: false }); + checks[5].focus(); await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))); expect(document.activeElement).toBe(dialog.querySelector('button')); await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); @@ -43,22 +43,22 @@ it.each(['dark', 'light'])('opens engine selection, locks Core and restores keyb it('persists independent choices, ignores the obsolete Context preference and cannot disable Core', () => { localStorage.setItem('ASSESSOR.contextLayer', 'off'); - expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true }); setEngineEnabled('report', false); setEngineEnabled('core', false); resetEnginePreferencesForTests(); - expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: false, remediation: true, compliance: true, comparison: true }); localStorage.setItem(ENGINE_PREFERENCES_KEY, '{"core":false,"context":false}'); resetEnginePreferencesForTests(); - expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true }); + expect(readEnginePreferences()).toEqual({ core: true, context: false, report: true, remediation: true, compliance: true, comparison: true }); }); it('defaults safely when storage is malformed or blocked and still allows session toggles', () => { localStorage.setItem(ENGINE_PREFERENCES_KEY, 'bad json'); - expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true }); resetEnginePreferencesForTests(); vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); }); vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); }); - expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true }); + expect(readEnginePreferences()).toEqual({ core: true, context: true, report: true, remediation: true, compliance: true, comparison: true }); expect(() => setEngineEnabled('context', false)).not.toThrow(); expect(readEnginePreferences().context).toBe(false); }); diff --git a/tests/features/db-structure-flow.test.tsx b/tests/features/db-structure-flow.test.tsx index 374abf2..a43621d 100644 --- a/tests/features/db-structure-flow.test.tsx +++ b/tests/features/db-structure-flow.test.tsx @@ -27,8 +27,8 @@ describe('DB Structure data flow', () => { try { await act(async () => root.render(createElement(EngineDataFlow))); const engines = [...container.querySelectorAll('.db-engine-flow__node.is-engine')]; - expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance']); - const connectionCount: Record = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4 }; + expect(engines.map(engine => engine.dataset.node)).toEqual(['core', 'context', 'report', 'remediation', 'compliance', 'comparison']); + const connectionCount: Record = { core: 3, context: 4, report: 3, remediation: 4, compliance: 4, comparison: 2 }; // Remove the initial Core selection; identity frames must remain independently. await act(async () => engines[0].click()); const highlighted = () => container.querySelectorAll('.db-engine-flow__edges > path.is-related'); @@ -60,8 +60,8 @@ describe('DB Structure data flow', () => { const container = document.createElement('div'); const root = createRoot(container); await act(async () => root.render(createElement(EngineDataFlow))); - expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(16); - expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(17); + expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18); + expect(container.querySelectorAll('.db-engine-flow__edges > path')).toHaveLength(19); expect(container.querySelectorAll('.db-engine-flow__edges > path[marker-start]')).toHaveLength(4); expect(container.querySelectorAll('.db-engine-flow__edges > path.is-related')).toHaveLength(3); expect(container.querySelector('.db-engine-flow__dictionary')?.hasAttribute('open')).toBe(false); @@ -98,12 +98,19 @@ describe('DB Structure data flow', () => { expect(container.querySelector('.db-engine-flow__inspector')?.textContent).toContain('assessor_remediation'); expect(container.querySelector('.db-engine-flow__inspector')?.textContent).not.toContain('Planned'); await act(async () => remediation.click()); - expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(16); + // The Comparison Engine reads both timeslots through the port and reaches the UI only through extension points. + const comparison = [...container.querySelectorAll('.db-engine-flow__node')] + .find(button => button.textContent?.startsWith('Comparison Engine'))!; + await act(async () => comparison.click()); + expect([...container.querySelectorAll('.db-engine-flow__inspector li strong')].map(item => item.textContent)).toEqual([ + 'Core read-only port → Comparison Engine', 'Comparison Engine → Comparison in the UI']); + await act(async () => comparison.click()); + expect(container.querySelectorAll('.db-engine-flow__node')).toHaveLength(18); const dictionary = container.querySelector('.db-engine-flow__dictionary')!; await act(async () => dictionary.querySelector('summary')!.click()); expect(dictionary.open).toBe(true); - expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (17)'); - expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(17); + expect(dictionary.querySelector('summary')?.textContent).toBe('All relationships (19)'); + expect(dictionary.querySelectorAll('tbody tr')).toHaveLength(19); act(() => root.unmount()); }); @@ -218,9 +225,9 @@ describe('DB Structure data flow', () => { expect(container.querySelector('.db-flow__header p')).toBeNull(); expect(container.querySelector('.db-flow .db-flow__header h3')?.textContent).toBe('Data flow'); expect([...container.querySelectorAll('.db-engine-flow__node.is-engine strong')].map(item => item.textContent)) - .toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine']); + .toEqual(['Core Engine', 'Context Engine', 'Report Engine', 'Remediation Engine', 'Compliance Engine', 'Comparison Engine']); const relations = container.querySelector('table[aria-label="Directed engine relationships"]'); - expect(relations?.querySelectorAll('tbody tr')).toHaveLength(17); + expect(relations?.querySelectorAll('tbody tr')).toHaveLength(19); expect(relations?.textContent).toContain('Core read-only port'); expect(relations?.textContent).not.toContain('Planned'); expect(relations?.textContent).toContain('Versioned Context publication for the Management Report'); @@ -228,8 +235,8 @@ describe('DB Structure data flow', () => { container.querySelector('.db-structure-intro-actions button')!.click(); await new Promise(resolve => setTimeout(resolve, 0)); }); - expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(5); - expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(17); + expect(container.querySelectorAll('.db-engine-flow__node.is-engine')).toHaveLength(6); + expect(container.querySelectorAll('.db-engine-flow tbody tr')).toHaveLength(19); expect(container.querySelector('.db-structure-intro-actions button')?.textContent).toBe('Refresh schema'); expect(container.querySelector('.db-flow__lane.is-reference')?.textContent).toContain('Input to GPO step'); const chips = [...container.querySelectorAll('.db-flow__table')]; diff --git a/tests/server/assessor-http-server.test.ts b/tests/server/assessor-http-server.test.ts index 6088e4d..15cb138 100644 --- a/tests/server/assessor-http-server.test.ts +++ b/tests/server/assessor-http-server.test.ts @@ -60,7 +60,7 @@ describe('local ASSESSOR HTTP server', () => { const health = await fetch(`${baseUrl}/api/v1/health`); expect(await health.json()).toEqual({ - status: 'ok', application: 'ASSESSOR', productVersion: '2.25.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' + status: 'ok', application: 'ASSESSOR', productVersion: '2.26.0', apiVersion: 1, repositoryRevision: 0, persistence: 'memory' }); expect(health.headers.get('access-control-allow-origin')).toBeNull(); });