import { describe, expect, it } from 'vitest'; import type { UnifiedResultProjection } from '../../src/domain/unified-result'; import { RELEASED_ASSESSMENT_CONTEXT_CATALOG as catalog } from '../../src/assessment-context/context-catalog'; import { buildAssessmentContext } from '../../src/assessment-context/context-engine'; import { contextGapReport, contextSuggestionApprovalText } from '../../src/assessment-context/context-gaps'; // User decision 2026-10-01: a gap list per tool with vocabulary suggestions that never contradict the catalogue. const finding = (occurrence: string, tool: string, outcome: string, title: string, ruleId = `RULE-${occurrence}`) => ({ resultProjectionId: `scanner-finding:${occurrence}`, sourceKind: 'scanner-finding', sourceResultId: occurrence, scanRunId: 'run-a', sourceTool: tool, sourceRuleId: ruleId, outcomeNormalized: outcome, title }) as unknown as UnifiedResultProjection; const build = (scannerFindings: UnifiedResultProjection[]) => buildAssessmentContext({ projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1, observations: [], analyses: [], catalog, scannerFindings }); const catalogued = catalog.scannerRules.find(rule => rule.tool === 'pingcastle')!; describe('context gap report', () => { const context = build([ finding('occ-catalogued', 'pingcastle', 'fail', catalogued.ruleText, catalogued.ruleId), finding('occ-recognized', 'purple-knight', 'fail', 'Users with SPN defined'), finding('occ-gap-1', 'purple-knight', 'fail', 'Synthetic quarterly widget drift'), finding('occ-gap-2', 'purple-knight', 'fail', 'Synthetic widget inventory mismatch'), finding('occ-passed', 'purple-knight', 'pass', 'Synthetic passed rule') ]); // The vocabulary would recognize the spooler; for the gap list it is switched off so the term surfaces as a suggestion. const withoutVocabulary = buildAssessmentContext({ projectId: 'dummy-project', assessmentIds: ['dummy-assessment'], generatedAt: '2026-10-01T08:00:00Z', repositoryRevision: 1, observations: [], analyses: [], catalog, recognitionTerms: [], scannerFindings: [finding('occ-gap-1', 'purple-knight', 'fail', 'Synthetic print spooler reachable on hosts'), finding('occ-gap-2', 'purple-knight', 'fail', 'Synthetic spooler without patch level')] }); it('counts each deviation once per tool by the strongest tier that places it; passed results are no deviations', () => { const report = contextGapReport(context); expect(report.coverage).toEqual([ { tool: 'pingcastle', deviations: 1, catalogued: 1, declaredTechnique: 0, recognized: 0, estimated: 0, gaps: 0 }, { tool: 'purple-knight', deviations: 3, catalogued: 0, declaredTechnique: 0, recognized: 1, estimated: 0, gaps: 2 } ]); expect(report.gaps.map(item => item.key)).toEqual(['finding:occ-gap-1', 'finding:occ-gap-2']); // The review list holds every automatic assignment with its confidence and method. expect(report.assignments.map(item => [item.text, item.clusterId, item.confidence, item.method])).toEqual([ ['Users with SPN defined', 'kerberoasting-exposure', 'recognized', 'title-term']]); }); it('suggests a term only for the one cluster every catalogued text containing it belongs to', () => { const report = contextGapReport(withoutVocabulary); const winrm = report.suggestions.find(item => item.term === 'spooler'); expect(winrm).toMatchObject({ clusterId: 'credential-relay-exposure', covers: 2 }); expect(winrm!.catalogueSupport).toBeGreaterThanOrEqual(2); // Every suggestion is backed by catalogued texts that all carry the suggested cluster. for (const suggestion of report.suggestions) { expect(suggestion.catalogueSupport).toBeGreaterThanOrEqual(2); } // Words that only the synthetic gaps share and the catalogue does not tie to one cluster are listed, never assigned. expect(report.suggestions.some(item => item.term === 'synthetic')).toBe(false); expect(report.unassignedTerms.map(item => item.term)).toContain('synthetic'); }); it('writes an approval text for the confirmed assignments and selected terms only', () => { const report = contextGapReport(withoutVocabulary); const confirmed = report.assignments.slice(0, 1); expect(confirmed).toHaveLength(1); const text = contextSuggestionApprovalText(report, report.suggestions.filter(item => item.term === 'spooler'), confirmed); expect(text.split('\n')).toEqual([ `Context freigeben (Katalog ${report.catalogVersion}, Vokabular ${report.vocabulary.version}):`, `- Zuordnung bestaetigen: ${confirmed[0].tool} ${confirmed[0].sourceRuleId} -> ${confirmed[0].clusterId} (${confirmed[0].confidence}, ${confirmed[0].method})`, expect.stringMatching(/^- Begriff: credential-relay-exposure <- "spooler" \(deckt 2 Texte, Katalogbeleg \d+ Texte\)$/) ]); }); });