245 lines
16 KiB
TypeScript
245 lines
16 KiB
TypeScript
import { resetEnginePreferencesForTests, useEnginePreferences } from '../../src/app/engine-preferences';
|
|
import { EnginesControl } from '../../src/components/EnginesControl';
|
|
// @vitest-environment jsdom
|
|
import { act, useState } from 'react';
|
|
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { useApp } from '../../src/app/AppProvider';
|
|
import { AssessorApiClient } from '../../src/api/assessor-api-client';
|
|
import { RELEASED_ASSESSMENT_CONTEXT_CATALOG } from '../../src/assessment-context/context-catalog';
|
|
import { buildAssessmentContext } from '../../src/assessment-context/context-engine';
|
|
import type { StoredGpoPolicyAnalysis } from '../../src/domain/gpo-analysis-persistence';
|
|
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
|
|
import {
|
|
CONTEXT_CLUSTER_FACET_ID, contextClusterIndex, withContextClusterFacet
|
|
} from '../../src/features/context-layer/context-cluster-facet';
|
|
import { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context';
|
|
import { resetContextClusterIndexForTests } from '../../src/features/context-layer/use-context-cluster-index';
|
|
import type { DynamicFacet } from '../../src/features/search-filter/dynamic-facets';
|
|
import { filterAndSortRecords } from '../../src/features/search-filter/filter-records';
|
|
import { SearchFilterBar } from '../../src/features/search-filter/SearchFilterBar';
|
|
import { selectWorkFacets } from '../../src/features/search-filter/work-facets';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import { genericProfile } from '../../src/profiles/generic/generic-profile';
|
|
|
|
vi.mock('../../src/app/AppProvider', () => {
|
|
const useApp = vi.fn();
|
|
return { useApp, useOptionalApp: useApp };
|
|
});
|
|
|
|
const LSA = 'MACHINE\\System\\CurrentControlSet\\Control\\Lsa\\LmCompatibilityLevel';
|
|
|
|
function syntheticContext() {
|
|
const observations = [{
|
|
resultProjectionId: 'policy-observation:obs-lm', sourceKind: 'policy-observation', sourceResultId: 'obs-lm',
|
|
policyScope: 'computer', policyState: 'configured', policyIdentityStatus: 'resolved', policyIdentityKey: 'dummy-identity-lm',
|
|
policyValues: [{ kind: 'registry', typeCode: 4, rawData: '2' }], policyBackupInstanceId: 'backup-a'
|
|
}] as unknown as UnifiedResultProjection[];
|
|
const analyses = [{ run: { id: 'dummy' }, resolutions: [{ observationId: 'obs-lm',
|
|
resolution: { status: 'resolved', identity: { id: 'dummy-identity-lm', target: { kind: 'security-template', section: 'Registry Values', key: LSA } } } }] }] as unknown as StoredGpoPolicyAnalysis[];
|
|
const finding = (occurrence: string, tool: string, ruleId: string, outcome: string) => ({
|
|
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence,
|
|
sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title: ruleId
|
|
}) as unknown as UnifiedResultProjection;
|
|
return buildAssessmentContext({
|
|
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 1,
|
|
observations, analyses, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
|
|
scannerFindings: [finding('occ-rbcd', 'purple-knight', 'SI000079', 'fail'), finding('occ-pc', 'pingcastle', 'S-OldNtlm', 'fail')]
|
|
});
|
|
}
|
|
|
|
function workSheet() {
|
|
const sheet = makeSheet('Dummy', [
|
|
['Title', 'Status', 'Scope', 'Technical Identity'],
|
|
['Dummy LAN Manager setting', 'Failed', 'computer', 'dummy-identity-lm'],
|
|
['Dummy RBCD finding', 'Failed', '', ''],
|
|
['Dummy NTLM finding', 'Passed', '', ''],
|
|
['Dummy unrelated finding', 'Failed', '', '']
|
|
], '', 'dummy.csv', { isProjectSheet: true });
|
|
const [, rbcd, ntlm, unrelated] = sheet.analysis.records;
|
|
rbcd.findingOccurrenceId = 'occ-rbcd';
|
|
ntlm.findingOccurrenceId = 'occ-pc';
|
|
unrelated.findingOccurrenceId = 'occ-unrelated';
|
|
return sheet;
|
|
}
|
|
|
|
const baseOptions = (sheet = workSheet()) => ({
|
|
sheet,
|
|
options: { activeProfile: genericProfile, search: '', sortColumn: '', sortDirection: 'asc' as const, statusFilter: 'all',
|
|
facetFilters: {}, dataset: { fileName: 'Dummy', sheets: [sheet], scanTool: 'Generic' } as never, actionOnly: false, reviews: {} }
|
|
});
|
|
|
|
describe('Work facet Context Cluster', () => {
|
|
it('permanent acceptance: without a context filter Work records and every other facet count are identical with the layer on and off', () => {
|
|
const { sheet, options } = baseOptions();
|
|
const index = contextClusterIndex(syntheticContext());
|
|
for (const statusFilter of ['all', 'deviation']) {
|
|
const off = filterAndSortRecords(sheet, { ...options, statusFilter });
|
|
const on = filterAndSortRecords(sheet, { ...options, statusFilter, contextClusters: index });
|
|
expect(on.map(record => record.id)).toEqual(off.map(record => record.id));
|
|
const facetsOff = selectWorkFacets(sheet, { ...options, statusFilter });
|
|
const facetsOn = selectWorkFacets(sheet, { ...options, statusFilter, contextClusters: index });
|
|
expect(facetsOn.filter(facet => facet.id !== CONTEXT_CLUSTER_FACET_ID && facet.id !== 'report:business-impact')
|
|
.map(({ valuesFor: _v, ...facet }) => facet)).toEqual(facetsOff.map(({ valuesFor: _v, ...facet }) => facet));
|
|
expect(facetsOn.length).toBe(facetsOff.length + 2);
|
|
}
|
|
});
|
|
|
|
it('offers reachable clusters and selects exactly the GPO settings and findings that provide evidence', () => {
|
|
const { sheet, options } = baseOptions();
|
|
const index = contextClusterIndex(syntheticContext());
|
|
const facet = selectWorkFacets(sheet, { ...options, contextClusters: index }).find(item => item.id === CONTEXT_CLUSTER_FACET_ID)!;
|
|
expect(facet.label).toBe('Context Cluster');
|
|
expect(facet.values.map(value => value.value)).toEqual(['credential-relay-exposure', 'kerberos-delegation-abuse']);
|
|
const count = (id: string) => facet.values.find(value => value.value === id)!.count;
|
|
expect(count('credential-relay-exposure')).toBe(2);
|
|
expect(count('kerberos-delegation-abuse')).toBe(1);
|
|
expect(facet.values.some(value => value.value === 'adcs-escalation')).toBe(false);
|
|
const relay = filterAndSortRecords(sheet, { ...options, contextClusters: index,
|
|
facetFilters: { [CONTEXT_CLUSTER_FACET_ID]: { values: ['credential-relay-exposure'] } } });
|
|
expect(relay.map(record => record.title)).toEqual(['Dummy LAN Manager setting', 'Dummy NTLM finding']);
|
|
const delegation = filterAndSortRecords(sheet, { ...options, contextClusters: index,
|
|
facetFilters: { [CONTEXT_CLUSTER_FACET_ID]: { values: ['kerberos-delegation-abuse'] } } });
|
|
expect(delegation.map(record => record.title)).toEqual(['Dummy RBCD finding']);
|
|
});
|
|
|
|
it('stays directly before the permanent final Source: Scan control', () => {
|
|
const facet = (id: string) => ({ id, label: id, source: 'derived', kind: 'category', values: [] }) as DynamicFacet;
|
|
const index = contextClusterIndex(syntheticContext());
|
|
expect(withContextClusterFacet([facet('status'), facet('provenance:source-scan')], index).map(item => item.id))
|
|
.toEqual(['status', CONTEXT_CLUSTER_FACET_ID, 'provenance:source-scan']);
|
|
expect(withContextClusterFacet([facet('status')], undefined).map(item => item.id)).toEqual(['status']);
|
|
});
|
|
|
|
it('counts All once per record with overlapping clusters and respects search and status', () => {
|
|
const { sheet, options } = baseOptions();
|
|
const original = contextClusterIndex(syntheticContext());
|
|
const index = { ...original, facet: { ...original.facet, valuesFor: () => ['credential-relay-exposure', 'kerberos-delegation-abuse'] } };
|
|
const count = (search: string, statusFilter: string) => selectWorkFacets(sheet, { ...options, search, statusFilter, contextClusters: index,
|
|
facetFilters: { [CONTEXT_CLUSTER_FACET_ID]: { values: ['credential-relay-exposure'] } } }).find(f => f.id === CONTEXT_CLUSTER_FACET_ID)!;
|
|
expect(count('', 'all').allCount).toBe(4);
|
|
expect(count('RBCD', 'all').allCount).toBe(1);
|
|
expect(count('not-present', 'all').allCount).toBe(0);
|
|
expect(count('', 'passed').allCount).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Work facet Context Cluster in the filter bar', () => {
|
|
let host: HTMLDivElement;
|
|
let root: Root;
|
|
const setFacetFilter = vi.fn();
|
|
|
|
function mockApp(facetFilters: Record<string, { values: string[] }>) {
|
|
const { sheet } = baseOptions();
|
|
vi.mocked(useApp).mockReturnValue({
|
|
activeSheet: sheet, activeProfile: genericProfile, dataset: { fileName: 'Dummy', sheets: [sheet], scanTool: 'Generic' },
|
|
reviews: {}, search: '', statusFilter: 'all', setStatusFilter: vi.fn(), setActiveSheet: vi.fn(), facetFilters,
|
|
pathFindingIds: [], setFacetFilter, resetFilters: vi.fn(), actionOnly: false, setActionOnly: vi.fn(),
|
|
targetProjectId: 'dummy-project', activeAssessmentIds: ['dummy-assessment'], activeScanRunIds: []
|
|
} as unknown as ReturnType<typeof useApp>);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
localStorage.clear();
|
|
resetEnginePreferencesForTests();
|
|
resetAssessmentContextRequestsForTests();
|
|
resetContextClusterIndexForTests();
|
|
setFacetFilter.mockReset();
|
|
host = document.createElement('div');
|
|
document.body.append(host);
|
|
root = createRoot(host);
|
|
});
|
|
afterEach(async () => {
|
|
await act(async () => { root.unmount(); });
|
|
host.remove();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it.each(['dark', 'light'])('selects the badge cluster in the visible Work filter and filters the record set in %s', async theme => {
|
|
document.documentElement.dataset.theme = theme;
|
|
const context = syntheticContext();
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
|
|
mockApp({});
|
|
const initial = useApp();
|
|
const { sheet, options } = baseOptions();
|
|
function WorkFixture() {
|
|
const engines = useEnginePreferences();
|
|
const [facets, setFacets] = useState<Record<string, { values: string[] }>>({});
|
|
vi.mocked(useApp).mockReturnValue({ ...initial, activeSheet: sheet, facetFilters: facets,
|
|
setFacetFilter: (id, filter) => setFacets(current => {
|
|
const next = { ...current };
|
|
if (filter?.values?.length) next[id] = { values: filter.values };
|
|
else delete next[id];
|
|
return next;
|
|
})
|
|
});
|
|
const filtered = filterAndSortRecords(sheet, { ...options, facetFilters: facets, contextClusters: engines.context ? contextClusterIndex(context) : undefined });
|
|
return <><EnginesControl/><SearchFilterBar/><output data-record-count>{filtered.length}</output>
|
|
<FindingReader record={sheet.analysis.records[0]} title="Synthetic setting" linkedSheets={[]} resultReferences={[]}
|
|
search="" position={0} recordCount={4} toolId="group-policy" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/></>;
|
|
}
|
|
await act(async () => { root.render(<WorkFixture/>); await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
expect(host.querySelector('[data-record-count]')?.textContent).toBe('4');
|
|
await act(async () => { host.querySelector<HTMLButtonElement>('[data-context-badges] [data-cluster-id="credential-relay-exposure"]')!.click(); });
|
|
const select = host.querySelector<HTMLSelectElement>('select[aria-label="Context Cluster"]')!;
|
|
expect(select.value).toBe('credential-relay-exposure');
|
|
expect(select.options[0].textContent).toBe('Context Cluster: All (4)');
|
|
expect(select.classList.contains('is-filtered')).toBe(true);
|
|
expect(host.querySelector('[data-record-count]')?.textContent).toBe('2');
|
|
expect(host.querySelector<HTMLDetailsElement>('details.finding-context')!.open).toBe(false);
|
|
await act(async () => { select.value = ''; select.dispatchEvent(new Event('change', { bubbles: true })); });
|
|
expect(host.querySelector('[data-record-count]')?.textContent).toBe('4');
|
|
expect(host.querySelector('details.finding-context')).not.toBeNull();
|
|
expect(select.options[0].textContent).toBe('Context Cluster: All (4)');
|
|
await act(async () => { host.querySelector<HTMLButtonElement>('[data-context-badges] [data-cluster-id="credential-relay-exposure"]')!.click(); });
|
|
await act(async () => { host.querySelector<HTMLButtonElement>('.engines-button')!.click(); });
|
|
const contextToggle = [...document.querySelectorAll<HTMLLabelElement>('.engines-option')]
|
|
.find(label => label.textContent === 'Context Engine')!.querySelector<HTMLInputElement>('input')!;
|
|
await act(async () => { contextToggle.click(); });
|
|
expect(host.querySelector('details.finding-context')).toBeNull();
|
|
expect(host.querySelector('select[aria-label="Context Cluster"]')).toBeNull();
|
|
expect(useApp().facetFilters[CONTEXT_CLUSTER_FACET_ID]).toBeUndefined();
|
|
expect(host.querySelector('[data-record-count]')?.textContent).toBe('4');
|
|
await act(async () => { contextToggle.click(); });
|
|
expect(host.querySelector('select[aria-label="Context Cluster"]')).not.toBeNull();
|
|
expect(host.querySelector<HTMLDetailsElement>('details.finding-context')!.open).toBe(false);
|
|
delete document.documentElement.dataset.theme;
|
|
});
|
|
|
|
it('always shows the facet and keeps the selected cluster even with an obsolete off preference', async () => {
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
localStorage.setItem('ASSESSOR.contextLayer', 'off');
|
|
mockApp({ [CONTEXT_CLUSTER_FACET_ID]: { values: ['credential-relay-exposure'] } });
|
|
await act(async () => { root.render(<SearchFilterBar/>); await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
expect(host.textContent).toContain('Context Cluster');
|
|
expect(setFacetFilter).not.toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, null);
|
|
});
|
|
|
|
it('offers only reachable clusters as search changes, keeps an empty selection visible and permits All', async () => {
|
|
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
|
|
mockApp({});
|
|
await act(async () => { root.render(<SearchFilterBar/>); await new Promise(resolve => setTimeout(resolve, 0)); });
|
|
const select = () => host.querySelector<HTMLSelectElement>('select[aria-label="Context Cluster"]')!;
|
|
expect([...select().options].map(option => option.value)).toEqual(['', 'credential-relay-exposure', 'kerberos-delegation-abuse']);
|
|
await act(async () => { select().value = 'credential-relay-exposure'; select().dispatchEvent(new Event('change', { bubbles: true })); });
|
|
expect(setFacetFilter).toHaveBeenLastCalledWith(CONTEXT_CLUSTER_FACET_ID, { values: ['credential-relay-exposure'] });
|
|
mockApp({ [CONTEXT_CLUSTER_FACET_ID]: { values: ['credential-relay-exposure'] } });
|
|
vi.mocked(useApp).mockReturnValue({ ...useApp(), search: 'RBCD' });
|
|
await act(async () => root.render(<SearchFilterBar/>));
|
|
expect(select().value).toBe('credential-relay-exposure');
|
|
expect([...select().options].filter(option => !option.disabled).map(option => option.value)).toEqual(['', 'kerberos-delegation-abuse']);
|
|
expect(select().selectedOptions[0].disabled).toBe(true);
|
|
expect(select().options[0].textContent).toBe('Context Cluster: All (1)');
|
|
expect(setFacetFilter).not.toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, null);
|
|
await act(async () => { select().value = ''; select().dispatchEvent(new Event('change', { bubbles: true })); });
|
|
expect(setFacetFilter).toHaveBeenLastCalledWith(CONTEXT_CLUSTER_FACET_ID, null);
|
|
mockApp({}); vi.mocked(useApp).mockReturnValue({ ...useApp(), search: 'not-present' });
|
|
await act(async () => root.render(<SearchFilterBar/>));
|
|
expect([...select().options].map(option => option.textContent)).toEqual(['Context Cluster: All (0)']);
|
|
});
|
|
});
|