feat(compliance): update compliance UI and logic for ISO controls
Deploy / Test, build & deploy (push) Failing after 2m39s Details

- Refactor compliance CSS to enhance layout and styling for the new Compliance row.
- Update RESULT_DB_DISCLOSURE_POLICY_VERSION to 3.8.0 for consistency across modules.
- Implement rollup logic for policy comparisons in result DB materialization.
- Enhance DashboardPanel to support theme filtering alongside control filtering.
- Introduce new ClientTileParts component for shared dashboard elements.
- Add tests for result DB materialization to ensure correct handling of GPO comparisons.
- Update existing tests to reflect changes in compliance logic and UI.
This commit is contained in:
Binh 2026-10-06 16:03:29 +02:00
parent 32c67bacce
commit 47c23a8c6e
12 changed files with 424 additions and 87 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,11 +2,15 @@ import { useEffect, useId, useState } from 'react';
import { AssessorApiClient } from '../../api/assessor-api-client'; import { AssessorApiClient } from '../../api/assessor-api-client';
import type { BackendStatus } from '../../app/app-state'; import type { BackendStatus } from '../../app/app-state';
import { useEnginePreferences } from '../../app/engine-preferences'; import { useEnginePreferences } from '../../app/engine-preferences';
import type { RegulatoryControlOverviewResponse } from '../regulatory-mapping'; import { DashboardGlyph, type DashboardFact } from '../../features/dashboard/ClientTileParts';
import { EnvironmentResultsButton } from '../../features/dashboard/EnvironmentResultsButton';
import type { RegulatoryControlOverviewItem, RegulatoryControlOverviewResponse, RegulatoryControlTheme } from '../regulatory-mapping';
import { useRegulatoryDecisionRevision } from './decision-revision'; import { useRegulatoryDecisionRevision } from './decision-revision';
import './compliance.css'; import './compliance.css';
const assessorApi = new AssessorApiClient(); const assessorApi = new AssessorApiClient();
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
export const ISO_TILE_TITLE = 'ISO 27001/27002';
interface Props { interface Props {
projectId?: string; projectId?: string;
@ -15,12 +19,36 @@ interface Props {
backendStatus: BackendStatus; backendStatus: BackendStatus;
/** Opens Work filtered to this accepted control, like a Context heatmap cell. */ /** Opens Work filtered to this accepted control, like a Context heatmap cell. */
onOpenControl(controlId: string): void; onOpenControl(controlId: string): void;
/** Opens Work filtered to this ISO theme. */
onOpenTheme(theme: RegulatoryControlTheme): void;
} }
type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse }; type OverviewState = { status: 'idle' | 'loading' } | { status: 'error' } | { status: 'ready'; overview: RegulatoryControlOverviewResponse };
/** Client Info `ISO controls`: accepted controls of the timeslot's deviations, laid out like the Context heatmap. */ const number = (value: number) => value.toLocaleString('en-US');
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl }: Props) { const count = (value: number, noun: string) => `${number(value)} ${noun}${value === 1 ? '' : 's'}`;
const byFindings = (left: RegulatoryControlOverviewItem, right: RegulatoryControlOverviewItem) =>
right.findings.length - left.findings.length || left.controlId.localeCompare(right.controlId, 'en', { numeric: true });
interface ThemeGroup { theme?: RegulatoryControlTheme; controls: RegulatoryControlOverviewItem[]; findings: number }
/** Themes in ISO 27002 order, only with hits; a control without a known theme follows without a drilldown. */
function themeGroups(controls: RegulatoryControlOverviewItem[]): ThemeGroup[] {
return [...THEMES, undefined]
.map(theme => {
const themeControls = controls.filter(control => control.theme === theme).sort(byFindings);
// A finding with several controls of one theme counts once for that theme.
const findings = new Set(themeControls.flatMap(control => control.findings.map(finding => finding.resultProjectionId))).size;
return { theme, controls: themeControls, findings };
})
.filter(group => group.controls.length > 0);
}
/**
* Client Info `Compliance` row: one tile per framework in the environment tile geometry, the complete
* control list in its Results window. ISO/IEC 27001/27002 is the first framework.
*/
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl, onOpenTheme }: Props) {
const available = useEnginePreferences().compliance; const available = useEnginePreferences().compliance;
const revision = useRegulatoryDecisionRevision(); const revision = useRegulatoryDecisionRevision();
const id = useId(); const id = useId();
@ -39,23 +67,68 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
}, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps }, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (!available || state.status === 'idle') return null; if (!available || state.status === 'idle') return null;
return <section className="iso-controls" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}> const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : [];
<header className="iso-controls__header"> // Pending values stay an em dash, never zero; all four themes follow the totals in ISO order.
<h2 id={`${id}-title`}>ISO controls</h2> const ready = state.status === 'ready';
{state.status === 'ready' && <span className="iso-controls__total" aria-label="Unique findings with an accepted ISO control"> const totals: DashboardFact[] = [
Findings: {state.overview.findingCount.toLocaleString('en-US')}</span>} { label: 'Controls', value: ready ? number(state.overview.controls.length) : '—' },
</header> { label: 'Findings', value: ready ? number(state.overview.findingCount) : '—' }];
{state.status === 'error' && <p role="alert">ISO controls could not be loaded.</p>} const themeFindings = (theme: RegulatoryControlTheme) => groups.find(group => group.theme === theme)?.findings ?? 0;
{state.status === 'ready' && !state.overview.controls.length && <p className="iso-controls__empty">No accepted ISO controls in this timeslot.</p>} // Same cell markup as the shared fact grid; a theme with hits opens Work filtered to it.
{state.status === 'ready' && state.overview.controls.length > 0 && <div className="iso-controls__grid" aria-label="Accepted ISO controls"> const tileFacts = <dl className="client-env-facts">
{state.overview.controls.map(control => <button key={control.controlId} type="button" className="iso-controls__cell" {totals.map(fact => <div key={fact.label}><dt>{fact.label}</dt><dd>{fact.value}</dd></div>)}
{THEMES.map(theme => {
const findings = themeFindings(theme);
return <div key={theme} className={ready && findings > 0 ? 'iso-tile-theme is-actionable' : 'iso-tile-theme'} data-theme={theme}>
<dt>{theme}</dt>
<dd>{!ready ? '—' : findings > 0
? <button type="button" aria-label={`Open ${theme} ISO theme findings in Work (${findings})`}
onClick={() => onOpenTheme(theme)}>{count(findings, 'finding')}</button>
: count(0, 'finding')}</dd>
</div>;
})}
</dl>;
const results = (dismiss: () => void) => {
const open = (action: () => void) => { dismiss(); action(); };
if (state.status !== 'ready') return null;
if (!groups.length) return <p className="iso-controls__empty">No accepted ISO controls in this timeslot.</p>;
return <div className="iso-controls">
<p className="iso-controls__total" aria-label="Unique findings with an accepted ISO control">Findings: {number(state.overview.findingCount)}</p>
<div className="iso-controls__themes" aria-label="Accepted ISO controls by theme">
{groups.map(({ theme, controls, findings }) => {
const summary = <><span className="iso-controls__theme-name">{theme ?? 'No ISO theme'}</span>
<span className="iso-controls__theme-count">{count(controls.length, 'control')} · {count(findings, 'finding')}</span></>;
return <div key={theme ?? 'none'} className="iso-controls__group" data-theme={theme ?? ''}>
{theme
? <button type="button" className="iso-controls__theme" aria-label={`Open ${theme} ISO theme findings in Work (${findings})`}
onClick={() => open(() => onOpenTheme(theme))}>{summary}</button>
: <div className="iso-controls__theme">{summary}</div>}
<div className="iso-controls__rows">{controls.map(control => <button key={control.controlId} type="button" className="iso-controls__row"
data-control={control.controlId} data-control={control.controlId}
aria-label={`Open ${control.annexAControlId} findings in Work (${control.findings.length})`} aria-label={`Open ${control.annexAControlId} findings in Work (${control.findings.length})`}
onClick={() => onOpenControl(control.controlId)}> onClick={() => open(() => onOpenControl(control.controlId))}>
{control.theme && <span className="iso-controls__category">{control.theme}</span>} <span className="iso-controls__id">{control.annexAControlId}</span>
<strong>{control.annexAControlId}{control.title ? ` · ${control.title}` : ''}</strong> <span className="iso-controls__title">{control.title ?? ''}</span>
<span className="iso-controls__amount">{control.findings.length.toLocaleString('en-US')} {control.findings.length === 1 ? 'finding' : 'findings'}</span> <span className="iso-controls__amount">{number(control.findings.length)}</span>
</button>)} </button>)}</div>
</div>} </div>;
})}
</div>
</div>;
};
return <section className="client-compliance" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
<h2 id={`${id}-title`}>Compliance</h2>
{/* Same tile geometry as the environment row; later frameworks add their tiles here. */}
<div className="client-compliance-grid" aria-label="Compliance frameworks">
<section className="client-env-group" data-framework="iso-27001-27002">
<header className="client-env-group-header">
<h3 className="client-env-group-title"><DashboardGlyph name="clipboard"/><span>{ISO_TILE_TITLE}</span></h3>
{state.status === 'ready' && <EnvironmentResultsButton title={ISO_TILE_TITLE}>{results}</EnvironmentResultsButton>}
</header>
{state.status === 'error' ? <p role="alert" className="iso-controls__error">ISO controls could not be loaded.</p> : tileFacts}
</section>
</div>
</section>; </section>;
} }

