179 lines
13 KiB
TypeScript
179 lines
13 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { EnvironmentSnapshot, ScannerReportSection } from '../../src/domain/environment-context';
|
|
import { dashboardEnvironmentDetail } from '../../src/features/dashboard/DashboardPanel';
|
|
|
|
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');
|
|
|
|
const tool = { id: 'synthetic', name: 'Synthetic', version: '1' };
|
|
|
|
function observation(property: string, label: string, value: string | number | boolean, state: 'observed' | 'explicitly-absent' = 'observed') {
|
|
return {
|
|
version: 1 as const, id: `${property}-${label}`, scanRunId: 'run-1', importFileId: 'file-1', tool,
|
|
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, source: { rowIndex: 0 }, mappingVersion: '1', attributes: {}
|
|
};
|
|
}
|
|
|
|
function section(sectionKey: string, label: string, tables: ScannerReportSection['tables'], state: ScannerReportSection['state'] = 'observed'): ScannerReportSection {
|
|
return {
|
|
version: 1, id: `section-${sectionKey}`, scanRunId: 'run-1', importFileId: 'file-1', tool,
|
|
sectionKey, label, ordinal: 1, state, tables, source: { rowIndex: 0 }, extractorVersion: '1', attributes: {}
|
|
};
|
|
}
|
|
|
|
const snapshot = {
|
|
version: 1, projectId: 'project-1', scanRunIds: ['run-1'], generatedAt: '2026-08-25T00:00:00.000Z',
|
|
coverage: { observed: 6, explicitlyAbsent: 1, empty: 0, restricted: 1, incomplete: 0 },
|
|
sections: [
|
|
section('sectionPasswordPolicies', 'Password policies', [{
|
|
name: 'Password policies list',
|
|
headers: ['Policy Name', 'Complexity', 'Min Password Length', 'Password History'],
|
|
rows: [['Default Domain Policy " data-bs-html="true" title="" data-bs-original-title=" Linked to: DC=synthetic ">', 'False', '5', '24']]
|
|
}]),
|
|
section('sectionPKI', 'Certificates', [
|
|
{ name: 'Certificate Templates list', headers: ['Name'], rows: [['Template A'], ['Template B']] },
|
|
{ name: 'Certificates list', headers: ['Subject'], rows: [['Certificate A']] }
|
|
]),
|
|
section('sectionUserInformation', 'Honey Pot', [{
|
|
name: 'Account analysis',
|
|
headers: ['Nb User Accounts', 'Nb Enabled', 'Nb Disabled', 'Nb pwd never Expire'],
|
|
rows: [[16, 15, 1, 15]]
|
|
}], 'license-restricted'),
|
|
section('sectionInfrastructure', 'Microsoft Entra Connect settings', [], 'license-restricted')
|
|
],
|
|
observations: [
|
|
observation('domain.fqdn', 'Domain', 'synthetic.example'),
|
|
observation('domain.netbios_name', 'Netbios Name', 'SYNTHETIC'),
|
|
observation('forest.schema_version', 'Schema version', 'Windows Server 2019'),
|
|
observation('domain.controller_count', 'DC count', 1),
|
|
observation('domain.count', 'Number of domains', 2),
|
|
observation('operating_system.name', 'Operating System', 'Synthetic Server OS'),
|
|
observation('entra.configuration_detected', 'Entra ID configuration detected', false, 'explicitly-absent'),
|
|
observation('purple_knight.ad.security_posture_score', 'security posture score', 65),
|
|
observation('purple_knight.ad.ioes_found', 'IOEs found', 19),
|
|
observation('purple_knight.ad.category_score.kerberos_security', 'Kerberos Security', 98),
|
|
observation('benchmark.name', 'Benchmark', 'Synthetic CIS Benchmark'),
|
|
observation('benchmark.score', 'Benchmark score', 61),
|
|
observation('host.name', 'Assessed host', 'SYNTHETIC-HOST-01')
|
|
]
|
|
} satisfies EnvironmentSnapshot;
|
|
|
|
function fact(items: Array<{ label: string; value: string }>, label: string): string | undefined {
|
|
return items.find(item => item.label === label)?.value;
|
|
}
|
|
|
|
describe('client info environment detail', () => {
|
|
it('aggregates directory and inventory facts from the snapshot contract', () => {
|
|
const detail = dashboardEnvironmentDetail(snapshot);
|
|
|
|
expect(fact(detail.directory, 'Domain')).toBe('synthetic.example');
|
|
expect(fact(detail.directory, 'NetBIOS name')).toBe('SYNTHETIC');
|
|
expect(fact(detail.directory, 'Schema version')).toBe('Windows Server 2019');
|
|
expect(fact(detail.directory, 'Domains')).toBe('2');
|
|
expect(fact(detail.directory, 'Entra ID')).toBe('Not detected');
|
|
expect(fact(detail.inventory, 'User accounts')).toBe('16');
|
|
expect(fact(detail.inventory, 'Enabled / disabled')).toBe('15 / 1');
|
|
expect(fact(detail.inventory, 'Password never expires')).toBe('15');
|
|
expect(fact(detail.inventory, 'Domain controllers')).toBe('1');
|
|
expect(detail.operatingSystems.flatMap(group => group.entries)).toEqual([
|
|
expect.objectContaining({ name: 'Synthetic Server OS' })
|
|
]);
|
|
expect(detail.operatingSystems.flatMap(group => group.entries)[0].count).toBeUndefined();
|
|
expect(fact(detail.inventory, 'Assessed host')).toBe('SYNTHETIC-HOST-01');
|
|
});
|
|
|
|
it('renders unknown facts as an em dash instead of an inferred zero', () => {
|
|
const empty = dashboardEnvironmentDetail(undefined);
|
|
|
|
expect(new Set(empty.directory.map(item => item.value))).toEqual(new Set(['—']));
|
|
expect(new Set(empty.inventory.map(item => item.value))).toEqual(new Set(['—', '— / —']));
|
|
expect(new Set(empty.certificates.map(item => item.value))).toEqual(new Set(['—']));
|
|
expect(fact(dashboardEnvironmentDetail(snapshot).inventory, 'Trusts')).toBe('—');
|
|
});
|
|
|
|
it('reads the password policy and PKI counts from their own report tables', () => {
|
|
const detail = dashboardEnvironmentDetail(snapshot);
|
|
|
|
expect(fact(detail.passwordPolicy, 'Policy')).toBe('Default Domain Policy');
|
|
expect(fact(detail.passwordPolicy, 'Complexity')).toBe('False');
|
|
expect(fact(detail.passwordPolicy, 'Minimum length')).toBe('5');
|
|
expect(fact(detail.passwordPolicy, 'History')).toBe('24');
|
|
expect(fact(detail.certificates, 'Certificate templates')).toBe('2');
|
|
expect(fact(detail.certificates, 'Certificates')).toBe('1');
|
|
expect(fact(detail.certificates, 'DC certificates')).toBe('—');
|
|
});
|
|
|
|
it('keeps native tool metrics labelled and lists restricted sections instead of counting them as zero', () => {
|
|
const detail = dashboardEnvironmentDetail(snapshot);
|
|
|
|
expect(fact(detail.toolContext, 'Purple Knight AD posture')).toBe('65');
|
|
expect(fact(detail.toolContext, 'Purple Knight AD IOEs')).toBe('19');
|
|
expect(fact(detail.toolContext, 'Purple Knight AD · Kerberos Security')).toBe('98');
|
|
expect(fact(detail.toolContext, 'CIS-CAT benchmark')).toBe('Synthetic CIS Benchmark');
|
|
expect(fact(detail.toolContext, 'CIS-CAT score')).toBe('61');
|
|
expect(detail.restricted).toEqual(['Honey Pot', 'Microsoft Entra Connect settings']);
|
|
});
|
|
|
|
it('keeps all six square overview tiles on one row and complete evidence in Results', () => {
|
|
expect(component).toContain('className="client-environment-grid" aria-label="Environment overview"');
|
|
expect(component).not.toContain('className="client-environment-detail"');
|
|
expect(component).not.toContain('environmentDetailRef');
|
|
expect(component).toContain('<DashboardFactList items={items}/>');
|
|
expect(component).toContain('<DashboardFactList items={tileItems}/>');
|
|
expect(component).toContain('unit: \'/100 ASSESSOR\'');
|
|
expect(component).toContain('PingCastle risk ${exactNative}/100');
|
|
expect(component).toContain('useGpoInventory');
|
|
expect(component).toContain('className="client-environment-sources"');
|
|
expect(component).not.toContain('className="client-environment-sources" aria-labelledby=');
|
|
expect(component).toContain('<EnvironmentResultsButton title="Overall Score">');
|
|
expect(component).toContain('title="Directory" items={environmentDetail.directory}');
|
|
expect(component).toContain('title="Resilience" items={environmentDetail.resilience}');
|
|
expect(component).toContain('title="Inventory" items={environmentDetail.inventory}');
|
|
expect(component).toContain('title="Password policy" items={environmentDetail.passwordPolicy}');
|
|
expect(component).toContain('title="Certificates & PKI" items={environmentDetail.certificates}');
|
|
expect(component).not.toContain('title="Source metrics" items={environmentDetail.toolContext}');
|
|
expect(component).toContain('icon={<DashboardGlyph name="computer"/>}');
|
|
expect(component).not.toContain('className="client-source-overview"');
|
|
const overviewLayout = styles.match(/\.client-environment-grid\s*\{([^}]*)\}/)?.[1] ?? '';
|
|
expect(overviewLayout).toMatch(/display:\s*grid;\s*grid-template-columns:\s*repeat\(6, minmax\(var\(--tile-environment-min-width\), 1fr\)\);/);
|
|
expect(overviewLayout).toMatch(/overflow-x:\s*auto;/);
|
|
expect(overviewLayout).not.toMatch(/flex-wrap:\s*wrap|grid-template-rows:/);
|
|
expect(component).toContain('const tileItems = (overviewItems ?? items).slice(0, 4);');
|
|
// 2026-10-01: a longer value stays reachable by scrolling inside the tile instead of being cut off,
|
|
// and four facts appear from the measured 294px tile width (16.25rem inner width).
|
|
expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*aspect-ratio:\s*1;[^}]*overflow:\s*auto;/s);
|
|
expect(styles).not.toMatch(/\.client-environment-grid > \.client-os-inventory\s*\{[^}]*overflow:\s*hidden;/s);
|
|
expect(styles).toMatch(/@container environment-tile \(max-width: 16\.25rem\)\s*\{\s*\.client-env-facts > div:nth-child\(n \+ 3\)\s*\{\s*display:\s*none;/s);
|
|
expect(styles).not.toMatch(/\.client-environment-grid > section > \.client-env-facts dd\s*\{[^}]*overflow-wrap:\s*normal;/s);
|
|
expect(styles).toMatch(/\.client-environment-grid > \.client-env-group,[\s\S]*?\.client-environment-grid > \.client-os-inventory\s*\{[^}]*background:\s*var\(--tile-surface\);/s);
|
|
expect(styles).toMatch(/\.client-environment-sources li > div,[\s\S]*?\.client-environment-sources li > button\s*\{[^}]*background:\s*transparent;/s);
|
|
expect(styles).toMatch(/\.client-env-group-title > \.client-glyph\s*\{[^}]*width:\s*var\(--space-5\);[^}]*color:\s*var\(--client-info-muted\);/s);
|
|
expect(styles).toMatch(/\.client-environment-grid \.client-env-group-title,[\s\S]*?\.client-command-center \.client-environment-grid > \.client-os-inventory > header h3\s*\{[^}]*font-size:\s*var\(--type-h4-size\);/s);
|
|
expect(styles).toMatch(/\.client-env-group-header\s*\{[^}]*flex-wrap:\s*wrap;/s);
|
|
expect(styles).toMatch(/\.client-overall-head\s*\{[^}]*justify-content:\s*space-between;/s);
|
|
expect(styles).toMatch(/\.client-environment-results-button\s*\{[^}]*margin-inline-start:\s*auto;/s);
|
|
expect(styles).toMatch(/\.client-command-center \.client-os-inventory > header h3\s*\{[^}]*font-size:\s*var\(--type-h3-size\);[^}]*font-weight:\s*var\(--fw-bold\);/s);
|
|
expect(styles).not.toMatch(/\.client-env-group-title::after\s*\{/);
|
|
expect(styles).toMatch(/\.client-env-facts\s*\{[^}]*border-top:/s);
|
|
expect(styles).toMatch(/\.client-env-facts > div\s*\{[^}]*padding:\s*var\(--space-3\) var\(--space-2\);/s);
|
|
expect(styles).toMatch(/\.client-env-facts dt\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
|
expect(styles).toMatch(/\.client-env-facts dd\s*\{[^}]*font-size:\s*var\(--type-body-size\);/s);
|
|
expect(styles).toMatch(/\.client-environment-grid > section > \.client-env-facts\s*\{[^}]*background:\s*var\(--tile-surface\);/s);
|
|
expect(styles).toMatch(/\.client-environment-grid > section > \.client-env-facts > div\s*\{[^}]*border:\s*var\(--border-hair\) solid var\(--client-info-line\);/s);
|
|
expect(styles).toMatch(/\.client-domain-information dl\s*\{[^}]*gap:\s*var\(--border-hair\);[^}]*background:\s*var\(--client-info-line\);/s);
|
|
expect(styles).toMatch(/\.client-data-fact small\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
|
expect(styles).toMatch(/\.client-priority-context span\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
|
expect(styles).toMatch(/\.client-domains li > button,[\s\S]*?\.client-domains li > div\s*\{[^}]*border:\s*0;/s);
|
|
expect(styles).toMatch(/\.client-domains li > button:focus-visible\s*\{[^}]*outline:\s*var\(--bw-emphasis\) solid var\(--focus-ring\);/s);
|
|
expect(styles).toMatch(/\.client-environment-sources li em\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
|
expect(styles).toMatch(/\.client-command-center \.client-os-group th\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
|
expect(styles).toMatch(/\.client-command-center \.client-os-group \.client-os-source\s*\{[^}]*font-size:\s*var\(--type-table-body-size\);/s);
|
|
expect(styles).toMatch(/\.floating-data-window \.client-os-groups\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);[^}]*width:\s*100%;/s);
|
|
expect(styles).toMatch(/\.floating-data-window \.client-os-group table\s*\{[^}]*width:\s*max-content;[^}]*min-width:\s*100%;/s);
|
|
expect(styles).toMatch(/\.client-domain-score em\s*\{[^}]*color:\s*var\(--chart-color, var\(--client-info-text\)\);/s);
|
|
});
|
|
});
|