more features checks. setup improvements

This commit is contained in:
master
2026-02-13 02:04:55 +02:00
parent 9911b7d73c
commit 9ca2de05df
675 changed files with 37550 additions and 1826 deletions

View File

@@ -0,0 +1,446 @@
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Counterfactuals;
using Xunit;
namespace StellaOps.Policy.Tests.Counterfactuals;
/// <summary>
/// Behavioral tests for CounterfactualEngine.
/// Verifies the 5 counterfactual path types and their conditional logic.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Feature", "counterfactual-engine")]
public sealed class CounterfactualEngineTests
{
private readonly CounterfactualEngine _engine;
public CounterfactualEngineTests()
{
_engine = new CounterfactualEngine(NullLogger<CounterfactualEngine>.Instance);
}
// ── Already passing ────────────────────────────────────
[Fact(DisplayName = "ComputeAsync returns AlreadyPassing when verdict is Pass")]
public async Task ComputeAsync_AlreadyPassing_ReturnsNoPaths()
{
var finding = CreateBlockedFinding();
var verdict = CreateVerdict(PolicyVerdictStatus.Pass);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var result = await _engine.ComputeAsync(finding, verdict, document, config);
result.FindingId.Should().Be(finding.FindingId);
result.CurrentVerdict.Should().Be("Ship");
result.TargetVerdict.Should().Be("Ship");
result.Paths.Should().BeEmpty();
result.HasPaths.Should().BeFalse();
result.RecommendedPath.Should().BeNull();
}
// ── Exception path ─────────────────────────────────────
[Theory(DisplayName = "Exception path effort varies by severity")]
[InlineData(PolicySeverity.Critical, 5)]
[InlineData(PolicySeverity.High, 4)]
[InlineData(PolicySeverity.Medium, 3)]
[InlineData(PolicySeverity.Low, 2)]
public async Task ExceptionPath_EffortVariesBySeverity(PolicySeverity severity, int expectedEffort)
{
var finding = PolicyFinding.Create(
"finding-1",
severity,
cve: "CVE-2025-1234",
purl: "pkg:npm/lodash@4.17.0",
tags: ImmutableArray.Create("vex:not_affected", "reachability:no"));
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeReachabilityPaths = false,
IncludeVersionUpgradePaths = false,
IncludeCompensatingControlPaths = false,
IncludeExceptionPaths = true,
PolicyAllowsExceptions = true
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().ContainSingle();
var exceptionPath = result.Paths[0];
exceptionPath.Type.Should().Be(CounterfactualType.Exception);
exceptionPath.EstimatedEffort.Should().Be(expectedEffort);
exceptionPath.Description.Should().Contain("CVE-2025-1234");
}
[Fact(DisplayName = "Exception path excluded when PolicyAllowsExceptions is false")]
public async Task ExceptionPath_ExcludedWhenPolicyDisallows()
{
var finding = CreateBlockedFinding();
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeReachabilityPaths = false,
IncludeVersionUpgradePaths = false,
IncludeCompensatingControlPaths = false,
IncludeExceptionPaths = true,
PolicyAllowsExceptions = false
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.Exception);
}
[Fact(DisplayName = "Exception path excluded when IncludeExceptionPaths is false")]
public async Task ExceptionPath_ExcludedWhenOptionDisabled()
{
var finding = CreateBlockedFinding();
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeReachabilityPaths = false,
IncludeVersionUpgradePaths = false,
IncludeCompensatingControlPaths = false,
IncludeExceptionPaths = false
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.Exception);
}
// ── Version upgrade path ───────────────────────────────
[Fact(DisplayName = "Version upgrade path uses FixedVersionLookup delegate")]
public async Task VersionUpgradePath_UsesFixedVersionLookup()
{
var finding = PolicyFinding.Create(
"finding-1",
PolicySeverity.High,
cve: "CVE-2025-5678",
purl: "pkg:npm/lodash@4.17.0",
tags: ImmutableArray.Create("vex:not_affected", "reachability:no"));
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeExceptionPaths = false,
IncludeReachabilityPaths = false,
IncludeCompensatingControlPaths = false,
IncludeVersionUpgradePaths = true,
FixedVersionLookup = (cve, purl, ct) => Task.FromResult<string?>("4.17.21")
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().ContainSingle();
var versionPath = result.Paths[0];
versionPath.Type.Should().Be(CounterfactualType.VersionUpgrade);
versionPath.Description.Should().Contain("4.17.21");
versionPath.Conditions[0].CurrentValue.Should().Be("4.17.0");
versionPath.Conditions[0].RequiredValue.Should().Be("4.17.21");
}
[Fact(DisplayName = "Version upgrade path not produced when FixedVersionLookup returns null")]
public async Task VersionUpgradePath_NotProducedWhenNoFixAvailable()
{
var finding = CreateBlockedFinding();
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeExceptionPaths = false,
IncludeReachabilityPaths = false,
IncludeCompensatingControlPaths = false,
IncludeVersionUpgradePaths = true,
FixedVersionLookup = (cve, purl, ct) => Task.FromResult<string?>(null)
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.VersionUpgrade);
}
[Fact(DisplayName = "Version upgrade path not produced when FixedVersionLookup is not set")]
public async Task VersionUpgradePath_NotProducedWhenDelegateNotSet()
{
var finding = CreateBlockedFinding();
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeExceptionPaths = false,
IncludeReachabilityPaths = false,
IncludeCompensatingControlPaths = false,
IncludeVersionUpgradePaths = true,
FixedVersionLookup = null
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().NotContain(p => p.Type == CounterfactualType.VersionUpgrade);
}
// ── Compensating control path ──────────────────────────
[Fact(DisplayName = "Compensating control path has effort 4")]
public async Task CompensatingControlPath_HasEffort4()
{
var finding = CreateBlockedFinding();
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeExceptionPaths = false,
IncludeReachabilityPaths = false,
IncludeVersionUpgradePaths = false,
IncludeCompensatingControlPaths = true,
PolicyAllowsCompensatingControls = true
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().ContainSingle();
var controlPath = result.Paths[0];
controlPath.Type.Should().Be(CounterfactualType.CompensatingControl);
controlPath.EstimatedEffort.Should().Be(4);
}
[Fact(DisplayName = "Compensating control excluded when PolicyAllowsCompensatingControls is false")]
public async Task CompensatingControlPath_ExcludedWhenPolicyDisallows()
{
var finding = CreateBlockedFinding();
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var options = new CounterfactualOptions
{
IncludeVexPaths = false,
IncludeExceptionPaths = false,
IncludeReachabilityPaths = false,
IncludeVersionUpgradePaths = false,
IncludeCompensatingControlPaths = true,
PolicyAllowsCompensatingControls = false
};
var result = await _engine.ComputeAsync(finding, verdict, document, config, options);
result.Paths.Should().BeEmpty();
}
// ── Null validation ────────────────────────────────────
[Fact(DisplayName = "ComputeAsync throws on null finding")]
public async Task ComputeAsync_ThrowsOnNullFinding()
{
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var act = () => _engine.ComputeAsync(null!, verdict, document, config);
await act.Should().ThrowAsync<ArgumentNullException>();
}
[Fact(DisplayName = "ComputeAsync throws on null verdict")]
public async Task ComputeAsync_ThrowsOnNullVerdict()
{
var finding = CreateBlockedFinding();
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
var act = () => _engine.ComputeAsync(finding, null!, document, config);
await act.Should().ThrowAsync<ArgumentNullException>();
}
// ── Default options produce all applicable paths ──────
[Fact(DisplayName = "Default options include exception and compensating control paths for blocked finding")]
public async Task DefaultOptions_IncludeExceptionAndCompensatingControl()
{
var finding = PolicyFinding.Create(
"finding-1",
PolicySeverity.High,
cve: "CVE-2025-9999",
purl: "pkg:npm/express@4.18.0",
tags: ImmutableArray.Create("vex:not_affected", "reachability:no"));
var verdict = CreateVerdict(PolicyVerdictStatus.Blocked);
var document = CreateDocument();
var config = PolicyScoringConfig.Default;
// Use default options (all enabled, but no FixedVersionLookup)
var result = await _engine.ComputeAsync(finding, verdict, document, config);
// Exception + compensating control should always be present for a blocked finding with CVE
result.Paths.Should().Contain(p => p.Type == CounterfactualType.Exception);
result.Paths.Should().Contain(p => p.Type == CounterfactualType.CompensatingControl);
}
// ── Result model ───────────────────────────────────────
[Fact(DisplayName = "CounterfactualResult.Blocked sorts paths by effort")]
public void CounterfactualResult_Blocked_SortsByEffort()
{
var paths = new[]
{
CounterfactualPath.CompensatingControl("f1", effort: 4),
CounterfactualPath.Vex("affected", "CVE-1", effort: 2),
CounterfactualPath.Exception("CVE-1", effort: 5)
};
var result = CounterfactualResult.Blocked("f1", paths);
result.Paths[0].EstimatedEffort.Should().Be(2);
result.Paths[1].EstimatedEffort.Should().Be(4);
result.Paths[2].EstimatedEffort.Should().Be(5);
result.RecommendedPath!.Type.Should().Be(CounterfactualType.VexStatus);
}
[Fact(DisplayName = "CounterfactualResult.AlreadyPassing has Ship verdict and no paths")]
public void CounterfactualResult_AlreadyPassing_Properties()
{
var result = CounterfactualResult.AlreadyPassing("f1");
result.FindingId.Should().Be("f1");
result.CurrentVerdict.Should().Be("Ship");
result.TargetVerdict.Should().Be("Ship");
result.HasPaths.Should().BeFalse();
result.RecommendedPath.Should().BeNull();
}
// ── CounterfactualPath factory methods ─────────────────
[Fact(DisplayName = "CounterfactualPath.Vex creates correct path structure")]
public void CounterfactualPath_Vex_CorrectStructure()
{
var path = CounterfactualPath.Vex("affected", "CVE-2025-001", effort: 2);
path.Type.Should().Be(CounterfactualType.VexStatus);
path.EstimatedEffort.Should().Be(2);
path.Actor.Should().Contain("Vendor");
path.Conditions.Should().ContainSingle();
path.Conditions[0].CurrentValue.Should().Be("affected");
path.Conditions[0].RequiredValue.Should().Be("NotAffected");
path.Conditions[0].IsMet.Should().BeFalse();
}
[Fact(DisplayName = "CounterfactualPath.Exception creates correct path structure")]
public void CounterfactualPath_Exception_CorrectStructure()
{
var path = CounterfactualPath.Exception("CVE-2025-002", effort: 4);
path.Type.Should().Be(CounterfactualType.Exception);
path.EstimatedEffort.Should().Be(4);
path.Actor.Should().Contain("Security");
path.Description.Should().Contain("CVE-2025-002");
path.ActionUri.Should().Contain("CVE-2025-002");
}
[Fact(DisplayName = "CounterfactualPath.Reachability creates correct path structure")]
public void CounterfactualPath_Reachability_CorrectStructure()
{
var path = CounterfactualPath.Reachability("yes", "finding-1", effort: 4);
path.Type.Should().Be(CounterfactualType.Reachability);
path.EstimatedEffort.Should().Be(4);
path.Actor.Should().Contain("Development");
path.Conditions[0].CurrentValue.Should().Be("yes");
path.Conditions[0].RequiredValue.Should().Contain("not reachable");
}
[Fact(DisplayName = "CounterfactualPath.VersionUpgrade creates correct path structure")]
public void CounterfactualPath_VersionUpgrade_CorrectStructure()
{
var path = CounterfactualPath.VersionUpgrade("4.17.0", "4.17.21", "pkg:npm/lodash@4.17.0", effort: 2);
path.Type.Should().Be(CounterfactualType.VersionUpgrade);
path.EstimatedEffort.Should().Be(2);
path.Description.Should().Contain("4.17.21");
path.Conditions[0].CurrentValue.Should().Be("4.17.0");
path.Conditions[0].RequiredValue.Should().Be("4.17.21");
}
[Fact(DisplayName = "CounterfactualPath.CompensatingControl creates correct path structure")]
public void CounterfactualPath_CompensatingControl_CorrectStructure()
{
var path = CounterfactualPath.CompensatingControl("finding-1", effort: 4);
path.Type.Should().Be(CounterfactualType.CompensatingControl);
path.EstimatedEffort.Should().Be(4);
path.Actor.Should().Contain("Security");
path.ActionUri.Should().Contain("finding-1");
}
// ── Helpers ────────────────────────────────────────────
private static PolicyFinding CreateBlockedFinding()
{
return PolicyFinding.Create(
"finding-blocked-1",
PolicySeverity.High,
cve: "CVE-2025-1234",
purl: "pkg:npm/lodash@4.17.0",
tags: ImmutableArray.Create("vex:affected", "reachability:yes"));
}
private static PolicyVerdict CreateVerdict(PolicyVerdictStatus status)
{
return new PolicyVerdict(
FindingId: "finding-blocked-1",
Status: status,
RuleName: "BlockHigh",
Score: 75.0);
}
private static PolicyDocument CreateDocument()
{
var action = new PolicyAction(PolicyActionType.Block, null, null, null, false);
var rule = PolicyRule.Create(
"BlockHigh",
action,
ImmutableArray.Create(PolicySeverity.High, PolicySeverity.Critical),
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
ImmutableArray<string>.Empty,
PolicyRuleMatchCriteria.Empty,
expires: null,
justification: null);
return new PolicyDocument(
PolicySchema.CurrentVersion,
ImmutableArray.Create(rule),
ImmutableDictionary<string, string>.Empty,
PolicyExceptionConfiguration.Empty);
}
}