View File

@ -9,29 +9,49 @@
.finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; } .finding-iso-controls li { display: flex; flex-wrap: wrap; gap: var(--space-2); font-size: var(--type-body-size); line-height: 1.5; }
.finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; } .finding-iso-controls li strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
/* Client Info section, laid out like the Context heatmap with neutral cells. */ /* Client Info `Compliance` row; its tiles use the environment tile geometry. */
.iso-controls { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; } .client-compliance { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; }
.iso-controls h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); } .client-compliance > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
.iso-controls p { margin: var(--space-2) 0; font-size: var(--type-body-size); } .iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
.iso-controls__header { display: flex; align-items: baseline; justify-content: flex-start; gap: var(--space-4); flex-wrap: wrap; } /* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
.iso-controls__total { font-size: var(--type-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; } .client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
.iso-controls__empty { color: var(--text-secondary); } /* A theme fact with hits is one click target over its whole cell; hover changes the surface only. */
.iso-controls__grid { .client-compliance-grid .iso-tile-theme.is-actionable { position: relative; cursor: pointer; }
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--tile-compact-width)), var(--tile-compact-width))); .client-compliance-grid .iso-tile-theme.is-actionable:hover { background: var(--tile-surface-hover); }
gap: var(--space-2); margin-top: var(--space-3); grid-auto-rows: 1fr; .client-compliance-grid .iso-tile-theme.is-actionable:has(button:focus-visible) { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: calc(var(--bw-emphasis) * -1); }
.iso-tile-theme button {
padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; text-align: left; cursor: pointer;
} }
.iso-controls__cell { .iso-tile-theme button::after { content: ''; position: absolute; inset: 0; }
display: flex; flex-direction: column; align-items: start; gap: var(--space-2); .iso-tile-theme button:focus-visible { outline: none; }
padding: var(--space-3); min-width: 0; min-height: calc(var(--tile-h) * .75); height: auto;
border: var(--bw-emphasis) solid transparent; border-radius: var(--radius-sm); /* ISO Results window: accepted controls grouped by ISO 27002 theme as ranked rows. */
background: var(--surface-panel-raised); color: var(--text-primary); .iso-controls { color: var(--text-primary); min-width: 0; }
font-family: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere; .iso-controls__total { margin: 0; font-size: var(--type-body-size); font-weight: var(--fw-regular); color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.iso-controls__empty { margin: 0; font-size: var(--type-body-size); color: var(--text-secondary); }
.iso-controls__themes { display: grid; gap: var(--space-3); margin-top: var(--space-3); }
.iso-controls__group { display: grid; gap: var(--space-1); min-width: 0; }
/* Rows fill columns of twice the compact tile width; a partial last row keeps its track width. */
.iso-controls__rows { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, calc(var(--tile-compact-width) * 2)), 1fr)); gap: var(--space-1) var(--space-3); }
.iso-controls__theme {
display: flex; align-items: baseline; justify-content: flex-start; gap: var(--space-4); flex-wrap: wrap;
padding: var(--space-2) var(--space-1); border: 0; border-bottom: var(--bw-hairline) solid var(--tile-divider);
background: transparent; color: var(--text-primary); font-family: inherit; text-align: left;
} }
.iso-controls__cell:hover { border-color: currentColor; } button.iso-controls__theme { cursor: pointer; }
.iso-controls__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); } button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: underline; }
.iso-controls__cell strong { font-size: var(--type-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); } .iso-controls__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
.iso-controls__category { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); } .iso-controls__theme-count { font-size: var(--type-table-body-size); color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.iso-controls__amount { margin-top: auto; font-size: var(--type-body-size); font-weight: var(--fw-semibold); } .iso-controls__row {
display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: var(--space-3);
padding: var(--space-2) var(--space-3); border: var(--bw-hairline) solid var(--tile-border); border-radius: var(--radius-sm);
background: var(--tile-surface); color: var(--text-primary); font-family: inherit; text-align: left; cursor: pointer;
}
.iso-controls__row:hover { background: var(--tile-surface-hover); }
.iso-controls__row:focus-visible, button.iso-controls__theme:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
.iso-controls__id { font-size: var(--type-table-body-size); color: var(--text-secondary); font-variant-numeric: tabular-nums; white-space: nowrap; }
.iso-controls__title { font-size: var(--type-body-size); line-height: var(--lh-ui); overflow-wrap: break-word; }
.iso-controls__amount { font-size: var(--type-body-size); font-weight: var(--fw-semibold); text-align: right; font-variant-numeric: tabular-nums; }
.regulatory-earlier-controls { margin-top: 16px; } .regulatory-earlier-controls { margin-top: 16px; }
.regulatory-earlier-controls > h4 { margin-bottom: 4px; } .regulatory-earlier-controls > h4 { margin-bottom: 4px; }
.regulatory-earlier-controls > p { color: var(--text-muted); } .regulatory-earlier-controls > p { color: var(--text-muted); }

View File

@ -6,7 +6,7 @@ import { wellKnownSidName } from '../shared/windows-codes';
import { applyResultDbIdentityGuard, RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard'; import { applyResultDbIdentityGuard, RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard';
import { addedPlaceholders, type ResultDbDisclosureTrace } from './result-db-trace'; import { addedPlaceholders, type ResultDbDisclosureTrace } from './result-db-trace';
export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.6.0'; export const RESULT_DB_DISCLOSURE_POLICY_VERSION = '3.8.0';
const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z'; const SAFE_TIMESTAMP = '1970-01-01T00:00:00.000Z';

View File

@ -1,5 +1,6 @@
import { discloseResultDbProjectionWithTrace, RESULT_DB_DISCLOSURE_POLICY_VERSION } from './result-db-disclosure'; import { discloseResultDbProjectionWithTrace, RESULT_DB_DISCLOSURE_POLICY_VERSION } from './result-db-disclosure';
import { RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard'; import { RESULT_DB_IDENTITY_GUARD_VERSION, type ResultDbIdentityGuard } from './result-db-identity-guard';
import { policyBaselineVariantDecision } from './policy-evaluation';
import type { UnifiedResultProjection } from './unified-result'; import type { UnifiedResultProjection } from './unified-result';
/** /**
@ -32,7 +33,8 @@ export function materializeResultDb(
sourceRevision: number, sourceRevision: number,
materializedAt: string materializedAt: string
): MaterializedResultDb { ): MaterializedResultDb {
const rows = sourceResults.map(item => discloseResultDbProjectionWithTrace(item, guards.get(item.projectId))); const rows = rollupPolicyComparisons(sourceResults,
sourceResults.map(item => discloseResultDbProjectionWithTrace(item, guards.get(item.projectId))));
return { return {
rows, rows,
status: { status: {
@ -50,6 +52,72 @@ export function materializeResultDb(
}; };
} }
/** Work's verdict priority for a rolled-up GPO setting: Fail before Review required before Pass. */
const classificationPriority: Record<string, number> = {
misconfigured: 6, 'review-required': 5, conflict: 5, match: 4, 'not-configured': 3
};
/**
* User decision 2026-10-06: like Work, the Result DB shows one row per GPO setting (scope and
* technical identity of one aggregation run) instead of one per compared baseline release, whose
* concrete release is withheld anyway. The row lists every compared baseline family, combines their
* distinct expectations and takes the verdict of the strongest comparison. Its id is the member Work
* references first, so links from Work and the Compliance Engine keep resolving. A GPO duplicate is
* one comparison with its `Duplicates` flag and is not affected.
*/
function rollupPolicyComparisons(sources: UnifiedResultProjection[], rows: UnifiedResultProjection[]): UnifiedResultProjection[] {
const groups = new Map<string, UnifiedResultProjection[]>();
const output: Array<UnifiedResultProjection | UnifiedResultProjection[]> = [];
rows.forEach((row, index) => {
const source = sources[index];
if (!row.resultProjectionId.startsWith('policy-evaluation-comparison:') || !source.policyIdentityKey) {
output.push(row);
return;
}
const key = JSON.stringify([source.projectId, source.collectionRunId, source.policyScope, source.policyIdentityKey]);
const group = groups.get(key);
if (group) group.push(row);
else {
const created = [row];
groups.set(key, created);
output.push(created);
}
});
return output.map(entry => Array.isArray(entry) ? combinePolicyComparisons(entry) : entry);
}
function combinePolicyComparisons(group: UnifiedResultProjection[]): UnifiedResultProjection {
if (group.length === 1) return group[0];
const reference = [...group].sort((a, b) => a.sourceResultId.localeCompare(b.sourceResultId))[0];
const verdict = group.reduce((left, right) => (classificationPriority[right.policyClassification ?? ''] ?? 0)
> (classificationPriority[left.policyClassification ?? ''] ?? 0) ? right : left);
const unique = <T,>(values: T[]) => [...new Map(values.map(value => [JSON.stringify(value), value])).values()];
const families = [...new Set(group.map(row => row.policyBaselineLabel).filter((label): label is string => Boolean(label)))].sort();
const baselineValues = unique(group.map(row => row.policyBaselineValue));
const assessments = group.map(row => row.policyBaselineVariantAssessment);
const variants = unique(assessments.flatMap(assessment => assessment?.variants ?? [])
.map(variant => ({ baselineValues: variant.baselineValues, comparator: variant.comparator })))
.map((variant, index) => ({ settingId: `baseline-expectation-${index + 1}`, ...variant }));
const blockers = [...new Set(assessments.flatMap(assessment => assessment?.diagnosticCodes ?? []))].sort();
const diagnostics = [...new Set(group.flatMap(row => row.policyDiagnosticCodes ?? []))].sort();
const trace = reference.disclosureTrace!;
return {
...verdict,
resultProjectionId: reference.resultProjectionId,
sourceResultId: reference.sourceResultId,
policyBaselineLabel: families.join('; ') || undefined,
policyBaselineValue: baselineValues.length === 1 ? baselineValues[0] : undefined,
policyBaselineVariantAssessment: assessments.every(Boolean) ? {
version: 1, scope: 'backup-configuration', actualValues: [], variants, diagnosticCodes: blockers,
decision: assessments.some(assessment => assessment!.decision === 'unavailable') ? 'unavailable'
: policyBaselineVariantDecision(variants, blockers)
} : undefined,
policyDiagnosticCodes: diagnostics.length ? diagnostics : undefined,
disclosureTrace: { ...trace, redactions: [...trace.redactions,
{ field: 'row', rule: 'baseline-comparisons-combined', count: group.length - 1 }] }
};
}
/** A stored state is current only for the same repository revision and the same policy versions. */ /** A stored state is current only for the same repository revision and the same policy versions. */
export function isResultDbCurrent(status: Pick<ResultDbMaterializationStatus, 'sourceRevision' | 'disclosurePolicyVersion' | 'identityGuardVersion'> | undefined, repositoryRevision: number): boolean { export function isResultDbCurrent(status: Pick<ResultDbMaterializationStatus, 'sourceRevision' | 'disclosurePolicyVersion' | 'identityGuardVersion'> | undefined, repositoryRevision: number): boolean {
return status?.sourceRevision === repositoryRevision return status?.sourceRevision === repositoryRevision

View File

@ -0,0 +1,29 @@
/** Client Info tile parts shared by Core tiles and engine tiles in Client Info extension points. */
export interface DashboardFact {
label: string;
value: string;
}
export type DashboardGlyphName = 'domain' | 'cloud' | 'user' | 'computer' | 'server' | 'policy' | 'shield' | 'lock' | 'code' | 'chart' | 'clipboard' | 'folder' | 'scan';
export function DashboardFactList({ items }: { items: DashboardFact[] }) {
return <dl className="client-env-facts">
{items.map(item => <div className={/^\S{17,}$/.test(item.value) ? 'is-wide' : undefined} key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
</dl>;
}
export function DashboardGlyph({ name }: { name: DashboardGlyphName }) {
const content = name === 'user' ? <><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3-7 8-7s8 3 8 7"/></>
: name === 'computer' ? <><rect x="3" y="4" width="18" height="13"/><path d="M8 21h8M12 17v4"/></>
: name === 'server' ? <><rect x="4" y="3" width="16" height="7"/><rect x="4" y="14" width="16" height="7"/><path d="M8 6h.01M8 17h.01M12 6h5M12 17h5"/></>
: name === 'policy' || name === 'clipboard' ? <><path d="M7 3h8l4 4v14H7z"/><path d="M15 3v5h5M10 12h6M10 16h6"/></>
: name === 'cloud' ? <path d="M6 19h12a4 4 0 0 0 0-8 6 6 0 0 0-11.5-1.5A4.8 4.8 0 0 0 6 19z"/>
: name === 'shield' || name === 'scan' ? <><path d="M12 2l8 3v6c0 5-3.4 8.5-8 11-4.6-2.5-8-6-8-11V5z"/><path d="M9 12l2 2 4-5"/></>
: name === 'lock' ? <><rect x="5" y="10" width="14" height="11"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/></>
: name === 'code' ? <><rect x="3" y="4" width="18" height="16"/><path d="M9 9l-3 3 3 3M15 9l3 3-3 3M13 8l-2 8"/></>
: name === 'chart' ? <><path d="M3 20h18M5 17l4-5 3 3 6-9"/><path d="M18 6h3v3"/></>
: name === 'folder' ? <path d="M3 6h7l2 2h9v11H3z"/>
: <><path d="M4 21V7h6v14M14 21V3h6v18M2 21h20"/><path d="M7 10h.01M7 14h.01M17 7h.01M17 11h.01"/></>;
return <svg className="client-glyph" viewBox="0 0 24 24" aria-hidden="true">{content}</svg>;
}

View File

@ -1,7 +1,7 @@
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences'; import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap'; import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls'; import { ClientIsoControls } from '../../compliance/ui/ClientIsoControls';
import { ISO_CONTROL_FACET_ID } from '../../compliance/ui/compliance-work-facets'; import { ISO_CONTROL_FACET_ID, ISO_THEME_FACET_ID } from '../../compliance/ui/compliance-work-facets';
import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet'; import { CONTEXT_CLUSTER_FACET_ID } from '../context-layer/context-cluster-facet';
import { useAssessmentContext } from '../context-layer/use-assessment-context'; import { useAssessmentContext } from '../context-layer/use-assessment-context';
import { publishContextReport } from '../../assessment-context/context-report-publication'; import { publishContextReport } from '../../assessment-context/context-report-publication';
@ -14,6 +14,8 @@ import { downloadText } from '../../exports/text-download';
import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter'; import { browserDownloadAdapter } from '../../platform/browser/browser-download-adapter';
import { OperatingSystemInventory } from './OperatingSystemInventory'; import { OperatingSystemInventory } from './OperatingSystemInventory';
import { EnvironmentResultsButton } from './EnvironmentResultsButton'; import { EnvironmentResultsButton } from './EnvironmentResultsButton';
import { DashboardFactList, DashboardGlyph, type DashboardFact, type DashboardGlyphName } from './ClientTileParts';
export type { DashboardFact } from './ClientTileParts';
import { CloudInformationTile } from './CloudInformationTile'; import { CloudInformationTile } from './CloudInformationTile';
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables'; import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence'; import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence';
@ -567,10 +569,6 @@ export interface DashboardEnvironmentDetail {
restricted: string[]; restricted: string[];
} }
export interface DashboardFact {
label: string;
value: string;
}
const UNKNOWN_FACT = '—'; const UNKNOWN_FACT = '—';
@ -763,11 +761,6 @@ function DashboardMetricTile({ label, value, tone = 'neutral', emphasis = false,
} }
/** Source-backed summary facts appear in the tiles and remain available in Results. */ /** Source-backed summary facts appear in the tiles and remain available in Results. */
function DashboardFactList({ items }: { items: DashboardFact[] }) {
return <dl className="client-env-facts">
{items.map(item => <div className={/^\S{17,}$/.test(item.value) ? 'is-wide' : undefined} key={item.label}><dt>{item.label}</dt><dd>{item.value}</dd></div>)}
</dl>;
}
function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: { function DashboardFactGroup({ title, items, overviewItems, glyph, snapshot, evidenceGroup }: {
title: string; items: DashboardFact[]; glyph: DashboardGlyphName; title: string; items: DashboardFact[]; glyph: DashboardGlyphName;
@ -879,7 +872,6 @@ export function DashboardDonutCard({ title, meta, initiallyExpanded = true, chil
</section>; </section>;
} }
type DashboardGlyphName = 'domain' | 'cloud' | 'user' | 'computer' | 'server' | 'policy' | 'shield' | 'lock' | 'code' | 'chart' | 'clipboard' | 'folder' | 'scan';
interface DashboardSourceScore { interface DashboardSourceScore {
tool: DashboardRiskTool; tool: DashboardRiskTool;
@ -1050,20 +1042,6 @@ function dashboardGpoCount(records: FindingRecord[]): number | undefined {
return identifiers.size || undefined; return identifiers.size || undefined;
} }
function DashboardGlyph({ name }: { name: DashboardGlyphName }) {
const content = name === 'user' ? <><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3-7 8-7s8 3 8 7"/></>
: name === 'computer' ? <><rect x="3" y="4" width="18" height="13"/><path d="M8 21h8M12 17v4"/></>
: name === 'server' ? <><rect x="4" y="3" width="16" height="7"/><rect x="4" y="14" width="16" height="7"/><path d="M8 6h.01M8 17h.01M12 6h5M12 17h5"/></>
: name === 'policy' || name === 'clipboard' ? <><path d="M7 3h8l4 4v14H7z"/><path d="M15 3v5h5M10 12h6M10 16h6"/></>
: name === 'cloud' ? <path d="M6 19h12a4 4 0 0 0 0-8 6 6 0 0 0-11.5-1.5A4.8 4.8 0 0 0 6 19z"/>
: name === 'shield' || name === 'scan' ? <><path d="M12 2l8 3v6c0 5-3.4 8.5-8 11-4.6-2.5-8-6-8-11V5z"/><path d="M9 12l2 2 4-5"/></>
: name === 'lock' ? <><rect x="5" y="10" width="14" height="11"/><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v3"/></>
: name === 'code' ? <><rect x="3" y="4" width="18" height="16"/><path d="M9 9l-3 3 3 3M15 9l3 3-3 3M13 8l-2 8"/></>
: name === 'chart' ? <><path d="M3 20h18M5 17l4-5 3 3 6-9"/><path d="M18 6h3v3"/></>
: name === 'folder' ? <path d="M3 6h7l2 2h9v11H3z"/>
: <><path d="M4 21V7h6v14M14 21V3h6v18M2 21h20"/><path d="M7 10h.01M7 14h.01M17 7h.01M17 11h.01"/></>;
return <svg className="client-glyph" viewBox="0 0 24 24" aria-hidden="true">{content}</svg>;
}
/** /**
* Verbindungskurven zwischen Overall Score und den Security Domains. * Verbindungskurven zwischen Overall Score und den Security Domains.
@ -1678,7 +1656,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] }); setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
}, false)} /> }, false)} />
{/* Compliance Engine: accepted ISO controls of this timeslot; a tile selects its control in the Work ISO control filter. */} {/* Compliance Engine: `Compliance` row with one tile per framework; its Results window opens a control or theme in Work. */}
<ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds} <ClientIsoControls projectId={app.targetProjectId} assessmentIds={app.activeAssessmentIds} scanRunIds={app.activeScanRunIds}
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => { backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet); const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
@ -1687,6 +1665,14 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
setStatusFilter('deviation'); setStatusFilter('deviation');
} }
setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] }); setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] });
}, false)}
onOpenTheme={theme => jump(() => {
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
if (targetSheet) {
app.setActiveSheet(targetSheet.id);
setStatusFilter('deviation');
}
setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] });
}, false)} /> }, false)} />
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />} {scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}

