feat(compliance): update compliance UI and logic for ISO controls
Deploy / Test, build & deploy (push) Failing after 2m39s
Details
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:
parent
32c67bacce
commit
47c23a8c6e
File diff suppressed because one or more lines are too long
|
|
@ -2,11 +2,15 @@ import { useEffect, useId, useState } from 'react';
|
|||
import { AssessorApiClient } from '../../api/assessor-api-client';
|
||||
import type { BackendStatus } from '../../app/app-state';
|
||||
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 './compliance.css';
|
||||
|
||||
const assessorApi = new AssessorApiClient();
|
||||
const THEMES: RegulatoryControlTheme[] = ['Organizational', 'People', 'Physical', 'Technological'];
|
||||
export const ISO_TILE_TITLE = 'ISO 27001/27002';
|
||||
|
||||
interface Props {
|
||||
projectId?: string;
|
||||
|
|
@ -15,12 +19,36 @@ interface Props {
|
|||
backendStatus: BackendStatus;
|
||||
/** Opens Work filtered to this accepted control, like a Context heatmap cell. */
|
||||
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 };
|
||||
|
||||
/** Client Info `ISO controls`: accepted controls of the timeslot's deviations, laid out like the Context heatmap. */
|
||||
export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds = [], backendStatus, onOpenControl }: Props) {
|
||||
const number = (value: number) => value.toLocaleString('en-US');
|
||||
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 revision = useRegulatoryDecisionRevision();
|
||||
const id = useId();
|
||||
|
|
@ -39,23 +67,68 @@ export function ClientIsoControls({ projectId, assessmentIds = [], scanRunIds =
|
|||
}, [enabled, revision, scopeKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!available || state.status === 'idle') return null;
|
||||
return <section className="iso-controls" aria-labelledby={`${id}-title`} aria-busy={state.status === 'loading'}>
|
||||
<header className="iso-controls__header">
|
||||
<h2 id={`${id}-title`}>ISO controls</h2>
|
||||
{state.status === 'ready' && <span className="iso-controls__total" aria-label="Unique findings with an accepted ISO control">
|
||||
Findings: {state.overview.findingCount.toLocaleString('en-US')}</span>}
|
||||
</header>
|
||||
{state.status === 'error' && <p role="alert">ISO controls could not be loaded.</p>}
|
||||
{state.status === 'ready' && !state.overview.controls.length && <p className="iso-controls__empty">No accepted ISO controls in this timeslot.</p>}
|
||||
{state.status === 'ready' && state.overview.controls.length > 0 && <div className="iso-controls__grid" aria-label="Accepted ISO controls">
|
||||
{state.overview.controls.map(control => <button key={control.controlId} type="button" className="iso-controls__cell"
|
||||
const groups = state.status === 'ready' ? themeGroups(state.overview.controls) : [];
|
||||
// Pending values stay an em dash, never zero; all four themes follow the totals in ISO order.
|
||||
const ready = state.status === 'ready';
|
||||
const totals: DashboardFact[] = [
|
||||
{ label: 'Controls', value: ready ? number(state.overview.controls.length) : '—' },
|
||||
{ label: 'Findings', value: ready ? number(state.overview.findingCount) : '—' }];
|
||||
const themeFindings = (theme: RegulatoryControlTheme) => groups.find(group => group.theme === theme)?.findings ?? 0;
|
||||
// Same cell markup as the shared fact grid; a theme with hits opens Work filtered to it.
|
||||
const tileFacts = <dl className="client-env-facts">
|
||||
{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}
|
||||
aria-label={`Open ${control.annexAControlId} findings in Work (${control.findings.length})`}
|
||||
onClick={() => onOpenControl(control.controlId)}>
|
||||
{control.theme && <span className="iso-controls__category">{control.theme}</span>}
|
||||
<strong>{control.annexAControlId}{control.title ? ` · ${control.title}` : ''}</strong>
|
||||
<span className="iso-controls__amount">{control.findings.length.toLocaleString('en-US')} {control.findings.length === 1 ? 'finding' : 'findings'}</span>
|
||||
</button>)}
|
||||
</div>}
|
||||
onClick={() => open(() => onOpenControl(control.controlId))}>
|
||||
<span className="iso-controls__id">{control.annexAControlId}</span>
|
||||
<span className="iso-controls__title">{control.title ?? ''}</span>
|
||||
<span className="iso-controls__amount">{number(control.findings.length)}</span>
|
||||
</button>)}</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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 strong { font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Client Info section, laid out like the Context heatmap with neutral cells. */
|
||||
.iso-controls { 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); }
|
||||
.iso-controls p { margin: var(--space-2) 0; font-size: var(--type-body-size); }
|
||||
.iso-controls__header { display: flex; align-items: baseline; justify-content: flex-start; gap: var(--space-4); flex-wrap: wrap; }
|
||||
.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; }
|
||||
.iso-controls__empty { color: var(--text-secondary); }
|
||||
.iso-controls__grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--tile-compact-width)), var(--tile-compact-width)));
|
||||
gap: var(--space-2); margin-top: var(--space-3); grid-auto-rows: 1fr;
|
||||
/* Client Info `Compliance` row; its tiles use the environment tile geometry. */
|
||||
.client-compliance { margin-block: var(--space-6) var(--space-8); color: var(--text-primary); min-width: 0; }
|
||||
.client-compliance > h2 { margin: 0; font-size: var(--type-h3-size); font-weight: var(--fw-semibold); }
|
||||
.iso-controls__error { margin: var(--space-2) 0 0; font-size: var(--type-table-body-size); }
|
||||
/* The theme facts are this tile's purpose, so they stay visible where narrow environment tiles show two facts. */
|
||||
.client-compliance-grid .client-env-facts > div:nth-child(n + 3) { display: grid; }
|
||||
/* A theme fact with hits is one click target over its whole cell; hover changes the surface only. */
|
||||
.client-compliance-grid .iso-tile-theme.is-actionable { position: relative; cursor: pointer; }
|
||||
.client-compliance-grid .iso-tile-theme.is-actionable:hover { background: var(--tile-surface-hover); }
|
||||
.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 {
|
||||
display: flex; flex-direction: column; align-items: start; gap: var(--space-2);
|
||||
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);
|
||||
background: var(--surface-panel-raised); color: var(--text-primary);
|
||||
font-family: inherit; text-align: left; cursor: pointer; overflow-wrap: anywhere;
|
||||
.iso-tile-theme button::after { content: ''; position: absolute; inset: 0; }
|
||||
.iso-tile-theme button:focus-visible { outline: none; }
|
||||
|
||||
/* ISO Results window: accepted controls grouped by ISO 27002 theme as ranked rows. */
|
||||
.iso-controls { color: var(--text-primary); min-width: 0; }
|
||||
.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; }
|
||||
.iso-controls__cell:focus-visible { outline: var(--bw-emphasis) solid var(--focus-ring); outline-offset: var(--bw-emphasis); }
|
||||
.iso-controls__cell strong { font-size: var(--type-body-size); font-weight: var(--fw-semibold); line-height: var(--lh-ui); }
|
||||
.iso-controls__category { font-size: var(--type-table-body-size); font-weight: var(--fw-regular); }
|
||||
.iso-controls__amount { margin-top: auto; font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
|
||||
button.iso-controls__theme { cursor: pointer; }
|
||||
button.iso-controls__theme:hover .iso-controls__theme-name { text-decoration: underline; }
|
||||
.iso-controls__theme-name { font-size: var(--type-body-size); font-weight: var(--fw-semibold); }
|
||||
.iso-controls__theme-count { font-size: var(--type-table-body-size); color: var(--text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.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 > h4 { margin-bottom: 4px; }
|
||||
.regulatory-earlier-controls > p { color: var(--text-muted); }
|
||||
|
|
|
|||
|
|
@ -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 { 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';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 { policyBaselineVariantDecision } from './policy-evaluation';
|
||||
import type { UnifiedResultProjection } from './unified-result';
|
||||
|
||||
/**
|
||||
|
|
@ -32,7 +33,8 @@ export function materializeResultDb(
|
|||
sourceRevision: number,
|
||||
materializedAt: string
|
||||
): 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 {
|
||||
rows,
|
||||
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. */
|
||||
export function isResultDbCurrent(status: Pick<ResultDbMaterializationStatus, 'sourceRevision' | 'disclosurePolicyVersion' | 'identityGuardVersion'> | undefined, repositoryRevision: number): boolean {
|
||||
return status?.sourceRevision === repositoryRevision
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { readEnginePreferences, useEnginePreferences } from '../../app/engine-preferences';
|
||||
import { ClientContextHeatmap } from '../context-layer/ContextHeatmap';
|
||||
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 { useAssessmentContext } from '../context-layer/use-assessment-context';
|
||||
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 { OperatingSystemInventory } from './OperatingSystemInventory';
|
||||
import { EnvironmentResultsButton } from './EnvironmentResultsButton';
|
||||
import { DashboardFactList, DashboardGlyph, type DashboardFact, type DashboardGlyphName } from './ClientTileParts';
|
||||
export type { DashboardFact } from './ClientTileParts';
|
||||
import { CloudInformationTile } from './CloudInformationTile';
|
||||
import { EnvironmentEvidenceTables } from './EnvironmentEvidenceTables';
|
||||
import { environmentEvidenceForGroup, type EnvironmentEvidenceGroup } from './environment-result-evidence';
|
||||
|
|
@ -567,10 +569,6 @@ export interface DashboardEnvironmentDetail {
|
|||
restricted: string[];
|
||||
}
|
||||
|
||||
export interface DashboardFact {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
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. */
|
||||
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 }: {
|
||||
title: string; items: DashboardFact[]; glyph: DashboardGlyphName;
|
||||
|
|
@ -879,7 +872,6 @@ export function DashboardDonutCard({ title, meta, initiallyExpanded = true, chil
|
|||
</section>;
|
||||
}
|
||||
|
||||
type DashboardGlyphName = 'domain' | 'cloud' | 'user' | 'computer' | 'server' | 'policy' | 'shield' | 'lock' | 'code' | 'chart' | 'clipboard' | 'folder' | 'scan';
|
||||
|
||||
interface DashboardSourceScore {
|
||||
tool: DashboardRiskTool;
|
||||
|
|
@ -1050,20 +1042,6 @@ function dashboardGpoCount(records: FindingRecord[]): number | 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.
|
||||
|
|
@ -1678,7 +1656,7 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
setFacetFilter(CONTEXT_CLUSTER_FACET_ID, { values: [clusterId] });
|
||||
}, 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}
|
||||
backendStatus={app.backendStatus} onOpenControl={controlId => jump(() => {
|
||||
const targetSheet = sourceScanNavigationSheet(dataset, activeSheet);
|
||||
|
|
@ -1687,6 +1665,14 @@ export function DashboardPanel({ onOpenWork }: DashboardPanelProps) {
|
|||
setStatusFilter('deviation');
|
||||
}
|
||||
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)} />
|
||||
|
||||
{scoringOpen && <ScoringMethodology sources={sourceScores} context={dataset.securityScoring} onClose={() => setScoringOpen(false)} />}
|
||||
|
|
|
|||
|
|
@ -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 .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); }
|
||||
/* 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-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; }
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ import { markRegulatoryDecisionSaved } from '../../src/compliance/ui/decision-re
|
|||
import { workRecordIdsForResult } from '../../src/features/results-browser/work-record-lookup';
|
||||
import { makeSheet } from '../../src/importers/sheet-factory';
|
||||
|
||||
// Client Info `ISO controls`: one tile per accepted control with its number of findings, laid out like
|
||||
// the Context heatmap; a tile opens exactly its findings in Work. Switched off, nothing renders or loads.
|
||||
// Client Info `Compliance` row: one ISO tile in the environment tile geometry with totals and the two themes
|
||||
// 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 = {
|
||||
version: 1, projectId: 'dummy-project', scope: { assessmentIds: ['dummy-assessment'], scanRunIds: [] }, findingCount: 3,
|
||||
controls: [
|
||||
|
|
@ -22,6 +23,10 @@ const overview: RegulatoryControlOverviewResponse = {
|
|||
{ resultProjectionId: 'scanner-finding:dummy-a', sourceKind: 'scanner-finding', sourceResultId: 'dummy-a' },
|
||||
{ resultProjectionId: 'scanner-finding:dummy-b', sourceKind: 'scanner-finding', sourceResultId: 'dummy-b' }] },
|
||||
{ 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' }] }
|
||||
]
|
||||
};
|
||||
|
|
@ -29,9 +34,10 @@ const overview: RegulatoryControlOverviewResponse = {
|
|||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const onOpenControl = vi.fn();
|
||||
const onOpenTheme = vi.fn();
|
||||
const render = () => act(async () => {
|
||||
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));
|
||||
});
|
||||
|
||||
|
|
@ -40,6 +46,7 @@ beforeEach(() => {
|
|||
localStorage.clear();
|
||||
resetEnginePreferencesForTests();
|
||||
onOpenControl.mockReset();
|
||||
onOpenTheme.mockReset();
|
||||
host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
|
|
@ -53,29 +60,77 @@ afterEach(async () => {
|
|||
});
|
||||
|
||||
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);
|
||||
await render();
|
||||
expect(request).toHaveBeenCalledWith('dummy-project', { assessmentIds: ['dummy-assessment'], scanRunIds: [] });
|
||||
expect(host.querySelector('h2')?.textContent).toBe('ISO controls');
|
||||
expect(host.querySelector('.iso-controls__total')?.textContent).toBe('Findings: 3');
|
||||
const tiles = [...host.querySelectorAll<HTMLButtonElement>('.iso-controls__cell')];
|
||||
expect(tiles.map(tile => [...tile.children].map(part => part.textContent))).toEqual([
|
||||
['Technological', 'A.8.20 · Networks security', '2 findings'],
|
||||
['Organizational', 'A.5.15', '1 finding']
|
||||
expect(host.querySelector('.client-compliance > h2')?.textContent).toBe('Compliance');
|
||||
// Same tile geometry as the environment row.
|
||||
expect(host.querySelector('.client-compliance-grid > section.client-env-group')).not.toBeNull();
|
||||
expect(host.querySelector('.client-env-group-title')?.textContent).toBe('ISO 27001/27002');
|
||||
// All four themes in ISO order; a finding counts once per theme; nothing else of the list is on the tile.
|
||||
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');
|
||||
// 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 () => {
|
||||
const request = vi.spyOn(AssessorApiClient.prototype, 'getRegulatoryControlOverview')
|
||||
.mockResolvedValueOnce({ ...overview, findingCount: 0, controls: [] }).mockResolvedValue(overview);
|
||||
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)); });
|
||||
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 () => {
|
||||
|
|
@ -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' });
|
||||
});
|
||||
|
||||
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');
|
||||
expect(source.indexOf('<ClientIsoControls')).toBeGreaterThan(source.indexOf('<ClientContextHeatmap'));
|
||||
expect(source.indexOf('<ClientIsoControls')).toBeLessThan(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('setFacetFilter(ISO_CONTROL_FACET_ID, { values: [controlId] })');
|
||||
expect(drilldown).toContain('setFacetFilter(ISO_THEME_FACET_ID, { values: [theme.toLowerCase()] })');
|
||||
expect(drilldown).not.toContain('setPathFindingIds');
|
||||
// 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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -49,7 +49,7 @@ describe('filtered Result DB JSONL export', () => {
|
|||
recordType: 'manifest',
|
||||
format: RESULT_DB_JSONL_FORMAT,
|
||||
schemaVersion: RESULT_DB_JSONL_SCHEMA_VERSION,
|
||||
disclosurePolicyVersion: '3.6.0',
|
||||
disclosurePolicyVersion: '3.8.0',
|
||||
handling: RESULT_DB_EXPORT_HANDLING,
|
||||
generatedOn: '2026-09-03',
|
||||
resultCount: 2,
|
||||
|
|
|
|||
Loading…
Reference in New Issue