View File

@@ -0,0 +1,566 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Scoring;
using StellaOps.Policy.Scoring;
using Xunit;
using ScorePolicy = StellaOps.Policy.Scoring.ScorePolicy;
using WeightsBps = StellaOps.Policy.Scoring.WeightsBps;
namespace StellaOps.Policy.Tests.Scoring;
/// <summary>
/// Behavioral tests for the Evidence-Weighted Score (EWS) model components:
/// SignalWeights, ScoringWeights, GradeThresholds, SeverityMultipliers,
/// FreshnessDecayConfig, TrustSourceWeightService, and ScorePolicyLoader.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Feature", "evidence-weighted-score-model")]
public sealed class EvidenceWeightedScoreModelTests
{
// ─── SignalWeights (6-dimension) ───────────────────────────
[Fact(DisplayName = "SignalWeights.Default has 6 dimensions summing to 1.0")]
public void SignalWeights_Default_SixDimensionsSumToOne()
{
var w = SignalWeights.Default;
w.VexWeight.Should().Be(0.25);
w.EpssWeight.Should().Be(0.15);
w.ReachabilityWeight.Should().Be(0.25);
w.RuntimeWeight.Should().Be(0.15);
w.BackportWeight.Should().Be(0.10);
w.SbomLineageWeight.Should().Be(0.10);
w.TotalWeight.Should().BeApproximately(1.0, 0.001);
w.IsNormalized().Should().BeTrue();
}
[Fact(DisplayName = "SignalWeights.IsNormalized returns false for non-normalized weights")]
public void SignalWeights_IsNormalized_FalseForNonNormalized()
{
var w = new SignalWeights
{
VexWeight = 0.50,
EpssWeight = 0.50,
ReachabilityWeight = 0.50,
RuntimeWeight = 0.0,
BackportWeight = 0.0,
SbomLineageWeight = 0.0
};
w.TotalWeight.Should().Be(1.5);
w.IsNormalized().Should().BeFalse();
}
[Fact(DisplayName = "SignalWeights.IsNormalized respects tolerance parameter")]
public void SignalWeights_IsNormalized_RespectsToleranceParameter()
{
var w = new SignalWeights
{
VexWeight = 0.25,
EpssWeight = 0.15,
ReachabilityWeight = 0.25,
RuntimeWeight = 0.15,
BackportWeight = 0.10,
SbomLineageWeight = 0.11 // 1.01 total
};
w.IsNormalized(tolerance: 0.001).Should().BeFalse();
w.IsNormalized(tolerance: 0.02).Should().BeTrue();
}
// ─── ScoringWeights (6-category) ──────────────────────────
[Fact(DisplayName = "ScoringWeights.Default validates to true (sums to 1.0)")]
public void ScoringWeights_Default_ValidatesToTrue()
{
var w = new ScoringWeights();
w.Vulnerability.Should().Be(0.25);
w.Exploitability.Should().Be(0.20);
w.Reachability.Should().Be(0.20);
w.Compliance.Should().Be(0.15);
w.SupplyChain.Should().Be(0.10);
w.Mitigation.Should().Be(0.10);
w.Validate().Should().BeTrue();
}
[Fact(DisplayName = "ScoringWeights.Validate returns false when sum is not 1.0")]
public void ScoringWeights_Validate_FalseWhenNotNormalized()
{
var w = new ScoringWeights
{
Vulnerability = 0.50,
Exploitability = 0.50,
Reachability = 0.20
};
w.Validate().Should().BeFalse();
}
// ─── GradeThresholds ──────────────────────────────────────
[Theory(DisplayName = "GradeThresholds.GetGrade maps scores to correct letter grades")]
[InlineData(100, "A")]
[InlineData(95, "A")]
[InlineData(90, "A")]
[InlineData(89, "B")]
[InlineData(80, "B")]
[InlineData(79, "C")]
[InlineData(70, "C")]
[InlineData(69, "D")]
[InlineData(60, "D")]
[InlineData(59, "F")]
[InlineData(0, "F")]
[InlineData(-1, "F")]
public void GradeThresholds_GetGrade_MapsCorrectly(int score, string expectedGrade)
{
var thresholds = new GradeThresholds();
thresholds.GetGrade(score).Should().Be(expectedGrade);
}
[Fact(DisplayName = "GradeThresholds with custom values apply correctly")]
public void GradeThresholds_CustomValues_ApplyCorrectly()
{
var thresholds = new GradeThresholds
{
A = 95,
B = 85,
C = 75,
D = 65
};
thresholds.GetGrade(96).Should().Be("A");
thresholds.GetGrade(94).Should().Be("B");
thresholds.GetGrade(84).Should().Be("C");
thresholds.GetGrade(74).Should().Be("D");
thresholds.GetGrade(64).Should().Be("F");
}
// ─── SeverityMultipliers ──────────────────────────────────
[Theory(DisplayName = "SeverityMultipliers.GetMultiplier returns correct value for each severity")]
[InlineData("CRITICAL", 1.5)]
[InlineData("HIGH", 1.2)]
[InlineData("MEDIUM", 1.0)]
[InlineData("LOW", 0.8)]
[InlineData("INFORMATIONAL", 0.5)]
[InlineData("INFO", 0.5)]
[InlineData("critical", 1.5)]
[InlineData("high", 1.2)]
public void SeverityMultipliers_GetMultiplier_ReturnsCorrectValue(string severity, double expected)
{
var m = new SeverityMultipliers();
m.GetMultiplier(severity).Should().Be(expected);
}
[Fact(DisplayName = "SeverityMultipliers.GetMultiplier defaults to Medium for unknown severity")]
public void SeverityMultipliers_GetMultiplier_DefaultsToMedium()
{
var m = new SeverityMultipliers();
m.GetMultiplier("UNKNOWN").Should().Be(1.0);
m.GetMultiplier("").Should().Be(1.0);
}
// ─── FreshnessDecayConfig ─────────────────────────────────
[Fact(DisplayName = "FreshnessDecayConfig defaults match specification")]
public void FreshnessDecayConfig_Defaults_MatchSpec()
{
var config = new FreshnessDecayConfig();
config.SbomDecayStartHours.Should().Be(168); // 7 days
config.FeedDecayStartHours.Should().Be(24);
config.DecayRatePerHour.Should().Be(0.001);
config.MinimumFreshness.Should().Be(0.5);
}
// ─── WeightsBps (4-factor basis points) ───────────────────
[Fact(DisplayName = "WeightsBps.Default sums to 10000")]
public void WeightsBps_Default_SumsTo10000()
{
var w = WeightsBps.Default;
(w.BaseSeverity + w.Reachability + w.Evidence + w.Provenance).Should().Be(10000);
w.BaseSeverity.Should().Be(1000);
w.Reachability.Should().Be(4500);
w.Evidence.Should().Be(3000);
w.Provenance.Should().Be(1500);
}
[Fact(DisplayName = "ScorePolicy.Default validates weights correctly")]
public void ScorePolicy_Default_ValidatesWeights()
{
var policy = ScorePolicy.Default;
policy.ValidateWeights().Should().BeTrue();
policy.PolicyVersion.Should().Be("score.v1");
policy.ScoringProfile.Should().Be("advanced");
}
[Fact(DisplayName = "ScorePolicy.ValidateWeights rejects invalid sums")]
public void ScorePolicy_ValidateWeights_RejectsInvalidSum()
{
var policy = new ScorePolicy
{
PolicyVersion = "score.v1",
WeightsBps = new WeightsBps
{
BaseSeverity = 1000,
Reachability = 1000,
Evidence = 1000,
Provenance = 1000
}
};
policy.ValidateWeights().Should().BeFalse();
}
// ─── ReachabilityPolicyConfig ─────────────────────────────
[Fact(DisplayName = "ReachabilityPolicyConfig.Default has 6 hop buckets with decreasing scores")]
public void ReachabilityPolicyConfig_Default_HasDecreasingBuckets()
{
var config = ReachabilityPolicyConfig.Default;
config.HopBuckets.Should().NotBeNull();
config.HopBuckets!.Should().HaveCount(6);
config.HopBuckets![0].Score.Should().Be(100); // Direct call
config.HopBuckets![1].Score.Should().Be(90); // 1 hop
config.HopBuckets![2].Score.Should().Be(70); // 2-3 hops
config.HopBuckets![3].Score.Should().Be(50); // 4-5 hops
config.HopBuckets![4].Score.Should().Be(30); // 6-10 hops
config.HopBuckets![5].Score.Should().Be(10); // > 10 hops
config.UnreachableScore.Should().Be(0);
}
// ─── EvidencePolicyConfig ─────────────────────────────────
[Fact(DisplayName = "EvidencePolicyConfig.Default has 6 freshness buckets with decreasing freshness")]
public void EvidencePolicyConfig_Default_HasDecreasingFreshnessBuckets()
{
var config = EvidencePolicyConfig.Default;
config.FreshnessBuckets.Should().NotBeNull();
config.FreshnessBuckets!.Should().HaveCount(6);
config.FreshnessBuckets![0].Should().Be(new FreshnessBucket(7, 10000)); // 100%
config.FreshnessBuckets![1].Should().Be(new FreshnessBucket(30, 9000)); // 90%
config.FreshnessBuckets![2].Should().Be(new FreshnessBucket(90, 7000)); // 70%
config.FreshnessBuckets![3].Should().Be(new FreshnessBucket(180, 5000)); // 50%
config.FreshnessBuckets![4].Should().Be(new FreshnessBucket(365, 3000)); // 30%
config.FreshnessBuckets![5].Should().Be(new FreshnessBucket(int.MaxValue, 1000)); // 10%
}
// ─── ProvenanceLevels ─────────────────────────────────────
[Fact(DisplayName = "ProvenanceLevels.Default increases from Unsigned to Reproducible")]
public void ProvenanceLevels_Default_IncreasingScale()
{
var levels = ProvenanceLevels.Default;
levels.Unsigned.Should().Be(0);
levels.Signed.Should().Be(30);
levels.SignedWithSbom.Should().Be(60);
levels.SignedWithSbomAndAttestations.Should().Be(80);
levels.Reproducible.Should().Be(100);
}
}
/// <summary>
/// Tests for TrustSourceWeightService - the weighted source merging engine.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Feature", "evidence-weighted-score-model")]
public sealed class TrustSourceWeightServiceTests
{
private readonly TrustSourceWeightService _service;
public TrustSourceWeightServiceTests()
{
_service = new TrustSourceWeightService();
}
[Fact(DisplayName = "GetSourceWeight returns explicit weight for known sources")]
public void GetSourceWeight_ReturnsExplicitWeight_ForKnownSources()
{
var nvdSource = CreateSource(KnownSources.NvdNist, SourceCategory.Government);
var cisaSource = CreateSource(KnownSources.CisaKev, SourceCategory.Government);
var osvSource = CreateSource(KnownSources.Osv, SourceCategory.Community);
_service.GetSourceWeight(nvdSource).Should().Be(0.90);
_service.GetSourceWeight(cisaSource).Should().Be(0.98);
_service.GetSourceWeight(osvSource).Should().Be(0.75);
}
[Fact(DisplayName = "GetSourceWeight falls back to category weight for unknown sources")]
public void GetSourceWeight_FallsBackToCategoryWeight()
{
var unknownGov = CreateSource("unknown-gov-source", SourceCategory.Government);
_service.GetSourceWeight(unknownGov).Should().Be(0.95);
}
[Fact(DisplayName = "GetSourceWeight falls back to default for completely unknown source")]
public void GetSourceWeight_FallsBackToDefault()
{
// Create a source with a category that isn't in defaults - use Internal since it's defined
var config = new TrustSourceWeightConfig
{
Weights = System.Collections.Immutable.ImmutableDictionary<string, double>.Empty,
CategoryWeights = System.Collections.Immutable.ImmutableDictionary<SourceCategory, double>.Empty,
DefaultWeight = 0.42
};
var service = new TrustSourceWeightService(config);
var source = CreateSource("totally-unknown", SourceCategory.Government);
service.GetSourceWeight(source).Should().Be(0.42);
}
[Fact(DisplayName = "GetSourceWeight boosts signed data by 1.05x")]
public void GetSourceWeight_BoostsSignedData()
{
var unsigned = CreateSource(KnownSources.NvdNist, SourceCategory.Government, isSigned: false);
var signed = CreateSource(KnownSources.NvdNist, SourceCategory.Government, isSigned: true);
var unsignedWeight = _service.GetSourceWeight(unsigned);
var signedWeight = _service.GetSourceWeight(signed);
signedWeight.Should().BeGreaterThan(unsignedWeight);
signedWeight.Should().BeApproximately(0.90 * 1.05, 0.001);
}
[Fact(DisplayName = "GetSourceWeight penalizes stale data >7 days old")]
public void GetSourceWeight_PenalizesStaleData()
{
var fresh = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
fetchedAt: DateTimeOffset.UtcNow.AddDays(-1));
var stale = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
fetchedAt: DateTimeOffset.UtcNow.AddDays(-10));
var freshWeight = _service.GetSourceWeight(fresh);
var staleWeight = _service.GetSourceWeight(stale);
staleWeight.Should().BeLessThan(freshWeight);
}
[Fact(DisplayName = "GetSourceWeight applies double penalty for >30 days stale data")]
public void GetSourceWeight_AppliesDoublePenaltyForVeryStaleData()
{
var moderatelyStale = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
fetchedAt: DateTimeOffset.UtcNow.AddDays(-10));
var veryStale = CreateSource(KnownSources.NvdNist, SourceCategory.Government,
fetchedAt: DateTimeOffset.UtcNow.AddDays(-35));
var moderatelyStaleWeight = _service.GetSourceWeight(moderatelyStale);
var veryStaleWeight = _service.GetSourceWeight(veryStale);
// >30 days gets both the >7d (0.95x) and >30d (0.90x) penalties
veryStaleWeight.Should().BeLessThan(moderatelyStaleWeight);
}
[Fact(DisplayName = "GetSourceWeight clamps to [0.0, 1.0] range")]
public void GetSourceWeight_ClampsToValidRange()
{
// Even with boost, CISA-KEV (0.98 * 1.05 = 1.029) should clamp to 1.0
var signedCisa = CreateSource(KnownSources.CisaKev, SourceCategory.Government, isSigned: true);
_service.GetSourceWeight(signedCisa).Should().BeLessThanOrEqualTo(1.0);
_service.GetSourceWeight(signedCisa).Should().BeGreaterThanOrEqualTo(0.0);
}
// ─── MergeFindings ────────────────────────────────────────
[Fact(DisplayName = "MergeFindings with empty list returns zero confidence")]
public void MergeFindings_EmptyList_ReturnsZeroConfidence()
{
var result = _service.MergeFindings([]);
result.Confidence.Should().Be(0);
}
[Fact(DisplayName = "MergeFindings uses highest-weight source for severity")]
public void MergeFindings_UsesHighestWeightSourceForSeverity()
{
var findings = new[]
{
CreateFinding(KnownSources.CisaKev, SourceCategory.Government, severity: "CRITICAL", cvss: 9.8),
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "HIGH", cvss: 7.5)
};
var result = _service.MergeFindings(findings);
// CISA-KEV has highest weight (0.98), so its severity should be used
result.Severity.Should().Be("CRITICAL");
result.ContributingSources.Should().HaveCount(2);
result.ContributingSources[0].Should().Be(KnownSources.CisaKev);
}
[Fact(DisplayName = "MergeFindings computes weighted CVSS average")]
public void MergeFindings_ComputesWeightedCvssAverage()
{
var findings = new[]
{
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "CRITICAL", cvss: 9.0),
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "CRITICAL", cvss: 7.0)
};
var result = _service.MergeFindings(findings);
result.CvssScore.Should().NotBeNull();
// Weighted average: (9.0 * 0.90 + 7.0 * 0.75) / (0.90 + 0.75) = 13.35 / 1.65 = 8.09
result.CvssScore!.Value.Should().BeApproximately(8.09, 0.1);
}
[Fact(DisplayName = "MergeFindings applies corroboration boost when sources agree on severity")]
public void MergeFindings_AppliesCorroborationBoost()
{
var findings = new[]
{
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "HIGH", cvss: 8.0),
CreateFinding(KnownSources.CisaKev, SourceCategory.Government, severity: "HIGH", cvss: 8.5),
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "HIGH", cvss: 7.5)
};
var result = _service.MergeFindings(findings);
result.Corroborated.Should().BeTrue();
result.CorroborationBoost.Should().BeGreaterThan(0);
}
[Fact(DisplayName = "MergeFindings does not corroborate when severities disagree")]
public void MergeFindings_NoCorroborationWhenSeveritiesDisagree()
{
var findings = new[]
{
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "HIGH", cvss: 8.0),
CreateFinding(KnownSources.Osv, SourceCategory.Community, severity: "CRITICAL", cvss: 9.5)
};
var result = _service.MergeFindings(findings);
result.Corroborated.Should().BeFalse();
result.CorroborationBoost.Should().Be(0);
}
[Fact(DisplayName = "MergeFindings selects earliest fix version")]
public void MergeFindings_SelectsEarliestFixVersion()
{
var findings = new[]
{
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, fixVersion: "2.0.1"),
CreateFinding(KnownSources.VendorAdvisory, SourceCategory.Vendor, fixVersion: "1.9.5")
};
var result = _service.MergeFindings(findings);
// Should pick earliest (alphabetical sort: "1.9.5" < "2.0.1")
result.FixVersion.Should().Be("1.9.5");
}
[Fact(DisplayName = "MergeFindings confidence is clamped to 1.0")]
public void MergeFindings_ConfidenceClamped()
{
// High weight source + corroboration boost could exceed 1.0
var findings = new[]
{
CreateFinding(KnownSources.CisaKev, SourceCategory.Government, severity: "CRITICAL"),
CreateFinding(KnownSources.NvdNist, SourceCategory.Government, severity: "CRITICAL"),
CreateFinding(KnownSources.VendorAdvisory, SourceCategory.Vendor, severity: "CRITICAL")
};
var result = _service.MergeFindings(findings);
result.Confidence.Should().BeLessThanOrEqualTo(1.0);
}
// ─── ScoringRulesSnapshotBuilder ──────────────────────────
[Fact(DisplayName = "ScoringRulesSnapshotBuilder.Build computes content-addressed digest")]
public void SnapshotBuilder_Build_ComputesDigest()
{
var snapshot = ScoringRulesSnapshotBuilder
.Create("snap-1", 1, DateTimeOffset.UtcNow)
.WithDescription("test snapshot")
.Build();
snapshot.Digest.Should().NotBeNullOrEmpty();
snapshot.Digest.Should().StartWith("sha256:");
}
[Fact(DisplayName = "ScoringRulesSnapshotBuilder.Build is deterministic")]
public void SnapshotBuilder_Build_IsDeterministic()
{
var ts = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var snap1 = ScoringRulesSnapshotBuilder
.Create("snap-1", 1, ts)
.WithDescription("test")
.Build();
var snap2 = ScoringRulesSnapshotBuilder
.Create("snap-1", 1, ts)
.WithDescription("test")
.Build();
snap1.Digest.Should().Be(snap2.Digest);
}
[Fact(DisplayName = "ScoringRulesSnapshotBuilder.Build throws when weights are invalid")]
public void SnapshotBuilder_Build_ThrowsOnInvalidWeights()
{
var builder = ScoringRulesSnapshotBuilder
.Create("snap-bad", 1, DateTimeOffset.UtcNow)
.WithWeights(new ScoringWeights
{
Vulnerability = 0.50,
Exploitability = 0.50,
Reachability = 0.50
});
var act = () => builder.Build();
act.Should().Throw<InvalidOperationException>()
.WithMessage("*weights*sum*");
}
// ─── Helpers ──────────────────────────────────────────────
private static SourceMetadata CreateSource(
string id,
SourceCategory category,
bool isSigned = false,
DateTimeOffset? fetchedAt = null)
{
return new SourceMetadata
{
Id = id,
Category = category,
IsSigned = isSigned,
FetchedAt = fetchedAt
};
}
private static SourceFinding CreateFinding(
string sourceId,
SourceCategory category,
string? severity = null,
double? cvss = null,
string? fixVersion = null)
{
return new SourceFinding
{
Source = new SourceMetadata
{
Id = sourceId,
Category = category
},
Severity = severity,
CvssScore = cvss,
FixVersion = fixVersion
};
}
}