Assessor_2.0/tests/features/finding-context-layer.test.tsx

393 lines
27 KiB
TypeScript

import { resetEnginePreferencesForTests, setEngineEnabled } from '../../src/app/engine-preferences';
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { act } from 'react';
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 { contextGapReport } from '../../src/assessment-context/context-gaps';
import type { StoredGpoPolicyAnalysis } from '../../src/domain/gpo-analysis-persistence';
import type { UnifiedResultProjection } from '../../src/domain/unified-result';
import { CONTEXT_CLUSTER_FACET_ID } from '../../src/features/context-layer/context-cluster-facet';
import { resetAssessmentContextRequestsForTests } from '../../src/features/context-layer/use-assessment-context';
import { FindingReader } from '../../src/features/finding-reader/FindingReader';
import { makeSheet } from '../../src/importers/sheet-factory';
vi.mock('../../src/app/AppProvider', () => {
const useApp = vi.fn();
return { useApp, useOptionalApp: useApp };
});
const LSA = 'MACHINE\\System\\CurrentControlSet\\Control\\Lsa\\LmCompatibilityLevel';
const SMB = 'MACHINE\\System\\CurrentControlSet\\Services\\LanManServer\\Parameters\\RequireSecuritySignature';
function syntheticContext() {
const declared = [
{ id: 'obs-lm', backup: 'backup-a', key: LSA, raw: '2', identity: 'dummy-identity-lm', link: 'linked' },
{ id: 'obs-smb', backup: 'backup-b', key: SMB, raw: '0', identity: 'dummy-identity-smb', link: 'not-linked' }
];
const observations = declared.map(item => ({
resultProjectionId: `policy-observation:${item.id}`, sourceKind: 'policy-observation', sourceResultId: item.id,
policyScope: 'computer', policyState: 'configured', policyIdentityStatus: 'resolved', policyIdentityKey: item.identity,
policyValues: [{ kind: 'registry', typeCode: 4, rawData: item.raw }], policyBackupInstanceId: item.backup,
policyGpoId: `{DUMMY-${item.backup}}`, policyBackupLinkStatus: item.link
})) as unknown as UnifiedResultProjection[];
const analyses = [{ run: { id: 'dummy' }, resolutions: declared.map(item => ({
observationId: item.id,
resolution: { status: 'resolved', identity: { id: item.identity, target: { kind: 'security-template', section: 'Registry Values', key: item.key } } }
})) }] as unknown as StoredGpoPolicyAnalysis[];
return buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
observations, analyses, catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG
});
}
function gpoRecord(identity: string) {
return makeSheet('GPOs', [
['Title', 'Category', 'Status', 'Scope', 'Technical Identity', 'Source: Scan'],
['Dummy LAN Manager setting', 'GPOs', 'Deviation', 'computer', identity, 'GPOs']
], '', 'dummy.csv', { isProjectSheet: true, toolId: 'group-policy' }).analysis.records[0];
}
let host: HTMLDivElement;
let root: Root;
const dataset = { fileName: 'Dummy', sheets: [], scanTool: 'group-policy' };
async function renderReader(identity = 'dummy-identity-lm') {
await act(async () => {
root.render(<FindingReader record={gpoRecord(identity)} title="Dummy LAN Manager setting" linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} toolId="group-policy" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
}
const readerWithoutContext = () => {
const clone = host.querySelector('article')!.cloneNode(true) as HTMLElement;
clone.querySelector('.finding-context')?.remove();
clone.querySelector('[data-context-badges]')?.remove();
return clone.innerHTML;
};
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
localStorage.clear();
resetEnginePreferencesForTests();
// Isolate the Context on/off contract from other optional engine output.
setEngineEnabled('remediation', false);
resetAssessmentContextRequestsForTests();
vi.mocked(useApp).mockReturnValue({
targetProjectId: 'dummy-project', activeAssessmentIds: ['dummy-assessment'], dataset, setFacetFilter: vi.fn()
} as unknown as ReturnType<typeof useApp>);
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => { root.unmount(); });
host.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('Finding Reader context layer', () => {
it('makes no request and renders no context when Context Engine is disabled', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
setEngineEnabled('context', false);
await renderReader();
expect(request).not.toHaveBeenCalled();
expect(host.querySelector('.finding-context')).toBeNull();
const technicalReader = readerWithoutContext();
await act(async () => { setEngineEnabled('context', true); });
expect(request).toHaveBeenCalledTimes(1);
expect(readerWithoutContext()).toBe(technicalReader);
await act(async () => { setEngineEnabled('context', false); });
expect(host.querySelector('.finding-context')).toBeNull();
await renderReader('dummy-identity-smb');
expect(request).toHaveBeenCalledTimes(1);
});
it('preserves the standalone reader without AppProvider and requests no context without a scope', async () => {
const app = useApp();
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
vi.mocked(useApp).mockReturnValue(null as unknown as ReturnType<typeof useApp>);
await renderReader();
const standalone = readerWithoutContext();
expect(host.querySelector('article')).not.toBeNull();
expect(host.querySelector('.finding-context')).toBeNull();
expect(request).not.toHaveBeenCalled();
vi.mocked(useApp).mockReturnValue(app);
await renderReader();
expect(host.querySelector('.finding-context')).not.toBeNull();
expect(request).toHaveBeenCalledTimes(1);
expect(readerWithoutContext()).toBe(standalone);
});
it.each(['dark', 'light'])('starts collapsed and supports opening, closing, badge navigation and record changes in %s', async theme => {
document.documentElement.dataset.theme = theme;
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
await renderReader();
const section = host.querySelector<HTMLDetailsElement>('details.finding-context')!;
expect(section.open).toBe(false);
const summary = section.querySelector('summary')!;
await act(async () => { summary.click(); });
expect(section.open).toBe(true);
expect(section.textContent).toContain('Declared in GPO backup');
await act(async () => { summary.click(); });
expect(section.open).toBe(false);
await act(async () => { host.querySelector<HTMLButtonElement>('.context-cluster-badge')!.click(); });
expect(useApp().setFacetFilter).toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, { values: ['credential-relay-exposure'] });
expect(section.open).toBe(false);
await act(async () => { summary.click(); });
// A different finding starts with its own closed disclosure.
const next = { ...gpoRecord('dummy-identity-smb'), id: 'next-record' };
await act(async () => {
root.render(<FindingReader record={next} title="Synthetic next setting" linkedSheets={[]} resultReferences={[]}
search="" position={1} recordCount={2} toolId="group-policy" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
});
expect(host.querySelector<HTMLDetailsElement>('details.finding-context')!.open).toBe(false);
delete document.documentElement.dataset.theme;
});
it.each(['off', 'on', null])('always loads context regardless of the obsolete saved preference %s', async preference => {
if (preference) localStorage.setItem('ASSESSOR.contextLayer', preference);
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
await renderReader();
expect(host.querySelector('details.finding-context')).not.toBeNull();
expect(request).toHaveBeenCalledTimes(1);
});
it('shows derived context and badge selection leaves the technical reader unchanged', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
await renderReader();
const withoutLayer = readerWithoutContext();
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith('dummy-project', ['dummy-assessment'], []);
const section = host.querySelector('.finding-context')!;
expect(section.querySelector('h4')?.textContent).toBe('Context');
expect(section.textContent).toContain('does not change the result above');
expect(section.textContent).toContain('Declared in GPO backup');
expect(section.textContent).toContain('Send NTLM response only (2)');
expect(section.textContent).toContain('LM or NTLMv1 responses are sent: Met');
expect(section.textContent).toMatch(/\bfavours?\b/);
expect(section.textContent).not.toMatch(/exploitable|confirmed attack|begünstigt/i);
expect(section.textContent).toContain('Credential Relay Exposure: Present');
expect(section.textContent).toContain('3 of 26 conditions met, 2 required');
expect(section.textContent).toContain('ATT&CK T1557.001');
const link = section.querySelector('a')!;
expect(link.getAttribute('href')).toMatch(/^https:\/\//);
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
expect(section.querySelector('[data-condition-state="satisfied"] .finding-context__glyph')?.textContent).toBe('●');
// User request 2026-10-01: cluster badges select the exact Work facet.
const badges = [...host.querySelectorAll('.reader-heading-actions [data-context-badges] .context-cluster-badge')];
expect(badges.map(badge => badge.textContent)).toEqual(['ContextCredential Relay Exposure']);
expect(badges[0].getAttribute('data-cluster-id')).toBe('credential-relay-exposure');
await act(async () => { (badges[0] as HTMLButtonElement).click(); });
expect(useApp().setFacetFilter).toHaveBeenCalledWith(CONTEXT_CLUSTER_FACET_ID, { values: ['credential-relay-exposure'] });
expect(readerWithoutContext()).toBe(withoutLayer);
await renderReader();
expect(request).toHaveBeenCalledTimes(1);
});
it('says plainly when a GPO setting is not in the catalog and reuses the loaded scope', async () => {
const request = vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(syntheticContext());
await renderReader('dummy-identity-unlisted');
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
expect(host.querySelector('.finding-context')?.textContent).toContain(`This setting is not in the context catalog ${syntheticContext().catalog.version} yet`);
expect(host.querySelector('[data-context-coverage]')?.textContent).toBe('In this timeslot, context covers 0 of 0 scanner findings and 2 GPO settings.');
await renderReader('dummy-identity-lm');
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
expect(host.querySelector('.finding-context')?.textContent).toContain('Met');
expect(request).toHaveBeenCalledTimes(1);
});
it('shows context for a scanner finding with its observation next to the GPO declaration', async () => {
const base = syntheticContext();
const withScanner = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
scannerFindings: [{ resultProjectionId: 'scanner-finding:occ-pk', sourceKind: 'scanner-finding', sourceResultId: 'occ-pk',
sourceTool: 'purple-knight', sourceRuleId: 'SI000154', outcomeNormalized: 'fail', title: 'SMB Signing is not required on Domain Controllers' } as unknown as UnifiedResultProjection]
});
const combined = { ...base, observations: withScanner.observations, findings: withScanner.findings,
clusters: base.clusters.map(cluster => ({ ...cluster, conditions: cluster.conditions.map(item => item.conditionId === 'smb-server-signing-not-required'
? { ...item, satisfiedBy: [...item.satisfiedBy, withScanner.observations[0].id] } : item) })) };
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(combined);
const record = makeSheet('Purple Knight', [['Name', 'Status'], ['SMB Signing is not required on Domain Controllers', 'Failed']], '', 'pk.csv').analysis.records[0];
record.findingOccurrenceId = 'occ-pk';
await act(async () => {
root.render(<FindingReader record={record} title="SMB Signing is not required on Domain Controllers" linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
const section = host.querySelector('.finding-context')!;
expect(section.textContent).toContain('SMB server does not require signing: Met');
expect(section.textContent).toContain('Observed by Purple Knight');
expect(section.textContent).toContain('SI000154: fail');
expect(section.textContent).toContain('Declared in GPO backup');
expect(section.textContent).toContain('Credential Relay Exposure');
expect([...section.querySelectorAll('a')].some(link => link.getAttribute('href') === 'https://www.semperis.com/purple-knight/')).toBe(true);
});
it('shows the ATT&CK tactic the scanners declare for a finding without an attack-path cluster', async () => {
const context = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
scannerFindings: ['occ-quota', 'occ-other'].map((occurrence, index) => ({ resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding',
sourceResultId: occurrence, sourceTool: index ? 'pingcastle' : 'purple-knight', sourceRuleId: `R-${index}`, outcomeNormalized: 'fail', title: occurrence })) as never,
referenceRuns: [{ schemaVersion: 1, id: 'run', projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId: 'scan', recognizerVersion: '1.0.0',
inputSha256: 'x', catalogHashes: [], createdAt: '2026-09-20T00:00:00Z', results: ['occ-quota', 'occ-other'].map(occurrenceId => ({ occurrenceId, importFileId: 'f', sourcePosition: {},
references: [{ framework: 'mitre-attack', id: 'TA0006', title: 'Credential Access', kind: 'tactic', tactics: [], status: 'active', evidence: 'declared',
matchedBy: 'id', catalogVersion: '19.2', catalogSha256: 'x', source: {} }] })) }] as never
});
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
const record = makeSheet('Purple Knight', [['Name', 'Status'], ['Unprivileged users can add computer accounts', 'Failed']], '', 'pk.csv').analysis.records[0];
record.findingOccurrenceId = 'occ-quota';
await act(async () => {
root.render(<FindingReader record={record} title="Unprivileged users can add computer accounts" linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
const block = host.querySelector('[data-context-kind="declared-attack"]')!;
expect(block.textContent).toContain('TA0006 Credential Access');
expect(block.textContent).toContain('2 findings from 2 scanners declare this tactic');
expect(block.textContent).toContain('2 fail · 0 pass');
expect(block.textContent).toContain('not a proven attack chain');
// User decision 2026-09-30: a deviation without a catalogued path stays visible in the uncatalogued cluster.
expect(host.querySelector('.finding-context')?.textContent).toContain('Other Deviations (not yet catalogued)');
expect(host.querySelector('.finding-context')?.textContent).toContain('deviation not yet catalogued');
});
it('shows the D3FEND countermeasure the scanners declare, separately from ATT&CK', async () => {
const reference = (framework: string, id: string, title: string) => ({ framework, id, title, kind: 'technique', tactics: [], status: 'active',
evidence: 'declared', matchedBy: 'exact-title', catalogVersion: '1.0', catalogSha256: 'x', source: {} });
const context = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
scannerFindings: [['occ-weak', 'fail'], ['occ-strong', 'pass']].map(([occurrence, outcome]) => ({ resultProjectionId: `scanner-finding:${occurrence}`,
sourceKind: 'scanner-finding', sourceResultId: occurrence, sourceTool: 'purple-knight', sourceRuleId: `R-${occurrence}`, outcomeNormalized: outcome, title: occurrence })) as never,
referenceRuns: [{ schemaVersion: 1, id: 'run', projectId: 'dummy-project', assessmentId: 'dummy-assessment', scanRunId: 'scan', recognizerVersion: '1.0.0',
inputSha256: 'x', catalogHashes: [], createdAt: '2026-09-20T00:00:00Z', results: ['occ-weak', 'occ-strong'].map(occurrenceId => ({ occurrenceId, importFileId: 'f', sourcePosition: {},
references: [reference('mitre-d3fend', 'D3-SPP', 'Strong Password Policy'), reference('mitre-attack', 'TA0007', 'Discovery')] })) }] as never
});
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
const record = makeSheet('Purple Knight', [['Name', 'Status'], ['Synthetic weak password finding', 'Failed']], '', 'pk.csv').analysis.records[0];
record.findingOccurrenceId = 'occ-weak';
await act(async () => {
root.render(<FindingReader record={record} title="Synthetic weak password finding" linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} toolId="purple-knight" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
const d3fend = host.querySelector('[data-context-kind="declared-d3fend"]')!;
expect(d3fend.querySelector('h5')?.textContent).toBe('Declared D3FEND');
expect(d3fend.textContent).toContain('D3-SPP Strong Password Policy');
expect(d3fend.textContent).toContain('2 findings from 1 scanner declare this countermeasure');
expect(d3fend.textContent).toContain('1 fail · 1 pass');
expect(d3fend.textContent).toContain('Same countermeasure according to the scanners; not a proven common cause.');
const attack = host.querySelector('[data-context-kind="declared-attack"]')!;
expect(attack.textContent).toContain('TA0007 Discovery');
expect(attack.textContent).not.toContain('D3-SPP');
});
it('shows the CIS control area of a CIS-CAT finding from its own scan report', async () => {
const context = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-09-30T08:00:00Z', repositoryRevision: 3,
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
scannerFindings: [['occ-cis-1', '9.1.1', 'fail'], ['occ-cis-2', '9.1.2', 'fail'], ['occ-cis-3', '9.2.1', 'pass']].map(([occurrence, ruleId, outcome]) => ({
resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence, scanRunId: 'run-cis',
sourceTool: 'cis-cat', sourceRuleId: ruleId, outcomeNormalized: outcome, title: `${ruleId} Ensure synthetic firewall setting` })) as never,
cisReportSections: [{ scanRunId: 'run-cis', sectionKey: 'cis-cat-section-scores', tables: [{ name: 'Scores', headers: ['Description', 'Pass', 'Fail'],
rows: [['9 Windows Firewall', '1', '2'], ['9.1 Domain Profile', '0', '7']] }] }]
});
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(context);
const record = makeSheet('CIS-CAT', [['Title', 'Status'], ['9.1.1 Ensure synthetic firewall setting', 'Fail']], '', 'cis.csv').analysis.records[0];
record.findingOccurrenceId = 'occ-cis-1';
await act(async () => {
root.render(<FindingReader record={record} title="9.1.1 Ensure synthetic firewall setting" linkedSheets={[]} resultReferences={[]}
search="" position={0} recordCount={1} toolId="cis-cat" onPrevious={vi.fn()} onNext={vi.fn()} onReview={vi.fn()}/>);
await new Promise(resolve => setTimeout(resolve, 0));
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
const block = host.querySelector('[data-context-kind="cis-control-area"]')!;
expect([...block.querySelectorAll('[data-cis-section]')].map(item => item.getAttribute('data-cis-section'))).toEqual(['9', '9.1']);
expect(block.textContent).toContain('CIS 9 Windows Firewall');
expect(block.textContent).toContain('3 findings in this timeslot');
expect(block.textContent).toContain('CIS 9.1 Domain Profile');
expect(block.textContent).toContain('2 findings in this timeslot · 2 fail · 0 pass · scan report section score: 0 pass, 7 fail');
expect(block.textContent).toContain('Same benchmark section of this scan report; not a proven attack chain.');
});
it('has no global Context toolbar switch', () => {
const app = readFileSync(join(process.cwd(), 'src/app/App.tsx'), 'utf8');
expect(app).not.toContain('context-layer-btn');
expect(app).not.toContain('useContextLayerEnabled');
});
it('never touches reviews, scores or stored data from the layer code', () => {
const sources = ['src/features/context-layer/use-assessment-context.ts',
'src/features/finding-reader/FindingContext.tsx'].map(path => readFileSync(join(process.cwd(), path), 'utf8'));
for (const source of sources) {
expect(source).not.toMatch(/\b(?:setStatusFilter|setPathFindingIds|resetFilters|setActionOnly|updateReview|onReview|saveReview|recordRiskAcceptance|setSearch)\b/);
expect(source).not.toMatch(/method:\s*'(?:POST|PUT|PATCH|DELETE)'/);
}
});
});
describe('Finding Reader automatic context and gap review (2026-10-01)', () => {
function automaticContext() {
const evaluation = (identity: string, title: string) => ({
resultProjectionId: `policy-evaluation:${identity}`, sourceKind: 'policy-evaluation', sourceResultId: identity, policyScope: 'computer',
policyIdentityKey: identity, isDeviation: true, outcomeNormalized: 'fail', title
}) as unknown as UnifiedResultProjection;
const context = buildAssessmentContext({
projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 5,
observations: [], analyses: [], catalog: RELEASED_ASSESSMENT_CONTEXT_CATALOG,
evaluations: [evaluation('dummy-identity-winrm', 'Allow Basic authentication (WinRM Service)'), evaluation('dummy-identity-lanman', 'Synthetic Lanman share hardening'),
evaluation('dummy-identity-lanman-2', 'Synthetic Lanman cache hardening')]
});
return { ...context, gapReport: contextGapReport(context) };
}
it('shows the recognized cluster with its confidence and opens the review list with a copyable approval', async () => {
vi.spyOn(AssessorApiClient.prototype, 'getAssessmentContext').mockResolvedValue(automaticContext());
const writeText = vi.fn(async () => undefined);
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } });
await renderReader('dummy-identity-winrm');
// The recognized cluster is a badge and names its confidence as text.
const badges = [...host.querySelectorAll('[data-context-badges] [data-cluster-id]')];
expect(badges.map(item => item.getAttribute('data-cluster-id'))).toEqual(['lateral-movement-exposure']);
expect(badges[0].querySelector('[data-confidence]')?.textContent).toBe('recognized');
const section = host.querySelector<HTMLDetailsElement>('details.finding-context')!;
await act(async () => { section.querySelector('summary')!.click(); });
const recognized = section.querySelector('[data-context-recognized]')!;
expect(recognized.querySelector('h5')?.textContent).toBe('Recognized context');
expect(recognized.textContent).toContain('Lateral Movement Exposure');
expect(recognized.textContent).toContain('recognized · term in the rule text "WinRM"');
expect(recognized.textContent).toContain('It counts for the cluster but may be wrong');
const open = [...section.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Context gaps')!;
expect(section.textContent).toContain('2 deviations of this timeslot have no context yet');
await act(async () => { open.click(); });
const window = document.body.querySelector<HTMLElement>('[data-context-gaps]')!;
expect(window.querySelector('tr[data-tool="gpo"]')?.textContent).toContain('3');
const review = window.querySelector<HTMLTableRowElement>('tr[data-confidence="recognized"]')!;
expect(review.textContent).toContain('Allow Basic authentication (WinRM Service)');
const suggestion = window.querySelector<HTMLTableRowElement>('tr[data-suggestion="lanman"]')!;
expect(suggestion.textContent).toContain('Credential Relay Exposure');
const copy = [...window.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Copy approval')!;
expect(copy.disabled).toBe(true);
await act(async () => { review.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click(); });
await act(async () => { suggestion.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click(); });
await act(async () => { copy.click(); });
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('- Zuordnung bestaetigen: gpo "Allow Basic authentication (WinRM Service)" -> lateral-movement-exposure (recognized, title-term)'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('- Begriff: credential-relay-exposure <- "lanman" (deckt 2 Texte'));
expect(window.textContent).toContain('Copied.');
expect(window.textContent).toContain('Synthetic Lanman share hardening');
});
});