import { describe, expect, it } from 'vitest'; import type { FindingOccurrence } from '../../src/domain/canonical-model'; import type { EnvironmentObservation } from '../../src/domain/environment-context'; import { advanceFindingObligation, comparisonObservationTime, comparisonScopeIdentity, evaluateRemediationEvidence, findingComparisonIdentity, retireFindingObligation, type FindingObligation } from '../../src/domain/timeslot-comparison'; import type { ToolTimeslotComparisonDefinition } from '../../src/domain/tool-profile-definition'; const provenance = { decisionSource: 'source-native' as const, confidence: { level: 'high' as const, score: 1, reasons: ['synthetic'] }, mappingVersion: '1' }; function occurrence(id: string, ruleId = 'RULE-1', asset = 'Server-A'): FindingOccurrence { return { id, definitionId: 'definition-rule-1', scanRunId: `run-${id}`, importFileId: `file-${id}`, sourceIdentity: { tool: { id: 'scanner', name: 'Synthetic scanner' }, canonicalRuleId: ruleId }, sourcePosition: { rowIndex: 1 }, originalFields: [], fieldMappings: [], severity: { originalValue: 'high', normalizedValue: 'high', provenance }, status: { originalValue: 'failed', normalizedValue: 'failed', provenance }, outcome: { originalValue: 'fail', normalizedValue: 'fail', provenance }, affectedAsset: { originalValue: asset, normalizedValue: asset, provenance } }; } const identity = (item: FindingOccurrence, objectKey = item.affectedAsset?.normalizedValue ?? '') => findingComparisonIdentity({ occurrence: item, granularity: 'asset', scope: { kind: 'forest', key: 'example.test' }, object: { kind: 'host', namespace: 'dns', key: objectKey } }); const scopeContract: ToolTimeslotComparisonDefinition = { version: '1.0.0', kind: 'available', granularity: 'scope', scope: { kind: 'host-benchmark', properties: ['host.name', 'benchmark.name'] }, observationTimeProperties: ['report.generated_at'], resultEvidence: 'explicit-results', negativeEvidence: 'unsupported', diagnosticPolicy: 'fail-closed-by-impact' }; function observation(property: string, value: string, id = property): EnvironmentObservation { return { version: 1, id, scanRunId: 'run-a', importFileId: 'file-a', tool: { id: 'scanner', name: 'Synthetic scanner' }, subject: { type: 'synthetic' }, property, label: property, originalValue: value, normalizedValue: value, valueType: 'string', state: 'observed', source: {}, mappingVersion: '1', attributes: {} }; } describe('timeslot comparison contract', () => { it('builds a scope only from all profile-declared observed properties', () => { const result = comparisonScopeIdentity(scopeContract, [ observation('host.name', 'Server-A'), observation('benchmark.name', 'CIS-1') ]); expect(result.state).toBe('verified'); if (result.state !== 'verified') return; expect(result.scope.kind).toBe('host-benchmark'); expect(result.scope.key).toContain('server-a'); expect(result.scope.key).toContain('cis-1'); }); it('fails closed for missing or conflicting scope properties', () => { expect(comparisonScopeIdentity(scopeContract, [observation('host.name', 'Server-A')])) .toEqual({ state: 'unverified', reasons: ['scope-property-missing'], properties: ['benchmark.name'] }); expect(comparisonScopeIdentity(scopeContract, [ observation('host.name', 'Server-A', 'host-a'), observation('host.name', 'Server-B', 'host-b'), observation('benchmark.name', 'CIS-1') ])).toEqual({ state: 'unverified', reasons: ['scope-property-ambiguous'], properties: ['host.name'] }); }); it('uses declared report time and never an import timestamp substitute', () => { expect(comparisonObservationTime(scopeContract, [])).toEqual({ state: 'unverified', reason: 'observation-time-missing' }); expect(comparisonObservationTime(scopeContract, [ observation('report.generated_at', '2026-02-01T10:00:00Z') ])).toEqual({ state: 'verified', observedAt: '2026-02-01T10:00:00.000Z', property: 'report.generated_at' }); }); it('keeps rule definition and object-specific comparison identity separate', () => { const serverA = identity(occurrence('a', 'RULE-1', 'Server-A')); const serverB = identity(occurrence('b', 'RULE-1', 'Server-B')); expect(serverA.state).toBe('verified'); expect(serverB.state).toBe('verified'); if (serverA.state !== 'verified' || serverB.state !== 'verified') return; expect(serverA.identity.ruleId).toBe(serverB.identity.ruleId); expect(serverA.identity.key).not.toBe(serverB.identity.key); }); it('does not treat a plain affected-asset display value as verified without kind and namespace', () => { const result = findingComparisonIdentity({ occurrence: occurrence('a'), granularity: 'asset', scope: { kind: 'forest', key: 'example.test' } }); expect(result).toEqual({ state: 'unverified', reasons: ['object-kind-missing', 'object-namespace-missing', 'object-key-missing'] }); }); it('verifies fail-to-pass only for the same identity and a newer compatible observation', () => { const baseline = identity(occurrence('a')); const sameTarget = identity(occurrence('b')); const differentTarget = identity(occurrence('c', 'RULE-1', 'Server-B')); const base = { baselineIdentity: baseline, baselineObservedAt: '2026-01-01T10:00:00.000Z', targetObservedAt: '2026-02-01T10:00:00.000Z', processingCompatible: true, coverage: 'complete' as const, absenceSemantics: 'not-reported' as const }; expect(evaluateRemediationEvidence({ ...base, targetIdentity: sameTarget, targetOutcome: 'pass' }).state) .toBe('verified-remediated'); expect(evaluateRemediationEvidence({ ...base, targetIdentity: differentTarget, targetOutcome: 'pass' })) .toMatchObject({ state: 'unverified-absent', reasons: ['comparison-identity-changed'] }); expect(evaluateRemediationEvidence({ ...base, targetIdentity: sameTarget, targetOutcome: 'fail' }).state) .toBe('confirmed-open'); }); it('keeps absence unresolved unless the exact identity is covered by explicit complete negative evidence', () => { const baseline = identity(occurrence('a')); if (baseline.state !== 'verified') throw new Error('Synthetic identity must be valid.'); const base = { baselineIdentity: baseline, baselineObservedAt: '2026-01-01T10:00:00.000Z', targetObservedAt: '2026-02-01T10:00:00.000Z', processingCompatible: true, targetOutcome: undefined }; expect(evaluateRemediationEvidence({ ...base, coverage: 'partial', coveredIdentityKeys: [baseline.identity.key], absenceSemantics: 'explicit-negative' }).state).toBe('unverified-absent'); expect(evaluateRemediationEvidence({ ...base, coverage: 'complete', coveredIdentityKeys: [], absenceSemantics: 'explicit-negative' }).state).toBe('unverified-absent'); expect(evaluateRemediationEvidence({ ...base, coverage: 'complete', coveredIdentityKeys: [baseline.identity.key], absenceSemantics: 'not-reported' }).state).toBe('unverified-absent'); expect(evaluateRemediationEvidence({ ...base, coverage: 'complete', coveredIdentityKeys: [baseline.identity.key], absenceSemantics: 'explicit-negative' }).state).toBe('verified-remediated'); }); it('treats result-impacting diagnostics and a mere reimport timestamp as unresolved', () => { const baseline = identity(occurrence('a')); const target = identity(occurrence('b')); const common = { baselineIdentity: baseline, targetIdentity: target, processingCompatible: true, baselineObservedAt: '2026-01-01T10:00:00.000Z', targetOutcome: 'pass' as const, coverage: 'complete' as const, absenceSemantics: 'not-reported' as const }; expect(evaluateRemediationEvidence({ ...common, targetObservedAt: '2026-01-01T10:00:00.000Z' }).state) .toBe('unverified-absent'); expect(evaluateRemediationEvidence({ ...common, targetObservedAt: '2026-02-01T10:00:00.000Z', blockingDiagnostics: ['coverage-unknown'] }).state).toBe('unverified-absent'); }); it('preserves obligation origin across unresolved follow-ups and requires audited retirement', () => { const verified = identity(occurrence('a')); if (verified.state !== 'verified') throw new Error('Synthetic identity must be valid.'); const obligation: FindingObligation = { version: '1.0.0', id: 'obligation-1', projectId: 'project-1', identity: verified.identity, originAssessmentId: 'assessment-a', originRunId: 'run-a', originOccurrenceId: 'a', originObservedAt: '2026-01-01T10:00:00.000Z', lastObservedAt: '2026-01-01T10:00:00.000Z', state: 'open-confirmed', stateReason: 'initial deviation' }; const pending = advanceFindingObligation(obligation, { version: '1.0.0', state: 'unverified-absent', identityKey: verified.identity.key, reasons: ['target-coverage-not-complete'] }, undefined, '2026-02-01T10:00:00.000Z'); expect(pending).toMatchObject({ state: 'awaiting-revalidation', originOccurrenceId: 'a', originObservedAt: '2026-01-01T10:00:00.000Z', lastObservedAt: '2026-02-01T10:00:00.000Z' }); expect(() => retireFindingObligation(pending, { actor: '', reason: 'scope ended', effectiveAt: '2026-03-01' })) .toThrow(/actor/); expect(retireFindingObligation(pending, { actor: 'synthetic-owner', reason: 'approved scope exit', effectiveAt: '2026-03-01T00:00:00.000Z' })).toMatchObject({ state: 'retired-out-of-scope', retirement: { actor: 'synthetic-owner' } }); }); });