View File

@ -386,6 +386,17 @@ button.client-info-metric[aria-pressed="true"] { border-color: var(--tile-select
.client-environment-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); } .client-environment-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); }
.client-environment-grid .client-env-group-title, .client-environment-grid .client-env-group-title,
.client-command-center .client-environment-grid > .client-os-inventory > header h3 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); } .client-command-center .client-environment-grid > .client-os-inventory > header h3 { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
/* Compliance Engine row (Client Info extension point): framework tiles with the exact environment tile
geometry and fact grid, kept as separate rules so the six-tile environment row stays unchanged. */
.client-compliance-grid { display: grid; grid-template-columns: repeat(6, minmax(var(--tile-environment-min-width), 1fr)); align-items: start; gap: var(--segment-gap); min-width: 0; margin-top: var(--segment-gap); overflow-x: auto; }
.client-compliance-grid > .client-env-group { display: flex; flex-direction: column; min-width: 0; aspect-ratio: 1; container: environment-tile / inline-size; padding: var(--tile-inset); border: var(--border-hair) solid var(--tile-border); border-radius: var(--tile-radius); background: var(--tile-surface); box-shadow: var(--tile-card-shadow); overflow: auto; }
.client-compliance-grid > section > header { align-items: flex-start; gap: var(--space-1); }
.client-compliance-grid .client-environment-results-button { margin-top: 0; margin-inline-start: auto; }
.client-compliance-grid > section > .client-env-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; margin: var(--space-2) 0 0; padding: 0; border: 0; background: var(--tile-surface); }
.client-compliance-grid > section > .client-env-facts > div { min-height: 0; padding: var(--space-1) var(--space-2); border: var(--border-hair) solid var(--client-info-line); background: var(--tile-surface); }
.client-compliance-grid > section > .client-env-facts > div.is-wide { grid-column: 1 / -1; }
.client-compliance-grid > section > .client-env-facts dt { font-weight: var(--fw-regular); }
.client-compliance-grid .client-env-group-title { font-size: var(--type-h4-size); font-weight: var(--fw-semibold); }
.client-env-group { min-width: 0; } .client-env-group { min-width: 0; }
.client-env-group-header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); } .client-env-group-header { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-3); }
.client-env-group-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin: 0; color: var(--client-info-muted); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; } .client-env-group-title { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); margin: 0; color: var(--client-info-muted); font-size: var(--type-h3-size); font-weight: var(--fw-bold); line-height: 1.35; }

