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(); 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); 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); 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('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('.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(); }); expect(host.querySelector('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 31 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(); 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(); 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(); 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(); 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('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('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('[data-context-gaps]')!; expect(window.querySelector('tr[data-tool="gpo"]')?.textContent).toContain('3'); const review = window.querySelector('tr[data-confidence="recognized"]')!; expect(review.textContent).toContain('Allow Basic authentication (WinRM Service)'); const suggestion = window.querySelector('tr[data-suggestion="lanman"]')!; expect(suggestion.textContent).toContain('Credential Relay Exposure'); const copy = [...window.querySelectorAll('button')].find(button => button.textContent === 'Copy approval')!; expect(copy.disabled).toBe(true); await act(async () => { review.querySelector('input[type="checkbox"]')!.click(); }); await act(async () => { suggestion.querySelector('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'); }); });