Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Gate.Tests/VexGateServiceTests.cs
2026-01-07 09:43:12 +02:00

328 lines
11 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Unit tests for <see cref="VexGateService"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class VexGateServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly VexGatePolicyEvaluator _policyEvaluator;
private readonly Mock<IVexObservationProvider> _vexProviderMock;
public VexGateServiceTests()
{
_timeProvider = new FakeTimeProvider(
new DateTimeOffset(2026, 1, 6, 10, 30, 0, TimeSpan.Zero));
_policyEvaluator = new VexGatePolicyEvaluator(
NullLogger<VexGatePolicyEvaluator>.Instance);
_vexProviderMock = new Mock<IVexObservationProvider>();
}
[Fact]
public async Task EvaluateAsync_WithVexNotAffected_ReturnsPass()
{
_vexProviderMock
.Setup(p => p.GetVexStatusAsync("CVE-2025-1234", "pkg:npm/test@1.0.0", It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>
{
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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>());
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<VexGateService>.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<VexGateFinding>
{
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<VexGateFinding>());
Assert.Empty(results);
}
[Fact]
public async Task EvaluateBatchAsync_UsesBatchPrefetch_WhenAvailable()
{
var batchProviderMock = new Mock<IVexObservationBatchProvider>();
var prefetchedKeys = new List<VexLookupKey>();
batchProviderMock
.Setup(p => p.PrefetchAsync(It.IsAny<IReadOnlyList<VexLookupKey>>(), It.IsAny<CancellationToken>()))
.Callback<IReadOnlyList<VexLookupKey>, CancellationToken>((keys, _) => prefetchedKeys.AddRange(keys))
.Returns(Task.CompletedTask);
batchProviderMock
.Setup(p => p.GetVexStatusAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((VexObservationResult?)null);
batchProviderMock
.Setup(p => p.GetStatementsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>());
var service = new VexGateService(
_policyEvaluator,
_timeProvider,
NullLogger<VexGateService>.Instance,
batchProviderMock.Object);
var findings = new List<VexGateFinding>
{
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<IReadOnlyList<VexLookupKey>>(), It.IsAny<CancellationToken>()),
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<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new List<VexStatementInfo>
{
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<VexGateService>.Instance,
_vexProviderMock.Object);
}
private VexGateService CreateServiceWithoutVex()
{
return new VexGateService(
_policyEvaluator,
_timeProvider,
NullLogger<VexGateService>.Instance,
vexProvider: null);
}
}