View File

@ -13,8 +13,9 @@ import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-re
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup'; import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
import { makeSheet } from '../../src/importers/sheet-factory'; import { makeSheet } from '../../src/importers/sheet-factory';
// Client Info `ISO controls`: one tile per accepted control with its number of findings, laid out like // Client Info `Compliance` row: one ISO tile in the environment tile geometry with totals and the two themes
// the Context heatmap; a tile opens exactly its findings in Work. Switched off, nothing renders or loads. // with the most findings; its Results window lists every control by theme and opens Work from a control or
// a theme. Switched off, nothing renders or loads.
const overview: RegulatoryControlOverviewResponse = { const overview: RegulatoryControlOverviewResponse = {
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3, version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
controls: [ controls: [
@ -22,6 +23,10 @@ const overview: RegulatoryControlOverviewResponse = {
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' }, { resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' },
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }] }, { resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }] },
{ controlId: '5.15', annexAControlId: 'A.5.15', theme: 'Organizational', findings: [ { controlId: '5.15', annexAControlId: 'A.5.15', theme: 'Organizational', findings: [
{ resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] },
{ controlId: '8.9', annexAControlId: 'A.8.9', title: 'Configuration management', theme: 'Technological', findings: [
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' },
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' },
{ resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] } { resultProjectionId: 'scanner-finding:dummy-c', sourceKind: 'scanner-finding', sourceResultId: 'dummy-c' }] }
] ]
}; };
@ -29,9 +34,10 @@ const overview: RegulatoryControlOverviewResponse = {
let host: HTMLDivElement; let host: HTMLDivElement;
let root: Root; let root: Root;
const onOpenControl = vi.fn(); const onOpenControl = vi.fn();
const onOpenTheme = vi.fn();
const render = () => act(async () => { const render = () => act(async () => {
root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]} root.render(<ClientIsoControls projectId="dummy-project" assessmentIds={['dummy-assessment']} scanRunIds={[]}
backendStatus="connected" onOpenControl={onOpenControl}/>); backendStatus="connected" onOpenControl={onOpenControl} onOpenTheme={onOpenTheme}/>);
await new Promise(resolve => setTimeout(resolve, 0)); await new Promise(resolve => setTimeout(resolve, 0));
}); });
@ -40,6 +46,7 @@ beforeEach(() => {
localStorage.clear(); localStorage.clear();
resetEnginePreferencesForTests(); resetEnginePreferencesForTests();
onOpenControl.mockReset(); onOpenControl.mockReset();
onOpenTheme.mockReset();
host = document.createElement('div'); host = document.createElement('div');
document.body.append(host); document.body.append(host);
root = createRoot(host); root = createRoot(host);
@ -53,29 +60,77 @@ afterEach(async () => {
}); });
describe('Client Info ISO controls', () => { describe('Client Info ISO controls', () => {
it('shows each accepted control with its finding count and opens exactly its findings', async () => { const facts = () => [...host.querySelectorAll('[data-framework="iso-27001-27002"] .client-env-facts > div')]
.map(fact => [fact.querySelector('dt')?.textContent, fact.querySelector('dd')?.textContent]);
const resultsButton = () => host.querySelector<HTMLButtonElement>('[data-framework="iso-27001-27002"] .client-environment-results-button');
const openResults = () => act(async () => resultsButton()!.click());
const windowGroups = () => [...document.querySelectorAll<HTMLElement>('.iso-controls__group')];
it('shows a Compliance tile with totals and all four themes, and opens Work from a theme with hits', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview); const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render(); await render();
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] }); expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
expect(host.querySelector('h2')?.textContent).toBe('ISO controls'); expect(host.querySelector('.client-compliance > h2')?.textContent).toBe('Compliance');
expect(host.querySelector('.iso-controls__total')?.textContent).toBe('Findings: 3'); // Same tile geometry as the environment row.
const tiles = [...host.querySelectorAll<HTMLButtonElement>('.iso-controls__cell')]; expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
expect(tiles.map(tile => [...tile.children].map(part => part.textContent))).toEqual([ expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002');
['Technological', 'A.8.20 · Networks security', '2 findings'], // All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile.
['Organizational', 'A.5.15', '1 finding'] expect(facts()).toEqual([['Controls', '3'], ['Findings', '3'], ['Organizational', '1 finding'], ['People', '0 findings'],
['Physical', '0 findings'], ['Technological', '3 findings']]);
expect(host.querySelector('.iso-controls__row')).toBeNull();
// Only a theme with hits is a Work drilldown; Work's ISO theme facet offers no zero-hit value.
const themeButton = (theme: string) => host.querySelector<HTMLButtonElement>(`[data-theme="${theme}"] button`);
expect(themeButton('People')).toBeNull();
expect(themeButton('Physical')).toBeNull();
expect(themeButton('Technological')?.getAttribute('aria-label')).toBe('Open Technological ISO theme findings in Work (3)');
await act(async () => themeButton('Technological')!.click());
expect(onOpenTheme).toHaveBeenCalledWith('Technological');
await act(async () => themeButton('Organizational')!.click());
expect(onOpenTheme).toHaveBeenLastCalledWith('Organizational');
});
it('keeps pending values as an em dash and offers no Results before the overview loads', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockReturnValue(new Promise(() => undefined));
await render();
expect(facts()).toEqual([['Controls', '—'], ['Findings', '—'], ['Organizational', '—'], ['People', '—'], ['Physical', '—'], ['Technological', '—']]);
expect(host.querySelector('[data-theme] button')).toBeNull();
expect(resultsButton()).toBeNull();
});
it('opens the complete list by theme in the Results window and opens Work from a control or theme', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview').mockResolvedValue(overview);
await render();
await openResults();
expect(document.querySelector('.iso-controls__total')?.textContent).toBe('Findings: 3');
// ISO 27002 theme order, only themes with hits, every control ranked by finding count.
expect(windowGroups().map(group => group.dataset.theme)).toEqual(['Organizational', 'Technological']);
expect(windowGroups().map(group => group.querySelector('.iso-controls__theme')?.textContent)).toEqual([
'Organizational1 control · 1 finding', 'Technological2 controls · 3 findings'
]); ]);
await act(async () => tiles[0].click()); const rows = (group: HTMLElement) => [...group.querySelectorAll<HTMLButtonElement>('.iso-controls__row')]
.map(row => [...row.children].map(part => part.textContent));
expect(rows(windowGroups()[0])).toEqual([['A.5.15', '', '1']]);
expect(rows(windowGroups()[1])).toEqual([['A.8.9', 'Configuration management', '3'], ['A.8.20', 'Networks security', '2']]);
await act(async () => windowGroups()[1].querySelectorAll<HTMLButtonElement>('.iso-controls__row')[1].click());
expect(onOpenControl).toHaveBeenCalledWith('8.20'); expect(onOpenControl).toHaveBeenCalledWith('8.20');
// Opening Work closes the window.
expect(document.querySelector('.iso-controls')).toBeNull();
await openResults();
await act(async () => windowGroups()[1].querySelector<HTMLButtonElement>('button.iso-controls__theme')!.click());
expect(onOpenTheme).toHaveBeenCalledWith('Technological');
expect(document.querySelector('.iso-controls')).toBeNull();
}); });
it('reloads after a control decision and says so when nothing is accepted', async () => { it('reloads after a control decision and says so when nothing is accepted', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview') const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview')
.mockResolvedValueOnce({ ...overview, findingCount: 0, controls: [] }).mockResolvedValue(overview); .mockResolvedValueOnce({ ...overview, findingCount: 0, controls: [] }).mockResolvedValue(overview);
await render(); await render();
expect(host.querySelector('.iso-controls__empty')?.textContent).toBe('No accepted ISO controls in this timeslot.'); expect(facts().slice(0, 3)).toEqual([['Controls', '0'], ['Findings', '0'], ['Organizational', '0 findings']]);
await openResults();
expect(document.querySelector('.iso-controls__empty')?.textContent).toBe('No accepted ISO controls in this timeslot.');
await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); }); await act(async () => { markRegulatoryDecisionSaved(); await new Promise(resolve => setTimeout(resolve, 0)); });
expect(request).toHaveBeenCalledTimes(2); expect(request).toHaveBeenCalledTimes(2);
expect(host.querySelectorAll('.iso-controls__cell')).toHaveLength(2); expect(facts()[0]).toEqual(['Controls', '3']);
}); });
it('renders nothing and requests nothing while the Compliance Engine is off', async () => { it('renders nothing and requests nothing while the Compliance Engine is off', async () => {
@ -91,13 +146,14 @@ describe('Client Info ISO controls', () => {
expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' }); expect(requestActivity('/projects/p/regulatory-control-assignments?assessmentIds=a')).toEqual({ area: 'Compliance Engine', label: 'Loading ISO control filters' });
}); });
it('places the section below the Context heatmap and opens Work through the ISO control filter', () => { it('places the section below the Context heatmap and opens Work through the ISO control and theme filters', () => {
const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8'); const source = readFileSync(join(process.cwd(), 'src/features/dashboard/DashboardPanel.tsx'), 'utf8');
expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap')); expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap'));
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('client-environment-grid')); expect(source.indexOf('<ClientIsoControls')).toBeLessThan(source.indexOf('client-environment-grid'));
const drilldown = source.slice(source.indexOf('<ClientIsoControls'), source.indexOf('client-environment-grid')); const drilldown = source.slice(source.indexOf('<ClientIsoControls'), source.indexOf('client-environment-grid'));
expect(drilldown).toContain('setStatusFilter(\x27deviation\x27)'); expect(drilldown).toContain('setStatusFilter(\x27deviation\x27)');
expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })'); expect(drilldown).toContain('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
expect(drilldown).not.toContain('setPathFindingIds'); expect(drilldown).not.toContain('setPathFindingIds');
// A scanner finding resolves to its Work record through the occurrence id. // A scanner finding resolves to its Work record through the occurrence id.
const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true }); const sheet = makeSheet('Dummy', [['Title', 'Status'], ['Dummy check', 'Fail']], '', 'dummy.csv', { isProjectSheet: true });

