281 lines
20 KiB
TypeScript
281 lines
20 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { FindingRecord } from '../../src/domain/finding';
|
|
import type { EnvironmentSnapshot } from '../../src/domain/environment-context';
|
|
import { dashboardCisCatSeverityRemainder, dashboardDomainInformation, dashboardEnvironmentSummary, dashboardExplicitIoeCount, dashboardExplicitIoeRecordIds, dashboardOverallScore, dashboardPingCastleRiskCategories, dashboardRiskTools, dashboardScanSourceLabel } from '../../src/features/dashboard/DashboardPanel';
|
|
import { securityDomainCatalog } from '../../src/features/dashboard/security-domains';
|
|
|
|
const component = readFileSync(new URL('../../src/features/dashboard/DashboardPanel.tsx', import.meta.url), 'utf8');
|
|
const styles = readFileSync(new URL('../../src/styles/datahub.css', import.meta.url), 'utf8');
|
|
|
|
describe('dashboard reference layout', () => {
|
|
it('uses the requested summary-to-action client information hierarchy', () => {
|
|
expect(component).toContain('className="client-overview-header"');
|
|
expect(component).toContain('<WorkTimeslotControl />');
|
|
expect(component.match(/<DashboardTopFact/g)).toHaveLength(6);
|
|
expect(component).toContain('className="client-overview-main"');
|
|
expect(component).toContain('Overall Score');
|
|
expect(component).toContain('Security Domains');
|
|
expect(component).toContain('<h2 id="client-priority-heading">Data</h2>');
|
|
expect(component).toContain('Domain Information');
|
|
expect(component).not.toContain('className="client-assessment-gaps"');
|
|
expect(component).toContain('<span>Gaps</span>');
|
|
expect(component).toContain('<EnvironmentResultsButton title="Overall Score">');
|
|
expect(component).toContain('<RiskRegister dataset={dataset} reviews={reviews} scopedRiskAcceptances={app.scopedRiskAcceptances}');
|
|
expect(component).toContain('onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))} />');
|
|
expect(styles).toMatch(/\.dashboard-page\s*\{[^}]*container-type:\s*inline-size;/s);
|
|
expect(component).toContain('className={`client-posture tone-${overallTone}`}');
|
|
expect(styles).toMatch(/\.dashboard-page\.client-command-center\s*\{[^}]*--client-info-surface-raised:\s*var\(--tile-surface\);[^}]*background:\s*var\(--surface-app\);/s);
|
|
expect(component).not.toContain('className="client-overview-facts"');
|
|
expect(component).toContain('className="client-data-facts"');
|
|
expect(styles).not.toContain('.client-overview-fact');
|
|
expect(styles).toMatch(/\.client-data-facts\s*\{[^}]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\);/s);
|
|
expect(styles).toMatch(/\.client-data-fact\s*\{[^}]*background:\s*transparent;/s);
|
|
expect(styles).toMatch(/\.client-overview-main\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);[^}]*align-items:\s*stretch;/s);
|
|
expect(styles).toMatch(/\.client-priority\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);[^}]*align-content:\s*stretch;[^}]*align-items:\s*stretch;/s);
|
|
expect(styles).not.toContain('.client-assessment-gaps');
|
|
expect(styles).toMatch(/@container \(max-width: 760px\)[\s\S]*?\.client-priority\s*\{[^}]*grid-template-columns:\s*1fr;/s);
|
|
expect(styles).toMatch(/\.client-posture\s*\{[^}]*grid-template-columns:\s*minmax\(300px, \.82fr\) minmax\(430px, 1\.18fr\);/s);
|
|
expect(styles).toMatch(/\.client-risk-register-layout\s*\{[^}]*grid-template-columns:\s*clamp\(420px, 27vw, 520px\) minmax\(0, 1fr\);[^}]*height:\s*calc\(var\(--tile-content-min-height\) \* 2\.5\);/s);
|
|
expect(styles).toMatch(/\.client-risk-register-navigation\s*\{[^}]*border-right:\s*var\(--bw-hairline\) solid var\(--border-default\);/s);
|
|
expect(styles).toMatch(/\.client-risk-register-information\s*\{[^}]*padding:\s*var\(--space-5\) var\(--space-6\);[^}]*overflow-y:\s*auto;/s);
|
|
expect(styles).toMatch(/\.client-risk-register-list button\[aria-pressed="true"\]\s*\{[^}]*box-shadow:\s*inset var\(--marker-width\) 0 0 var\(--marking-color\);/s);
|
|
expect(component).toContain('onOpenFinding={recordId => jump(() => setPathFindingIds([recordId]))}');
|
|
});
|
|
|
|
it('keeps source-balanced score, coverage and critical guardrail separate', () => {
|
|
expect(dashboardOverallScore([{ score: 90 }, { score: 30 }, { score: undefined }])).toBe(60);
|
|
expect(dashboardOverallScore([{ score: undefined }])).toBeUndefined();
|
|
expect(component).toContain('<DashboardOverallRing score={overallScore} onClick={() => jump(() => undefined, false)} />');
|
|
expect(component).toContain('Assessment Coverage');
|
|
expect(component).not.toContain('className="client-overall-status');
|
|
expect(component).not.toContain('className="client-overall-critical"');
|
|
expect(component).not.toContain('className="client-overall-secondary"');
|
|
expect(component).not.toContain('<dt>Lowest domain pass rate</dt>');
|
|
expect(component).toContain('dashboardOverallScore(sourceScores)');
|
|
expect(component).toContain("name: 'PingCastle'");
|
|
expect(component).toContain("name: 'GPO Analysis'");
|
|
expect(component).toContain("className=\"client-posture-connectors\"");
|
|
expect(styles).toMatch(/\.client-overall-coverage\s*\{[^}]*width:\s*min\(70%, var\(--tile-content-width\)\);/s);
|
|
expect(component).not.toContain('Design concept');
|
|
expect(component).not.toContain('Illustrative data');
|
|
});
|
|
|
|
it('shows the six structured security domains and all available sources', () => {
|
|
// User decision 2026-10-04: six domains; GPO is a delivery path, not a domain.
|
|
expect(securityDomainCatalog.map(domain => domain.label)).toEqual([
|
|
'Identity & Authentication', 'Privileged Access & Delegation', 'AD Infrastructure & PKI',
|
|
'System Hardening', 'Cloud & Collaboration', 'Monitoring & Recovery'
|
|
]);
|
|
expect(component).toContain('orderedSourceScores.map');
|
|
expect(component).toContain('nativePingCastleRisk');
|
|
expect(component).toContain('dashboardEnvironmentTableNumber');
|
|
expect(component).not.toContain('className="client-source-overview"');
|
|
expect(component).toContain('className="client-environment-sources"');
|
|
expect(styles).toMatch(/\.client-environment-sources ul\s*\{[^}]*list-style:\s*none;/s);
|
|
expect(styles).toMatch(/\.client-environment-sources li > button\s*\{[^}]*cursor:\s*pointer;/s);
|
|
expect(styles).not.toContain('.client-source-overview');
|
|
expect(component).toContain('dismissResults(); setScoringOpen(true);');
|
|
expect(styles).toMatch(/\.client-overall-ring-track\s*\{\s*stroke:\s*var\(--donut-track\);/);
|
|
expect(component).toContain('<span className="client-domain-copy"><strong>{domain.label}</strong></span>');
|
|
expect(component).toContain('className="client-domain-amount-bar" aria-hidden="true"');
|
|
expect(component).toContain('const domainDistributionTotal = displayedDomains.reduce');
|
|
expect(styles).toMatch(/\.client-domains ul\s*\{[^}]*grid-template-columns:\s*42px max-content minmax\(var\(--space-8\), 1fr\) max-content;/s);
|
|
expect(styles).toMatch(/\.client-domains li\s*\{[^}]*grid-template-columns:\s*subgrid;/s);
|
|
expect(styles).toMatch(/\.client-domain-score\s*\{[^}]*grid-column:\s*1;[^}]*grid-row:\s*1;/s);
|
|
expect(styles).toMatch(/\.client-domain-amount-bar\s*\{[^}]*grid-column:\s*3;[^}]*height:\s*var\(--space-6\);/s);
|
|
expect(styles).toMatch(/\.client-domain-amount-bar > i\s*\{[^}]*right:\s*0;[^}]*width:\s*var\(--share\);/s);
|
|
expect(styles).toMatch(/\.client-domain-amount-bar > i\s*\{[^}]*background:\s*var\(--chart-color, var\(--client-info-neutral\)\);/s);
|
|
expect(component).toContain('const overallTone = dashboardScoreTone(overallScore);');
|
|
expect(component).toContain('className={`client-posture tone-${overallTone}`}');
|
|
expect(styles).not.toContain('--domain-amount-color');
|
|
expect(component).not.toContain('client-domain-copy"><strong>{domain.label}</strong><small>');
|
|
expect(styles).toMatch(/\.client-domain-copy strong\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
|
expect(styles).toMatch(/\.client-domains li > div > b\s*\{[^}]*grid-column:\s*4;[^}]*text-align:\s*right;/s);
|
|
});
|
|
|
|
it('uses deviations as the finding base and shows other result states separately', () => {
|
|
expect(component).toContain("records: domainRecords.filter(record => states.get(record) === 'deviation')");
|
|
// Every check has exactly one of the six domains; there is no Unclassified node any more.
|
|
expect(component).not.toContain("label: 'Unclassified'");
|
|
expect(component).toContain('const displayedDomains = domains;');
|
|
expect(component).not.toContain('total deviations</span>');
|
|
expect(component).not.toContain("<small>{reviewOnly ? 'Review required' : 'Deviations'}</small>");
|
|
expect(component).toContain("<b title={reviewOnly ? 'Review required' : 'Deviations'}>");
|
|
expect(component).toContain('className="client-priority-context"');
|
|
expect(component).toContain('className="datahub-reference-knob client-priority-total"');
|
|
expect(component).toContain('percentage={share(deviations, total)} tone="deviation"');
|
|
expect(component.indexOf('className="client-priority-findings"')).toBeLessThan(component.indexOf('className="client-domain-information"'));
|
|
expect(component).not.toContain('criticalSeverity');
|
|
expect(component).not.toContain('highSeverity');
|
|
expect(styles).toMatch(/\.client-priority-findings > header\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/s);
|
|
expect(styles).toMatch(/\.client-priority-total\s*\{[^}]*--donut-ring-size:\s*var\(--tile-compact-width\);[^}]*width:\s*var\(--tile-compact-width\);[^}]*min-height:\s*0;[^}]*padding:\s*0;[^}]*cursor:\s*pointer;/s);
|
|
expect(styles).toMatch(/\.client-priority-total\s*\{[^}]*border-color:\s*var\(--segment-border\);/s);
|
|
expect(styles).not.toContain('.client-priority-total .donut-metric-card__plot');
|
|
expect(styles).not.toMatch(/\.client-priority-total\s*\{[^}]*(?:background|border-radius|box-shadow):/s);
|
|
expect(styles).not.toContain('.client-priority-total .donut-metric-card__title');
|
|
expect(component).not.toContain('<ol>{priorityRecords.length');
|
|
expect(component).not.toContain('No critical or high deviations');
|
|
expect(component).not.toContain('View findings →');
|
|
['Passed', 'Not configured', 'Duplicates', 'Conflicts']
|
|
.forEach(label => expect(component).toContain(`'${label}'`));
|
|
expect(styles).toMatch(/\.client-priority-context\s*\{[^}]*grid-template-columns:\s*repeat\(5, minmax\(0, 1fr\)\);/s);
|
|
expect(component.indexOf('className="client-data-facts"')).toBeGreaterThan(component.indexOf('className="client-priority-context"'));
|
|
expect((component.match(/<DashboardTopFact /g) ?? [])).toHaveLength(6);
|
|
expect(styles).not.toContain('.client-priority-findings ol');
|
|
expect(styles).not.toContain('.client-priority-findings { display: grid;');
|
|
expect(styles).toMatch(/\.client-domain-information > header h2\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
|
expect(styles).toMatch(/\.client-domain-information dl > div:first-child\s*\{[^}]*grid-column:\s*1 \/ -1;/s);
|
|
expect(styles).toMatch(/\.client-domain-information dt\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
|
expect(styles).toMatch(/\.client-env-facts dt\s*\{[^}]*font-weight:\s*var\(--fw-bold\);/s);
|
|
});
|
|
|
|
it('derives card values only from normalized environment snapshot contracts', () => {
|
|
const observation = (property: string, label: string, value: string | number | boolean, sectionKey?: string) => ({
|
|
version: 1 as const, id: `${property}-${label}`, scanRunId: 'run-1', importFileId: 'file-1',
|
|
tool: { id: 'synthetic', name: 'Synthetic', version: '1' }, subject: { type: 'directory' },
|
|
property, label, originalValue: value, normalizedValue: value, valueType: typeof value === 'number' ? 'number' as const : typeof value === 'boolean' ? 'boolean' as const : 'string' as const,
|
|
state: 'observed' as const, sectionKey, source: { rowIndex: 0 }, mappingVersion: '1', attributes: {}
|
|
});
|
|
const snapshot = {
|
|
version: 1, projectId: 'project-1', scanRunIds: ['run-1'], generatedAt: '2026-08-25T00:00:00.000Z',
|
|
coverage: { observed: 4, explicitlyAbsent: 1, empty: 2, restricted: 3, incomplete: 0 },
|
|
sections: [{ version: 1, id: 'section-1', scanRunId: 'run-1', importFileId: 'file-1', tool: { id: 'synthetic', name: 'Synthetic', version: '1' }, sectionKey: 'sectionAdminGroups', label: 'Groups', ordinal: 1, state: 'license-restricted', tables: [], source: { rowIndex: 0 }, extractorVersion: '1', attributes: {} }],
|
|
observations: [
|
|
observation('domain.fqdn', 'Domain', 'example.local'),
|
|
observation('risk.anomalies', 'Anomalies score', 42),
|
|
observation('admin.name', 'Name', 'Admin A', 'sectionAdminGroups')
|
|
]
|
|
} satisfies EnvironmentSnapshot;
|
|
const summary = dashboardEnvironmentSummary(snapshot);
|
|
expect(summary.directoryFacts[0]).toEqual({ label: 'Domain', value: 'example.local' });
|
|
expect(summary.directoryFacts.map(item => item.label)).not.toContain('PingCastle');
|
|
expect(summary.coverage.map(item => item.value)).toEqual([4, 2, 1, 3, 0]);
|
|
expect(summary.identity[0]).toMatchObject({ label: 'Admin groups', value: 1, restricted: true });
|
|
expect(summary.risk[1]).toMatchObject({ label: 'Anomalies', value: 42 });
|
|
});
|
|
|
|
it('shows source-backed domain facts beside priority findings without inferred zeroes', () => {
|
|
const observation = (property: string, label: string, value: string | number) => ({
|
|
version: 1 as const, id: `${property}-${label}`, scanRunId: 'run-1', importFileId: 'file-1',
|
|
tool: { id: 'synthetic', name: 'Synthetic', version: '1' }, subject: { type: 'directory' },
|
|
property, label, originalValue: value, normalizedValue: value, valueType: typeof value === 'number' ? 'number' as const : 'string' as const,
|
|
state: 'observed' as const, source: { rowIndex: 0 }, mappingVersion: '1', attributes: {}
|
|
});
|
|
const snapshot = {
|
|
version: 1, projectId: 'project-1', scanRunIds: ['run-1'], generatedAt: '2026-09-20T00:00:00.000Z',
|
|
coverage: { observed: 3, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }, sections: [],
|
|
observations: [
|
|
observation('domain.fqdn', 'Domain', 'example.local'),
|
|
observation('domain.netbios_name', 'Netbios Name', 'EXAMPLE'),
|
|
observation('forest.name', 'Forest', 'example.local'),
|
|
observation('domain.functional_level', 'Domain Functional Level', 'Windows Server 2016'),
|
|
observation('domain.controller_count', 'DC count', 3),
|
|
observation('forest.functional_level', 'Forest Functional Level', 'Windows Server 2016'),
|
|
observation('forest.schema_version', 'Schema version', 'Windows Server 2019'),
|
|
observation('domain.created_at', 'Creation date', '2026-05-27 10:24:53Z'),
|
|
observation('entra.configuration_detected', 'Entra ID configuration detected', 'Not detected')
|
|
]
|
|
} satisfies EnvironmentSnapshot;
|
|
|
|
expect(dashboardDomainInformation(snapshot)).toEqual([
|
|
{ label: 'Domain', value: 'example.local' },
|
|
{ label: 'NetBIOS name', value: 'EXAMPLE' },
|
|
{ label: 'Forest', value: 'example.local' },
|
|
{ label: 'Domains', value: '1' },
|
|
{ label: 'Domain controllers', value: '3' },
|
|
{ label: 'Domain functional level', value: 'Windows Server 2016' },
|
|
{ label: 'Forest functional level', value: 'Windows Server 2016' },
|
|
{ label: 'DC OS', value: '—' },
|
|
{ label: 'Schema version', value: 'Windows Server 2019' },
|
|
{ label: 'Domain created', value: '2026-05-27 10:24:53Z' },
|
|
{ label: 'Entra ID', value: 'Not detected' }
|
|
]);
|
|
});
|
|
|
|
it('shows DC OS only from observed domain-controller rows and retains distinct DC versions', () => {
|
|
const os = (id: string, type: string, value: string, state: 'observed' | 'unknown' = 'observed') => ({
|
|
version: 1 as const, id, scanRunId: 'run-1', importFileId: 'file-1',
|
|
tool: { id: 'synthetic', name: 'Synthetic', version: '1' }, subject: { type },
|
|
property: 'operating_system.name', label: 'Operating System', originalValue: value,
|
|
normalizedValue: value, valueType: 'string' as const, state,
|
|
source: { rowIndex: 0 }, mappingVersion: '1', attributes: {}
|
|
});
|
|
const base = { version: 1 as const, projectId: 'project-1', scanRunIds: ['run-1'],
|
|
generatedAt: '2026-09-24T00:00:00.000Z',
|
|
coverage: { observed: 0, explicitlyAbsent: 0, empty: 0, restricted: 0, incomplete: 0 }, sections: [] };
|
|
const general = os('general', 'computer-information.operating-systems', 'Windows Server 2022');
|
|
const dc = os('dc', 'computer-information.domain-controllers', 'Windows 2019');
|
|
expect(dashboardDomainInformation({ ...base, observations: [general] }).find(item => item.label === 'DC OS')?.value).toBe('—');
|
|
expect(dashboardDomainInformation({ ...base, observations: [general, dc, os('dc2', dc.subject.type, 'Windows 2019'),
|
|
os('dc3', dc.subject.type, 'Windows Server 2025'), os('unknown', dc.subject.type, 'Windows 2008', 'unknown')] })
|
|
.find(item => item.label === 'DC OS')?.value).toBe('Windows 2019, Windows Server 2025');
|
|
});
|
|
|
|
it('removes only a trailing scan timestamp from source labels', () => {
|
|
expect(dashboardScanSourceLabel('PurpleKnight · 2026-08-22T08:15:30.000Z')).toBe('PurpleKnight');
|
|
expect(dashboardScanSourceLabel('CIS-CAT Pro · 2026-08-22')).toBe('CIS-CAT Pro');
|
|
expect(dashboardScanSourceLabel('Scanner · Production')).toBe('Scanner · Production');
|
|
});
|
|
|
|
it('groups scan runs into one selectable risk profile per tool', () => {
|
|
expect(dashboardRiskTools([
|
|
{ value: 'pingcastle-a', label: 'PingCastle · 2026-08-22T08:15:30.000Z', count: 20 },
|
|
{ value: 'pingcastle-b', label: 'PingCastle · 2026-08-23T08:15:30.000Z', count: 24 },
|
|
{ value: 'purpleknight-a', label: 'PurpleKnight · 2026-08-23T09:00:00.000Z', count: 30 }
|
|
])).toEqual([
|
|
{ id: 'pingcastle', label: 'PingCastle', sourceValues: ['pingcastle-a', 'pingcastle-b'], count: 44 },
|
|
{ id: 'purpleknight', label: 'PurpleKnight', sourceValues: ['purpleknight-a'], count: 30 }
|
|
]);
|
|
});
|
|
|
|
it('adds only the severity-less CIS-CAT share as a neutral distribution segment', () => {
|
|
expect(dashboardCisCatSeverityRemainder([
|
|
{ value: 'cis-cat-run', label: 'CIS-CAT Pro · 2026-08-17', count: 411 },
|
|
{ value: 'maester-run', label: 'Maester · 2026-07-28', count: 318 }
|
|
], [])).toEqual({
|
|
value: 'dashboard-cis-cat-without-severity',
|
|
label: 'CIS-CAT',
|
|
count: 411,
|
|
sourceValues: ['cis-cat-run']
|
|
});
|
|
expect(dashboardCisCatSeverityRemainder([
|
|
{ value: 'cis-cat-run', label: 'CIS-CAT Pro · 2026-08-17', count: 411 }
|
|
], [{ value: 'high', label: 'High', count: 12 }])?.count).toBe(399);
|
|
expect(styles).toContain('.severity-dashboard-cis-cat-without-severity { --severity: var(--donut-not-run); }');
|
|
});
|
|
|
|
it('keeps the complete PingCastle risk catalog including zero scores', () => {
|
|
expect(dashboardPingCastleRiskCategories([
|
|
{ property: 'risk.anomalies', normalizedValue: 20, originalValue: '20' },
|
|
{ property: 'risk.stale_objects', normalizedValue: 10, originalValue: '10' },
|
|
{ property: 'risk.privileged_accounts', normalizedValue: 7, originalValue: '7' },
|
|
{ property: 'risk.trusts', normalizedValue: 0, originalValue: '0' }
|
|
], [])).toEqual([
|
|
{ value: 'anomalies', label: 'Anomalies', count: 20 },
|
|
{ value: 'stale objects', label: 'Stale Objects', count: 10 },
|
|
{ value: 'privileged accounts', label: 'Privileged Accounts', count: 7 },
|
|
{ value: 'trusts', label: 'Trusts', count: 0 }
|
|
]);
|
|
});
|
|
|
|
it('counts only explicit IOE-found source results for the top summary', () => {
|
|
const record = (id: string, value: string): FindingRecord => ({
|
|
id, index: 0, title: id,
|
|
fields: [{ index: 0, label: 'Result', value }],
|
|
filled: [], meta: []
|
|
});
|
|
expect(dashboardExplicitIoeCount([
|
|
record('ioe', 'IOE Found'),
|
|
record('ioes', 'IOEs found'),
|
|
record('deviation', 'Failed'),
|
|
record('pass', 'Passed')
|
|
])).toBe(2);
|
|
expect(dashboardExplicitIoeRecordIds([
|
|
record('ioe', 'IOE Found'),
|
|
record('ioes', 'IOEs found'),
|
|
record('deviation', 'Failed')
|
|
])).toEqual(['ioe', 'ioes']);
|
|
});
|
|
});
|