// ----------------------------------------------------------------------------- // VexGateServiceTests.cs // Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service // Description: Unit tests for VexGateService. // ----------------------------------------------------------------------------- using System.Collections.Immutable; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using Moq; using Xunit; namespace StellaOps.Scanner.Gate.Tests; /// /// Unit tests for . /// [Trait("Category", "Unit")] public sealed class VexGateServiceTests { private readonly FakeTimeProvider _timeProvider; private readonly VexGatePolicyEvaluator _policyEvaluator; private readonly Mock _vexProviderMock; public VexGateServiceTests() { _timeProvider = new FakeTimeProvider( new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero)); _policyEvaluator = new VexGatePolicyEvaluator( NullLogger.Instance); _vexProviderMock = new Mock(); } [Fact] public async Task EvaluateAsync_WithVexNotAffected_ReturnsPass() { _vexProviderMock .Setup(p => p.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny())) .ReturnsAsync(new VexObservationResult { Status = VexStatus.NotAffected, Confidence = 0.95, }); _vexProviderMock .Setup(p => p.GetStatementsAsync("CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny())) .ReturnsAsync(new List { new() { StatementId = "stmt-001", IssuerId = "vendor-a", Status = VexStatus.NotAffected, Timestamp = _timeProvider.GetUtcNow().AddDays(-1), TrustWeight = 0.9, }, }); var service = CreateService(); var finding = new VexGateFinding { FindingId = "finding-001", VulnerabilityId = "CVE-2025-1234", Purl = "pkg:npm/test@1.0.0", ImageDigest = "sha256:abc123", IsReachable = true, }; var result = await service.EvaluateAsync(finding); Assert.Equal(VexGateDecision.Pass, result.Decision); Assert.Equal("pass-vendor-not-affected", result.PolicyRuleMatched); Assert.Single(result.ContributingStatements); Assert.Equal("stmt-001", result.ContributingStatements[0].StatementId); } [Fact] public async Task EvaluateAsync_ExploitableReachable_ReturnsBlock() { _vexProviderMock .Setup(p => p.GetVexStatusAsync("CVE-2025-5678", "pkg:npm/vuln@2.0.0", It.IsAny())) .ReturnsAsync(new VexObservationResult { Status = VexStatus.Affected, Confidence = 0.9, }); _vexProviderMock .Setup(p => p.GetStatementsAsync("CVE-2025-5678", "pkg:npm/vuln@2.0.0", It.IsAny())) .ReturnsAsync(new List()); var service = CreateService(); var finding = new VexGateFinding { FindingId = "finding-002", VulnerabilityId = "CVE-2025-5678", Purl = "pkg:npm/vuln@2.0.0", ImageDigest = "sha256:def456", IsReachable = true, IsExploitable = true, HasCompensatingControl = false, SeverityLevel = "critical", }; var result = await service.EvaluateAsync(finding); Assert.Equal(VexGateDecision.Block, result.Decision); Assert.Equal("block-exploitable-reachable", result.PolicyRuleMatched); Assert.True(result.Evidence.IsReachable); Assert.True(result.Evidence.IsExploitable); } [Fact] public async Task EvaluateAsync_NoVexProvider_UsesDefaultEvidence() { var service = new VexGateService( _policyEvaluator, _timeProvider, NullLogger.Instance, vexProvider: null); var finding = new VexGateFinding { FindingId = "finding-003", VulnerabilityId = "CVE-2025-9999", Purl = "pkg:npm/unknown@1.0.0", ImageDigest = "sha256:xyz789", IsReachable = false, SeverityLevel = "high", }; var result = await service.EvaluateAsync(finding); // High severity + not reachable = warn Assert.Equal(VexGateDecision.Warn, result.Decision); Assert.Null(result.Evidence.VendorStatus); Assert.Empty(result.ContributingStatements); } [Fact] public async Task EvaluateAsync_EvaluatedAtIsSet() { var service = CreateServiceWithoutVex(); var finding = new VexGateFinding { FindingId = "finding-004", VulnerabilityId = "CVE-2025-1111", Purl = "pkg:npm/pkg@1.0.0", ImageDigest = "sha256:time123", }; var result = await service.EvaluateAsync(finding); Assert.Equal(_timeProvider.GetUtcNow(), result.EvaluatedAt); } [Fact] public async Task EvaluateBatchAsync_ProcessesMultipleFindings() { var service = CreateServiceWithoutVex(); var findings = new List { new() { FindingId = "f1", VulnerabilityId = "CVE-1", Purl = "pkg:npm/a@1.0.0", ImageDigest = "sha256:batch", IsReachable = true, IsExploitable = true, HasCompensatingControl = false, }, new() { FindingId = "f2", VulnerabilityId = "CVE-2", Purl = "pkg:npm/b@1.0.0", ImageDigest = "sha256:batch", IsReachable = false, SeverityLevel = "high", }, new() { FindingId = "f3", VulnerabilityId = "CVE-3", Purl = "pkg:npm/c@1.0.0", ImageDigest = "sha256:batch", SeverityLevel = "low", }, }; var results = await service.EvaluateBatchAsync(findings); Assert.Equal(3, results.Length); Assert.Equal(VexGateDecision.Block, results[0].GateResult.Decision); Assert.Equal(VexGateDecision.Warn, results[1].GateResult.Decision); Assert.Equal(VexGateDecision.Warn, results[2].GateResult.Decision); // Default } [Fact] public async Task EvaluateBatchAsync_EmptyList_ReturnsEmpty() { var service = CreateServiceWithoutVex(); var results = await service.EvaluateBatchAsync(new List()); Assert.Empty(results); } [Fact] public async Task EvaluateBatchAsync_UsesBatchPrefetch_WhenAvailable() { var batchProviderMock = new Mock(); var prefetchedKeys = new List(); batchProviderMock .Setup(p => p.PrefetchAsync(It.IsAny>(), It.IsAny())) .Callback, CancellationToken>((keys, _) => prefetchedKeys.AddRange(keys)) .Returns(Task.CompletedTask); batchProviderMock .Setup(p => p.GetVexStatusAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((VexObservationResult?)null); batchProviderMock .Setup(p => p.GetStatementsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); var service = new VexGateService( _policyEvaluator, _timeProvider, NullLogger.Instance, batchProviderMock.Object); var findings = new List { new() { FindingId = "f1", VulnerabilityId = "CVE-1", Purl = "pkg:npm/a@1.0.0", ImageDigest = "sha256:batch", }, new() { FindingId = "f2", VulnerabilityId = "CVE-2", Purl = "pkg:npm/b@1.0.0", ImageDigest = "sha256:batch", }, }; await service.EvaluateBatchAsync(findings); batchProviderMock.Verify( p => p.PrefetchAsync(It.IsAny>(), It.IsAny()), Times.Once); Assert.Equal(2, prefetchedKeys.Count); } [Fact] public async Task EvaluateAsync_VexFixed_ReturnsPass() { _vexProviderMock .Setup(p => p.GetVexStatusAsync("CVE-2025-FIXED", "pkg:deb/fixed@1.0.0", It.IsAny())) .ReturnsAsync(new VexObservationResult { Status = VexStatus.Fixed, Confidence = 0.85, BackportHints = ImmutableArray.Create("deb:1.0.0-2ubuntu1"), }); _vexProviderMock .Setup(p => p.GetStatementsAsync("CVE-2025-FIXED", "pkg:deb/fixed@1.0.0", It.IsAny())) .ReturnsAsync(new List { new() { StatementId = "stmt-fixed", IssuerId = "ubuntu", Status = VexStatus.Fixed, Timestamp = _timeProvider.GetUtcNow().AddHours(-6), TrustWeight = 0.95, }, }); var service = CreateService(); var finding = new VexGateFinding { FindingId = "finding-fixed", VulnerabilityId = "CVE-2025-FIXED", Purl = "pkg:deb/fixed@1.0.0", ImageDigest = "sha256:ubuntu", IsReachable = true, }; var result = await service.EvaluateAsync(finding); Assert.Equal(VexGateDecision.Pass, result.Decision); Assert.Equal("pass-backport-confirmed", result.PolicyRuleMatched); Assert.Single(result.Evidence.BackportHints); } private VexGateService CreateService() { return new VexGateService( _policyEvaluator, _timeProvider, NullLogger.Instance, _vexProviderMock.Object); } private VexGateService CreateServiceWithoutVex() { return new VexGateService( _policyEvaluator, _timeProvider, NullLogger.Instance, vexProvider: null); } }