View File

@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { materializeResultDb } from '../../src/domain/result-db-materialization';
const comparator = (relation: 'match' | 'different', expected: string) => ({
version: 1 as const, comparator: { kind: 'equality' as const }, relation,
actual: { kind: 'text' as const, value: '0' }, baseline: { kind: 'text' as const, value: expected }, diagnosticCodes: []
});
/** One GPO baseline comparison, as the evaluation stores it per compared baseline release. */
function comparison(id: string, overrides: Partial<UnifiedResultProjection> = {}, expected = '1'): UnifiedResultProjection {
const relation = overrides.policyClassification === 'match' ? 'match' : 'different';
return {
resultProjectionId: `policy-evaluation-comparison:${id}`, sourceKind: 'policy-evaluation', sourceResultId: id,
projectId: 'project-1', assessmentId: 'assessment-1', collectionRunId: 'aggregation-1', evaluationRunId: 'evaluation-1',
sourceTool: 'GPOs', sourceRuleId: 'gpo-identity-1', title: 'Synthetic setting · SyntheticValue',
severityOriginal: null, severityNormalized: 'unknown', outcomeOriginal: 'confirmed_configured_deviation',
outcomeNormalized: 'fail', technicalExecutionState: 'completed', resultType: 'policy_deviation',
isIoe: false, isDeviation: true, lifecycleState: 'unknown', mappingConfidence: 1,
reviewStatus: 'unreviewed', importedAt: '2026-10-06T08:00:00.000Z', createdAt: '2026-10-06T08:00:00.000Z',
policyScope: 'computer', policyDeliveryMechanism: 'registry-policy', policyIdentityKey: 'gpo-identity-1',
policyBaselineId: `baseline-${id}`, policyBaselineLabel: 'Windows Server 2022 Security Baseline',
policyBaselineValue: { kind: 'text', value: expected }, policyActualValue: { kind: 'text', value: '0' },
policyBaselineVariantAssessment: { version: 1, scope: 'backup-configuration',
decision: relation === 'match' ? 'all-pass' : 'all-fail', actualValues: [],
variants: [{ settingId: `setting-${id}`, baselineValues: [], comparator: comparator(relation, expected) }], diagnosticCodes: [] },
policyComparisonRelation: relation, policyCustomerState: 'configured', policyClassification: 'misconfigured',
frameworkReferences: [], categories: [],
...overrides
};
}
const passed = { outcomeNormalized: 'pass', isDeviation: false, policyClassification: 'match',
policyComparisonRelation: 'match' } as const;
function gpoRows(sources: UnifiedResultProjection[]): UnifiedResultProjection[] {
return materializeResultDb(sources, new Map(), 1, '2026-10-06T08:00:00.000Z').rows
.filter(row => row.sourceKind === 'policy-evaluation');
}
describe('Result DB GPO comparisons: one row per setting like Work (user decision 2026-10-06)', () => {
it('combines every baseline release and family of one setting into one row', () => {
const rows = gpoRows([
comparison('b-server-2025', { policyBaselineLabel: 'Windows Server 2025 Security Baseline' }),
comparison('a-server-2022'),
comparison('c-client', { policyBaselineLabel: 'Windows 11 v24H2 Security Baseline' })
]);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
resultProjectionId: 'policy-evaluation-comparison:a-server-2022',
policyBaselineLabel: 'Microsoft Security Baseline - Windows Client; Microsoft Security Baseline - Windows Server',
policyBaselineValue: { kind: 'text', value: '1' }
});
expect(rows[0].policyBaselineVariantAssessment?.variants).toHaveLength(1);
expect(rows[0].disclosureTrace?.redactions).toContainEqual({ field: 'row', rule: 'baseline-comparisons-combined', count: 2 });
});
it('takes the strongest verdict and keeps every distinct expectation', () => {
const [row] = gpoRows([
comparison('a-client', { ...passed, policyBaselineLabel: 'Windows 11 v24H2 Security Baseline' }, '0'),
comparison('b-server', {}, '1')
]);
expect(row).toMatchObject({ outcomeNormalized: 'fail', isDeviation: true, policyClassification: 'misconfigured',
resultProjectionId: 'policy-evaluation-comparison:a-client', policyBaselineValue: undefined });
expect(row.policyBaselineVariantAssessment).toMatchObject({ decision: 'mixed' });
expect(row.policyBaselineVariantAssessment?.variants.map(variant => variant.comparator.baseline))
.toEqual([{ kind: 'text', value: '0' }, { kind: 'text', value: '1' }]);
});
it('keeps different settings apart even when their visible content is identical', () => {
const rows = gpoRows([
comparison('a'),
comparison('b', { policyIdentityKey: 'gpo-identity-2' }),
comparison('c', { policyScope: 'user' }),
comparison('d', { collectionRunId: 'aggregation-2', assessmentId: 'assessment-2', evaluationRunId: 'evaluation-2' })
]);
expect(rows).toHaveLength(4);
});
it('never combines scanner findings', () => {
const scanner = (id: string): UnifiedResultProjection => ({
...comparison(id), resultProjectionId: `scanner-finding:${id}`, sourceKind: 'scanner-finding', sourceTool: 'maester',
sourceRuleId: 'MT.1001', title: 'Synthetic rule', resultType: 'compliance_check'
});
const { rows } = materializeResultDb([scanner('1'), scanner('2')], new Map(), 1, '2026-10-06T08:00:00.000Z');
expect(rows).toHaveLength(2);
});
});

View File

@ -49,7 +49,7 @@ describe('filtered Result DB JSONL export', () => {
recordType: 'manifest', recordType: 'manifest',
format: RESULT_DB_JSONL_FORMAT, format: RESULT_DB_JSONL_FORMAT,
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION, schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
disclosurePolicyVersion: '3.6.0', disclosurePolicyVersion: '3.8.0',
handling: RESULT_DB_EXPORT_HANDLING, handling: RESULT_DB_EXPORT_HANDLING,
generatedOn: '2026-09-03', generatedOn: '2026-09-03',
resultCount: 2, resultCount: 2,