partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
public class CombinedImpactCalculatorTests
|
||||
{
|
||||
private readonly CombinedImpactCalculator _calculator;
|
||||
|
||||
public CombinedImpactCalculatorTests()
|
||||
{
|
||||
var impactCalculator = new ImpactScoreCalculator(NullLogger<ImpactScoreCalculator>.Instance);
|
||||
var uncertaintyCalculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
|
||||
_calculator = new CombinedImpactCalculator(
|
||||
impactCalculator,
|
||||
uncertaintyCalculator,
|
||||
NullLogger<CombinedImpactCalculator>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_HighImpactLowUncertainty_ReturnsHighPriority()
|
||||
{
|
||||
// Arrange
|
||||
var impactContext = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Production,
|
||||
DataSensitivity = DataSensitivity.Healthcare,
|
||||
FleetPrevalence = 0.9,
|
||||
SlaTier = SlaTier.MissionCritical,
|
||||
CvssScore = 9.8
|
||||
};
|
||||
var signalSnapshot = CreateFullSnapshot();
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(impactContext, signalSnapshot);
|
||||
|
||||
// Assert
|
||||
result.Impact.Score.Should().BeGreaterThan(0.8);
|
||||
result.Uncertainty.Entropy.Should().Be(0.0);
|
||||
result.EffectivePriority.Should().BeGreaterThan(0.8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_HighImpactHighUncertainty_ReducesPriority()
|
||||
{
|
||||
// Arrange
|
||||
var impactContext = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Production,
|
||||
DataSensitivity = DataSensitivity.Healthcare,
|
||||
FleetPrevalence = 0.9,
|
||||
SlaTier = SlaTier.MissionCritical,
|
||||
CvssScore = 9.8
|
||||
};
|
||||
var signalSnapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(impactContext, signalSnapshot, uncertaintyPenaltyFactor: 0.5);
|
||||
|
||||
// Assert
|
||||
result.Impact.Score.Should().BeGreaterThan(0.8);
|
||||
result.Uncertainty.Entropy.Should().Be(1.0);
|
||||
// Effective = impact * (1 - 1.0 * 0.5) = impact * 0.5
|
||||
result.EffectivePriority.Should().BeLessThan(result.Impact.Score);
|
||||
result.EffectivePriority.Should().BeApproximately(result.Impact.Score * 0.5, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_LowImpactLowUncertainty_ReturnsLowPriority()
|
||||
{
|
||||
// Arrange
|
||||
var impactContext = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Development,
|
||||
DataSensitivity = DataSensitivity.Public,
|
||||
FleetPrevalence = 0.1,
|
||||
SlaTier = SlaTier.NonCritical,
|
||||
CvssScore = 2.0
|
||||
};
|
||||
var signalSnapshot = CreateFullSnapshot();
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(impactContext, signalSnapshot);
|
||||
|
||||
// Assert
|
||||
result.Impact.Score.Should().BeLessThan(0.2);
|
||||
result.Uncertainty.Entropy.Should().Be(0.0);
|
||||
result.EffectivePriority.Should().BeLessThan(0.2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ZeroPenaltyFactor_IgnoresUncertainty()
|
||||
{
|
||||
// Arrange
|
||||
var impactContext = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Production,
|
||||
DataSensitivity = DataSensitivity.Healthcare,
|
||||
FleetPrevalence = 0.9,
|
||||
SlaTier = SlaTier.MissionCritical,
|
||||
CvssScore = 9.8
|
||||
};
|
||||
var signalSnapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(impactContext, signalSnapshot, uncertaintyPenaltyFactor: 0.0);
|
||||
|
||||
// Assert
|
||||
result.EffectivePriority.Should().BeApproximately(result.Impact.Score, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_FullPenaltyFactor_MaximumReduction()
|
||||
{
|
||||
// Arrange
|
||||
var impactContext = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Production,
|
||||
DataSensitivity = DataSensitivity.Healthcare,
|
||||
FleetPrevalence = 0.9,
|
||||
SlaTier = SlaTier.MissionCritical,
|
||||
CvssScore = 9.8
|
||||
};
|
||||
var signalSnapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(impactContext, signalSnapshot, uncertaintyPenaltyFactor: 1.0);
|
||||
|
||||
// Assert
|
||||
// With 100% entropy and 100% penalty, effective priority = impact * (1 - 1.0) = 0
|
||||
result.EffectivePriority.Should().BeApproximately(0.0, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_IsDeterministic_SameInputSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var impactContext = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Staging,
|
||||
DataSensitivity = DataSensitivity.Pii,
|
||||
FleetPrevalence = 0.5,
|
||||
SlaTier = SlaTier.Important,
|
||||
CvssScore = 7.5
|
||||
};
|
||||
var signalSnapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var result1 = _calculator.Calculate(impactContext, signalSnapshot);
|
||||
var result2 = _calculator.Calculate(impactContext, signalSnapshot);
|
||||
|
||||
// Assert
|
||||
result1.EffectivePriority.Should().Be(result2.EffectivePriority);
|
||||
result1.EffectivePriorityBasisPoints.Should().Be(result2.EffectivePriorityBasisPoints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_BasisPointsCalculatedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var impactContext = ImpactContext.DefaultForUnknowns();
|
||||
var signalSnapshot = CreateFullSnapshot();
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(impactContext, signalSnapshot);
|
||||
|
||||
// Assert
|
||||
var expectedBasisPoints = (int)Math.Round(result.EffectivePriority * 10000);
|
||||
result.EffectivePriorityBasisPoints.Should().Be(expectedBasisPoints);
|
||||
}
|
||||
|
||||
private SignalSnapshot CreateFullSnapshot()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new SignalSnapshot
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Purl = "pkg:maven/test@1.0",
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary { Status = "affected", Confidence = 0.95, StatementCount = 3, ComputedAt = now }, now),
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = now }, now),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
|
||||
Runtime = SignalState<RuntimeEvidence>.Queried(
|
||||
new RuntimeEvidence { Detected = true, DetectedAt = now }, now),
|
||||
Backport = SignalState<BackportEvidence>.Queried(
|
||||
new BackportEvidence { Detected = false, AnalyzedAt = now }, now),
|
||||
Sbom = SignalState<SbomLineageEvidence>.Queried(
|
||||
new SbomLineageEvidence { HasLineage = true, AnalyzedAt = now }, now),
|
||||
Cvss = SignalState<CvssEvidence>.Queried(
|
||||
new CvssEvidence { Version = "3.1", BaseScore = 9.8, Severity = "CRITICAL", Source = "NVD", PublishedAt = now }, now),
|
||||
SnapshotAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private SignalSnapshot CreatePartialSnapshot()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return new SignalSnapshot
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Purl = "pkg:maven/test@1.0",
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary { Status = "affected", Confidence = 0.95, StatementCount = 3, ComputedAt = now }, now),
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = now }, now),
|
||||
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
|
||||
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
|
||||
Backport = SignalState<BackportEvidence>.NotQueried(),
|
||||
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
|
||||
Cvss = SignalState<CvssEvidence>.NotQueried(),
|
||||
SnapshotAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// <copyright file="DeltaIfPresentCalculatorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
public sealed class DeltaIfPresentCalculatorTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly UncertaintyScoreCalculator _uncertaintyCalculator;
|
||||
private readonly TrustScoreAggregator _trustAggregator;
|
||||
private readonly DeltaIfPresentCalculator _calculator;
|
||||
|
||||
public DeltaIfPresentCalculatorTests()
|
||||
{
|
||||
_uncertaintyCalculator = new UncertaintyScoreCalculator(
|
||||
NullLogger<UncertaintyScoreCalculator>.Instance);
|
||||
_trustAggregator = new TrustScoreAggregator(
|
||||
NullLogger<TrustScoreAggregator>.Instance);
|
||||
_calculator = new DeltaIfPresentCalculator(
|
||||
NullLogger<DeltaIfPresentCalculator>.Instance,
|
||||
_uncertaintyCalculator,
|
||||
_trustAggregator,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSingleSignalDelta_VexSignal_ReturnsExpectedDelta()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var result = _calculator.CalculateSingleSignalDelta(snapshot, "VEX", 0.0);
|
||||
|
||||
// Assert
|
||||
result.Signal.Should().Be("VEX");
|
||||
result.AssumedValue.Should().Be(0.0);
|
||||
result.SignalWeight.Should().Be(0.25);
|
||||
result.HypotheticalEntropy.Should().BeLessThan(result.CurrentEntropy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSingleSignalDelta_HighRiskValue_IncreasesScore()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var lowRisk = _calculator.CalculateSingleSignalDelta(snapshot, "EPSS", 0.0);
|
||||
var highRisk = _calculator.CalculateSingleSignalDelta(snapshot, "EPSS", 1.0);
|
||||
|
||||
// Assert
|
||||
highRisk.HypotheticalScore.Should().BeGreaterThan(lowRisk.HypotheticalScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSingleSignalDelta_AddsSignal_DecreasesEntropy()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var result = _calculator.CalculateSingleSignalDelta(snapshot, "Runtime", 0.5);
|
||||
|
||||
// Assert
|
||||
result.EntropyDelta.Should().BeLessThan(0);
|
||||
result.HypotheticalEntropy.Should().BeLessThan(result.CurrentEntropy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateFullAnalysis_ReturnsAllGaps()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var analysis = _calculator.CalculateFullAnalysis(snapshot);
|
||||
|
||||
// Assert
|
||||
analysis.GapAnalysis.Should().HaveCountGreaterThan(0);
|
||||
analysis.PrioritizedGaps.Should().NotBeEmpty();
|
||||
analysis.ComputedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateFullAnalysis_PrioritizesByMaxImpact()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateEmptySnapshot();
|
||||
|
||||
// Act
|
||||
var analysis = _calculator.CalculateFullAnalysis(snapshot);
|
||||
|
||||
// Assert - VEX and Reachability have highest weights (0.25 each)
|
||||
var topPriority = analysis.PrioritizedGaps.Take(2);
|
||||
topPriority.Should().Contain(s => s == "VEX" || s == "Reachability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateFullAnalysis_IncludesBestWorstPriorCases()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var analysis = _calculator.CalculateFullAnalysis(snapshot);
|
||||
|
||||
// Assert
|
||||
foreach (var gap in analysis.GapAnalysis)
|
||||
{
|
||||
gap.BestCase.Should().NotBeNull();
|
||||
gap.WorstCase.Should().NotBeNull();
|
||||
gap.PriorCase.Should().NotBeNull();
|
||||
|
||||
gap.BestCase.AssumedValue.Should().Be(0.0);
|
||||
gap.WorstCase.AssumedValue.Should().Be(1.0);
|
||||
gap.MaxImpact.Should().BeGreaterOrEqualTo(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScoreBounds_NoGaps_ReturnsSingleValue()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateFullSnapshot();
|
||||
|
||||
// Act
|
||||
var bounds = _calculator.CalculateScoreBounds(snapshot);
|
||||
|
||||
// Assert
|
||||
bounds.GapCount.Should().Be(0);
|
||||
bounds.Range.Should().Be(0.0);
|
||||
bounds.MinimumScore.Should().Be(bounds.MaximumScore);
|
||||
bounds.MissingWeightPercentage.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScoreBounds_WithGaps_ReturnsRange()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var bounds = _calculator.CalculateScoreBounds(snapshot);
|
||||
|
||||
// Assert
|
||||
bounds.GapCount.Should().BeGreaterThan(0);
|
||||
bounds.Range.Should().BeGreaterThan(0.0);
|
||||
bounds.MaximumScore.Should().BeGreaterThanOrEqualTo(bounds.MinimumScore);
|
||||
bounds.MissingWeightPercentage.Should().BeGreaterThan(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateScoreBounds_EmptySnapshot_ReturnsFullRange()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateEmptySnapshot();
|
||||
|
||||
// Act
|
||||
var bounds = _calculator.CalculateScoreBounds(snapshot);
|
||||
|
||||
// Assert
|
||||
bounds.GapCount.Should().Be(6); // All 6 signals missing
|
||||
bounds.CurrentEntropy.Should().Be(1.0);
|
||||
bounds.MissingWeightPercentage.Should().Be(100.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("VEX", 0.25)]
|
||||
[InlineData("EPSS", 0.15)]
|
||||
[InlineData("Reachability", 0.25)]
|
||||
[InlineData("Runtime", 0.15)]
|
||||
[InlineData("Backport", 0.10)]
|
||||
[InlineData("SBOMLineage", 0.10)]
|
||||
public void CalculateSingleSignalDelta_CorrectWeightPerSignal(string signal, double expectedWeight)
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreateEmptySnapshot();
|
||||
|
||||
// Act
|
||||
var result = _calculator.CalculateSingleSignalDelta(snapshot, signal, 0.5);
|
||||
|
||||
// Assert
|
||||
result.SignalWeight.Should().Be(expectedWeight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateSingleSignalDelta_DeterministicOutput()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act - Run twice
|
||||
var result1 = _calculator.CalculateSingleSignalDelta(snapshot, "VEX", 0.5);
|
||||
var result2 = _calculator.CalculateSingleSignalDelta(snapshot, "VEX", 0.5);
|
||||
|
||||
// Assert - Results should be identical
|
||||
result1.CurrentScore.Should().Be(result2.CurrentScore);
|
||||
result1.HypotheticalScore.Should().Be(result2.HypotheticalScore);
|
||||
result1.CurrentEntropy.Should().Be(result2.CurrentEntropy);
|
||||
result1.HypotheticalEntropy.Should().Be(result2.HypotheticalEntropy);
|
||||
}
|
||||
|
||||
private SignalSnapshot CreateEmptySnapshot()
|
||||
{
|
||||
return SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private SignalSnapshot CreatePartialSnapshot()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return new SignalSnapshot
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Purl = "pkg:maven/test@1.0",
|
||||
Vex = SignalState<VexClaimSummary>.NotQueried(),
|
||||
Epss = SignalState<EpssEvidence>.NotQueried(),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
|
||||
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
|
||||
Backport = SignalState<BackportEvidence>.NotQueried(),
|
||||
Sbom = SignalState<SbomLineageEvidence>.Queried(
|
||||
new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = now, HasProvenance = true }, now),
|
||||
Cvss = SignalState<CvssEvidence>.NotQueried(),
|
||||
SnapshotAt = now
|
||||
};
|
||||
}
|
||||
|
||||
private SignalSnapshot CreateFullSnapshot()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return new SignalSnapshot
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Purl = "pkg:maven/test@1.0",
|
||||
Vex = SignalState<VexClaimSummary>.Queried(
|
||||
new VexClaimSummary { Status = "affected", Confidence = 0.95, StatementCount = 3, ComputedAt = now }, now),
|
||||
Epss = SignalState<EpssEvidence>.Queried(
|
||||
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = now }, now),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
|
||||
Runtime = SignalState<RuntimeEvidence>.Queried(
|
||||
new RuntimeEvidence { Detected = true, Source = "test", ObservationStart = now.AddDays(-7), ObservationEnd = now, Confidence = 0.9 }, now),
|
||||
Backport = SignalState<BackportEvidence>.Queried(
|
||||
new BackportEvidence { Detected = false, Source = "test", DetectedAt = now, Confidence = 0.85 }, now),
|
||||
Sbom = SignalState<SbomLineageEvidence>.Queried(
|
||||
new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = now, HasProvenance = true }, now),
|
||||
Cvss = SignalState<CvssEvidence>.Queried(
|
||||
new CvssEvidence { Vector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", Version = "3.1", BaseScore = 9.8, Severity = "CRITICAL", Source = "NVD", PublishedAt = now }, now),
|
||||
SnapshotAt = now
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EwsCalculatorTests.cs
|
||||
// Sprint: SPRINT_20260208_045_Policy_evidence_weighted_score_model
|
||||
// Task: T1 - Evidence-Weighted Score (EWS) Model (6-Dimension Scoring)
|
||||
// Description: Unit tests for EWS calculator.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Determinization.Scoring.EvidenceWeightedScoring;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
public sealed class EwsCalculatorTests
|
||||
{
|
||||
private readonly EwsCalculator _calculator;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public EwsCalculatorTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-02-08T12:00:00Z"));
|
||||
_calculator = EwsCalculator.CreateDefault(_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithEmptySignal_ReturnsConservativeScore()
|
||||
{
|
||||
// Arrange
|
||||
var signal = EwsSignalInput.Empty;
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(signal);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.InRange(result.Score, 40, 80); // Conservative assumptions
|
||||
Assert.Equal(6, result.Dimensions.Length); // All 6 dimensions
|
||||
Assert.True(result.NeedsReview); // Low confidence triggers review
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithHighRiskSignals_ReturnsHighScore()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 4, // R4: Reachable from entrypoint
|
||||
IsInKev = true, // Known exploited
|
||||
EpssProbability = 0.85,
|
||||
VexStatus = "affected",
|
||||
CveId = "CVE-2024-12345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(signal);
|
||||
|
||||
// Assert
|
||||
Assert.InRange(result.Score, 70, 100); // KEV floor should kick in
|
||||
Assert.Equal("Critical", result.RiskTier);
|
||||
Assert.Contains(result.AppliedGuardrails, g => g.StartsWith("kev_floor"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_WithMitigatedSignals_ReturnsLowScore()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 0, // R0: Unreachable
|
||||
BackportDetected = true,
|
||||
BackportConfidence = 0.95,
|
||||
VexStatus = "not_affected",
|
||||
VexJustification = "Component not used in this deployment"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(signal);
|
||||
|
||||
// Assert
|
||||
Assert.InRange(result.Score, 0, 25); // not_affected cap
|
||||
Assert.Equal("Informational", result.RiskTier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_AllDimensionsPopulated_ReturnsCorrectStructure()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 2,
|
||||
CallGraphConfidence = 0.8,
|
||||
InstrumentationCoverage = 0.7,
|
||||
RuntimeInvocationCount = 50,
|
||||
BackportDetected = false,
|
||||
EpssProbability = 0.25,
|
||||
CvssBaseScore = 7.5,
|
||||
SbomCompleteness = 0.9,
|
||||
SbomSigned = true,
|
||||
VexStatus = "under_investigation",
|
||||
CveId = "CVE-2024-99999",
|
||||
Purl = "pkg:npm/example@1.0.0"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(signal);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(6, result.Dimensions.Length);
|
||||
|
||||
// Check each dimension is present
|
||||
Assert.Contains(result.Dimensions, d => d.Dimension == EwsDimension.Reachability);
|
||||
Assert.Contains(result.Dimensions, d => d.Dimension == EwsDimension.RuntimeSignals);
|
||||
Assert.Contains(result.Dimensions, d => d.Dimension == EwsDimension.BackportEvidence);
|
||||
Assert.Contains(result.Dimensions, d => d.Dimension == EwsDimension.Exploitability);
|
||||
Assert.Contains(result.Dimensions, d => d.Dimension == EwsDimension.SourceConfidence);
|
||||
Assert.Contains(result.Dimensions, d => d.Dimension == EwsDimension.MitigationStatus);
|
||||
|
||||
// Check metadata propagated
|
||||
Assert.Equal("CVE-2024-99999", result.CveId);
|
||||
Assert.Equal("pkg:npm/example@1.0.0", result.Purl);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), result.CalculatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_CustomWeights_UsesProvidedWeights()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
IsInKev = false,
|
||||
EpssProbability = 0.9 // High exploitability
|
||||
};
|
||||
|
||||
var exploitHeavyWeights = new EwsDimensionWeights
|
||||
{
|
||||
Reachability = 0.1,
|
||||
RuntimeSignals = 0.1,
|
||||
BackportEvidence = 0.05,
|
||||
Exploitability = 0.5, // Heavy weight on exploitability
|
||||
SourceConfidence = 0.1,
|
||||
MitigationStatus = 0.15
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(signal, exploitHeavyWeights);
|
||||
|
||||
// Assert
|
||||
var xplDim = result.GetDimension(EwsDimension.Exploitability);
|
||||
Assert.NotNull(xplDim);
|
||||
Assert.Equal(0.5, xplDim.Weight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_SpeculativeScore_AppliesCap()
|
||||
{
|
||||
// Arrange - no real evidence, just defaults/assumptions
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
CveId = "CVE-2024-00001"
|
||||
};
|
||||
|
||||
var guardrails = new EwsGuardrails
|
||||
{
|
||||
SpeculativeCap = 55
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _calculator.Calculate(signal, guardrails: guardrails);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Score <= 55, $"Score {result.Score} should be capped at speculative cap 55");
|
||||
Assert.True(result.NeedsReview); // Should need review due to low confidence
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Deterministic_SameInputsProduceSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 3,
|
||||
EpssProbability = 0.45,
|
||||
VexStatus = "affected"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = _calculator.Calculate(signal);
|
||||
var result2 = _calculator.Calculate(signal);
|
||||
|
||||
// Assert - should be identical
|
||||
Assert.Equal(result1.Score, result2.Score);
|
||||
Assert.Equal(result1.RawScore, result2.RawScore);
|
||||
Assert.Equal(result1.Confidence, result2.Confidence);
|
||||
Assert.Equal(result1.CalculatedAt, result2.CalculatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDimension_ReachabilityR4_ReturnsHighScore()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 4,
|
||||
CallGraphConfidence = 0.9
|
||||
};
|
||||
|
||||
// Act
|
||||
var dimScore = _calculator.CalculateDimension(EwsDimension.Reachability, signal, 0.25);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(EwsDimension.Reachability, dimScore.Dimension);
|
||||
Assert.Equal("RCH", dimScore.Code);
|
||||
Assert.InRange(dimScore.Score, 90, 100);
|
||||
Assert.True(dimScore.Confidence > 0.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDimension_ReachabilityR0_ReturnsLowScore()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 0,
|
||||
CallGraphConfidence = 0.95
|
||||
};
|
||||
|
||||
// Act
|
||||
var dimScore = _calculator.CalculateDimension(EwsDimension.Reachability, signal, 0.25);
|
||||
|
||||
// Assert
|
||||
Assert.InRange(dimScore.Score, 0, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNormalizer_AllDimensions_ReturnsNonNull()
|
||||
{
|
||||
foreach (EwsDimension dimension in Enum.GetValues<EwsDimension>())
|
||||
{
|
||||
var normalizer = _calculator.GetNormalizer(dimension);
|
||||
Assert.NotNull(normalizer);
|
||||
Assert.Equal(dimension, normalizer.Dimension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EwsDimensionCodesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(EwsDimension.Reachability, "RCH")]
|
||||
[InlineData(EwsDimension.RuntimeSignals, "RTS")]
|
||||
[InlineData(EwsDimension.BackportEvidence, "BKP")]
|
||||
[InlineData(EwsDimension.Exploitability, "XPL")]
|
||||
[InlineData(EwsDimension.SourceConfidence, "SRC")]
|
||||
[InlineData(EwsDimension.MitigationStatus, "MIT")]
|
||||
public void ToCode_ReturnsCorrectCode(EwsDimension dimension, string expectedCode)
|
||||
{
|
||||
Assert.Equal(expectedCode, dimension.ToCode());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RCH", EwsDimension.Reachability)]
|
||||
[InlineData("rch", EwsDimension.Reachability)]
|
||||
[InlineData("XPL", EwsDimension.Exploitability)]
|
||||
[InlineData("MIT", EwsDimension.MitigationStatus)]
|
||||
public void FromCode_ReturnsCorrectDimension(string code, EwsDimension expected)
|
||||
{
|
||||
var result = EwsDimensionCodes.FromCode(code);
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("INVALID")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void FromCode_InvalidCode_ReturnsNull(string? code)
|
||||
{
|
||||
var result = EwsDimensionCodes.FromCode(code!);
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EwsDimensionWeightsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_IsNormalized()
|
||||
{
|
||||
var weights = EwsDimensionWeights.Default;
|
||||
Assert.True(weights.IsNormalized());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_IsNormalized()
|
||||
{
|
||||
var weights = EwsDimensionWeights.Legacy;
|
||||
Assert.True(weights.IsNormalized());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWeight_ReturnsCorrectWeights()
|
||||
{
|
||||
var weights = new EwsDimensionWeights
|
||||
{
|
||||
Reachability = 0.3,
|
||||
RuntimeSignals = 0.1,
|
||||
BackportEvidence = 0.1,
|
||||
Exploitability = 0.25,
|
||||
SourceConfidence = 0.1,
|
||||
MitigationStatus = 0.15
|
||||
};
|
||||
|
||||
Assert.Equal(0.3, weights.GetWeight(EwsDimension.Reachability));
|
||||
Assert.Equal(0.25, weights.GetWeight(EwsDimension.Exploitability));
|
||||
Assert.True(weights.IsNormalized());
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EwsGuardrailsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_HasReasonableValues()
|
||||
{
|
||||
var guardrails = EwsGuardrails.Default;
|
||||
|
||||
Assert.InRange(guardrails.NotAffectedCap, 10, 50);
|
||||
Assert.InRange(guardrails.RuntimeFloor, 20, 50);
|
||||
Assert.InRange(guardrails.SpeculativeCap, 50, 70);
|
||||
Assert.InRange(guardrails.KevFloor, 60, 90);
|
||||
Assert.InRange(guardrails.BackportedCap, 10, 30);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GuardrailsEngineTests
|
||||
{
|
||||
private readonly GuardrailsEngine _engine = new();
|
||||
|
||||
[Fact]
|
||||
public void Apply_KevFloor_RaisesScoreForKnownExploited()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput { IsInKev = true };
|
||||
var guardrails = new EwsGuardrails { KevFloor = 70 };
|
||||
|
||||
// Act
|
||||
var result = _engine.Apply(50, signal, [], guardrails);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(70, result.AdjustedScore);
|
||||
Assert.Contains("kev_floor:70", result.AppliedGuardrails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_BackportedCap_LowersScoreForBackported()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput { BackportDetected = true };
|
||||
var guardrails = new EwsGuardrails { BackportedCap = 20 };
|
||||
|
||||
// Act
|
||||
var result = _engine.Apply(75, signal, [], guardrails);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(20, result.AdjustedScore);
|
||||
Assert.Contains("backported_cap:20", result.AppliedGuardrails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_NotAffectedCap_LowersScoreForMitigated()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput { VexStatus = "not_affected" };
|
||||
var guardrails = new EwsGuardrails { NotAffectedCap = 25 };
|
||||
|
||||
// Act
|
||||
var result = _engine.Apply(60, signal, [], guardrails);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(25, result.AdjustedScore);
|
||||
Assert.Contains("not_affected_cap:25", result.AppliedGuardrails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_NoGuardrailsTriggered_ReturnsOriginalScore()
|
||||
{
|
||||
// Arrange
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
VexStatus = "affected",
|
||||
IsInKev = false,
|
||||
BackportDetected = false
|
||||
};
|
||||
var guardrails = EwsGuardrails.Default;
|
||||
|
||||
// Act
|
||||
var result = _engine.Apply(55, signal, [], guardrails);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(55, result.AdjustedScore);
|
||||
Assert.Empty(result.AppliedGuardrails);
|
||||
Assert.False(result.WasModified);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// EwsNormalizerTests.cs
|
||||
// Sprint: SPRINT_20260208_045_Policy_evidence_weighted_score_model
|
||||
// Task: T1 - Evidence-Weighted Score (EWS) Model (6-Dimension Scoring)
|
||||
// Description: Unit tests for individual dimension normalizers.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Determinization.Scoring.EvidenceWeightedScoring;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
public sealed class ReachabilityNormalizerTests
|
||||
{
|
||||
private readonly ReachabilityNormalizer _normalizer = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 10)] // R0: Unreachable
|
||||
[InlineData(1, 15, 30)] // R1: In dependency
|
||||
[InlineData(2, 35, 50)] // R2: Imported not called
|
||||
[InlineData(3, 60, 80)] // R3: Called not entrypoint
|
||||
[InlineData(4, 90, 100)] // R4: Reachable
|
||||
public void Normalize_ReachabilityTier_ReturnsExpectedRange(int tier, int minScore, int maxScore)
|
||||
{
|
||||
var signal = new EwsSignalInput { ReachabilityTier = tier };
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.InRange(score, minScore, maxScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_RuntimeTraceConfirmed_BoostsScore()
|
||||
{
|
||||
var signalWithTrace = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 3,
|
||||
RuntimeTraceConfirmed = true
|
||||
};
|
||||
var signalWithoutTrace = new EwsSignalInput
|
||||
{
|
||||
ReachabilityTier = 3
|
||||
};
|
||||
|
||||
var scoreWith = _normalizer.Normalize(signalWithTrace);
|
||||
var scoreWithout = _normalizer.Normalize(signalWithoutTrace);
|
||||
|
||||
Assert.True(scoreWith > scoreWithout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConfidence_NoSignals_ReturnsLowConfidence()
|
||||
{
|
||||
var signal = EwsSignalInput.Empty;
|
||||
var confidence = _normalizer.GetConfidence(signal);
|
||||
Assert.True(confidence < 0.3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExplanation_ReturnsNonEmptyString()
|
||||
{
|
||||
var signal = new EwsSignalInput { ReachabilityTier = 3 };
|
||||
var explanation = _normalizer.GetExplanation(signal, 70);
|
||||
Assert.False(string.IsNullOrWhiteSpace(explanation));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExploitabilityNormalizerTests
|
||||
{
|
||||
private readonly ExploitabilityNormalizer _normalizer = new();
|
||||
|
||||
[Fact]
|
||||
public void Normalize_InKev_ReturnsMaximumScore()
|
||||
{
|
||||
var signal = new EwsSignalInput { IsInKev = true };
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.Equal(100, score);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, 0, 20)]
|
||||
[InlineData(0.5, 40, 70)]
|
||||
[InlineData(0.9, 70, 95)]
|
||||
[InlineData(1.0, 85, 100)]
|
||||
public void Normalize_EpssProbability_ScalesAppropriately(double epss, int minScore, int maxScore)
|
||||
{
|
||||
var signal = new EwsSignalInput { EpssProbability = epss };
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.InRange(score, minScore, maxScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ExploitKitAvailable_HighScore()
|
||||
{
|
||||
var signal = new EwsSignalInput { ExploitKitAvailable = true };
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.True(score >= 70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetConfidence_InKev_ReturnsMaximumConfidence()
|
||||
{
|
||||
var signal = new EwsSignalInput { IsInKev = true };
|
||||
var confidence = _normalizer.GetConfidence(signal);
|
||||
Assert.Equal(1.0, confidence);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BackportEvidenceNormalizerTests
|
||||
{
|
||||
private readonly BackportEvidenceNormalizer _normalizer = new();
|
||||
|
||||
[Fact]
|
||||
public void Normalize_VendorBackportConfirmed_ReturnsVeryLowScore()
|
||||
{
|
||||
var signal = new EwsSignalInput { VendorBackportConfirmed = true };
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.InRange(score, 0, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_BackportDetectedWithHighConfidence_ReturnsLowScore()
|
||||
{
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
BackportDetected = true,
|
||||
BackportConfidence = 0.9
|
||||
};
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.InRange(score, 0, 15);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NoBackportWithHighConfidence_ReturnsHighScore()
|
||||
{
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
BackportDetected = false,
|
||||
BackportConfidence = 0.9
|
||||
};
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.True(score >= 90);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NoAnalysis_AssumesVulnerable()
|
||||
{
|
||||
var signal = EwsSignalInput.Empty;
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.True(score >= 70);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MitigationStatusNormalizerTests
|
||||
{
|
||||
private readonly MitigationStatusNormalizer _normalizer = new();
|
||||
|
||||
[Theory]
|
||||
[InlineData("not_affected", 0, 10)]
|
||||
[InlineData("fixed", 5, 15)]
|
||||
[InlineData("under_investigation", 50, 70)]
|
||||
[InlineData("affected", 85, 95)]
|
||||
[InlineData("exploitable", 95, 100)]
|
||||
public void Normalize_VexStatus_ReturnsExpectedRange(string status, int minScore, int maxScore)
|
||||
{
|
||||
var signal = new EwsSignalInput { VexStatus = status };
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.InRange(score, minScore, maxScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_WorkaroundApplied_ReducesScore()
|
||||
{
|
||||
var signalWithWorkaround = new EwsSignalInput
|
||||
{
|
||||
VexStatus = "affected",
|
||||
WorkaroundApplied = true
|
||||
};
|
||||
var signalWithoutWorkaround = new EwsSignalInput
|
||||
{
|
||||
VexStatus = "affected"
|
||||
};
|
||||
|
||||
var scoreWith = _normalizer.Normalize(signalWithWorkaround);
|
||||
var scoreWithout = _normalizer.Normalize(signalWithoutWorkaround);
|
||||
|
||||
Assert.True(scoreWith < scoreWithout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NetworkControlsApplied_ReducesScore()
|
||||
{
|
||||
var signalWithControls = new EwsSignalInput
|
||||
{
|
||||
VexStatus = "affected",
|
||||
NetworkControlsApplied = true
|
||||
};
|
||||
var signalWithoutControls = new EwsSignalInput
|
||||
{
|
||||
VexStatus = "affected"
|
||||
};
|
||||
|
||||
var scoreWith = _normalizer.Normalize(signalWithControls);
|
||||
var scoreWithout = _normalizer.Normalize(signalWithoutControls);
|
||||
|
||||
Assert.True(scoreWith < scoreWithout);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RuntimeSignalsNormalizerTests
|
||||
{
|
||||
private readonly RuntimeSignalsNormalizer _normalizer = new();
|
||||
|
||||
[Fact]
|
||||
public void Normalize_HighInstrumentationNoInvocations_LowScore()
|
||||
{
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
InstrumentationCoverage = 0.9,
|
||||
RuntimeInvocationCount = 0,
|
||||
ApmActiveUsage = false
|
||||
};
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.InRange(score, 0, 30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_HighInvocationCount_HighScore()
|
||||
{
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
RuntimeInvocationCount = 5000
|
||||
};
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.True(score >= 70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_ApmActiveUsage_HighScore()
|
||||
{
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
ApmActiveUsage = true
|
||||
};
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.True(score >= 70);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SourceConfidenceNormalizerTests
|
||||
{
|
||||
private readonly SourceConfidenceNormalizer _normalizer = new();
|
||||
|
||||
[Fact]
|
||||
public void Normalize_HighConfidenceSource_LowRiskScore()
|
||||
{
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
SbomCompleteness = 0.95,
|
||||
SbomSigned = true,
|
||||
AttestationCount = 3,
|
||||
LineageVerified = true
|
||||
};
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.InRange(score, 0, 20); // Low risk from source uncertainty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_LowConfidenceSource_HighRiskScore()
|
||||
{
|
||||
var signal = new EwsSignalInput
|
||||
{
|
||||
SbomCompleteness = 0.3,
|
||||
SbomSigned = false,
|
||||
AttestationCount = 0,
|
||||
LineageVerified = false
|
||||
};
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.True(score >= 60); // High risk from source uncertainty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Normalize_NoSignals_AssumesHighUncertainty()
|
||||
{
|
||||
var signal = EwsSignalInput.Empty;
|
||||
var score = _normalizer.Normalize(signal);
|
||||
Assert.True(score >= 70);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
public class ImpactScoreCalculatorTests
|
||||
{
|
||||
private readonly ImpactScoreCalculator _calculator;
|
||||
|
||||
public ImpactScoreCalculatorTests()
|
||||
{
|
||||
_calculator = new ImpactScoreCalculator(NullLogger<ImpactScoreCalculator>.Instance);
|
||||
}
|
||||
|
||||
#region Calculate Tests
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ProductionHighSensitivityCriticalSla_ReturnsHighScore()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Production,
|
||||
DataSensitivity = DataSensitivity.Healthcare,
|
||||
FleetPrevalence = 0.9,
|
||||
SlaTier = SlaTier.MissionCritical,
|
||||
CvssScore = 9.8
|
||||
};
|
||||
|
||||
// Act
|
||||
var score = _calculator.Calculate(context);
|
||||
|
||||
// Assert
|
||||
score.Score.Should().BeGreaterThan(0.8);
|
||||
score.BasisPoints.Should().BeGreaterThan(8000);
|
||||
score.EnvironmentExposure.Should().Be(1.0);
|
||||
score.CvssSeverityScore.Should().BeApproximately(0.98, 0.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_DevelopmentLowSensitivity_ReturnsLowScore()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Development,
|
||||
DataSensitivity = DataSensitivity.Public,
|
||||
FleetPrevalence = 0.1,
|
||||
SlaTier = SlaTier.NonCritical,
|
||||
CvssScore = 2.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var score = _calculator.Calculate(context);
|
||||
|
||||
// Assert
|
||||
score.Score.Should().BeLessThan(0.2);
|
||||
score.BasisPoints.Should().BeLessThan(2000);
|
||||
score.EnvironmentExposure.Should().Be(0.0);
|
||||
score.DataSensitivityScore.Should().Be(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_DefaultForUnknowns_ReturnsModerateScore()
|
||||
{
|
||||
// Arrange
|
||||
var context = ImpactContext.DefaultForUnknowns();
|
||||
|
||||
// Act
|
||||
var score = _calculator.Calculate(context);
|
||||
|
||||
// Assert - default context assumes production, internal data, 0.5 fleet, standard SLA, CVSS 5.0
|
||||
score.Score.Should().BeInRange(0.3, 0.6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_CustomWeights_UsesProvidedWeights()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Production,
|
||||
DataSensitivity = DataSensitivity.Classified,
|
||||
FleetPrevalence = 1.0,
|
||||
SlaTier = SlaTier.MissionCritical,
|
||||
CvssScore = 10.0
|
||||
};
|
||||
|
||||
// All weights on CVSS, should return 1.0
|
||||
var weights = new ImpactFactorWeights
|
||||
{
|
||||
EnvironmentExposureWeight = 0.0,
|
||||
DataSensitivityWeight = 0.0,
|
||||
FleetPrevalenceWeight = 0.0,
|
||||
SlaTierWeight = 0.0,
|
||||
CvssSeverityWeight = 1.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var score = _calculator.Calculate(context, weights);
|
||||
|
||||
// Assert
|
||||
score.Score.Should().BeApproximately(1.0, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_IsDeterministic_SameInputSameOutput()
|
||||
{
|
||||
// Arrange
|
||||
var context = new ImpactContext
|
||||
{
|
||||
Environment = EnvironmentType.Staging,
|
||||
DataSensitivity = DataSensitivity.Pii,
|
||||
FleetPrevalence = 0.5,
|
||||
SlaTier = SlaTier.Important,
|
||||
CvssScore = 7.5
|
||||
};
|
||||
|
||||
// Act
|
||||
var score1 = _calculator.Calculate(context);
|
||||
var score2 = _calculator.Calculate(context);
|
||||
|
||||
// Assert
|
||||
score1.Score.Should().Be(score2.Score);
|
||||
score1.BasisPoints.Should().Be(score2.BasisPoints);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeEnvironment Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(EnvironmentType.Development, 0.0)]
|
||||
[InlineData(EnvironmentType.Testing, 0.33)]
|
||||
[InlineData(EnvironmentType.Staging, 0.66)]
|
||||
[InlineData(EnvironmentType.Production, 1.0)]
|
||||
public void NormalizeEnvironment_ReturnsExpectedScore(EnvironmentType env, double expected)
|
||||
{
|
||||
// Act
|
||||
var score = _calculator.NormalizeEnvironment(env);
|
||||
|
||||
// Assert
|
||||
score.Should().BeApproximately(expected, 0.01);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeDataSensitivity Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(DataSensitivity.Public, 0.0)]
|
||||
[InlineData(DataSensitivity.Internal, 0.2)]
|
||||
[InlineData(DataSensitivity.Pii, 0.5)]
|
||||
[InlineData(DataSensitivity.Financial, 0.7)]
|
||||
[InlineData(DataSensitivity.Healthcare, 0.8)]
|
||||
[InlineData(DataSensitivity.Classified, 1.0)]
|
||||
public void NormalizeDataSensitivity_ReturnsExpectedScore(DataSensitivity sensitivity, double expected)
|
||||
{
|
||||
// Act
|
||||
var score = _calculator.NormalizeDataSensitivity(sensitivity);
|
||||
|
||||
// Assert
|
||||
score.Should().BeApproximately(expected, 0.01);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeSlaTier Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(SlaTier.NonCritical, 0.0)]
|
||||
[InlineData(SlaTier.Standard, 0.25)]
|
||||
[InlineData(SlaTier.Important, 0.5)]
|
||||
[InlineData(SlaTier.Critical, 0.75)]
|
||||
[InlineData(SlaTier.MissionCritical, 1.0)]
|
||||
public void NormalizeSlaTier_ReturnsExpectedScore(SlaTier tier, double expected)
|
||||
{
|
||||
// Act
|
||||
var score = _calculator.NormalizeSlaTier(tier);
|
||||
|
||||
// Assert
|
||||
score.Should().BeApproximately(expected, 0.01);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NormalizeCvss Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, 0.0)]
|
||||
[InlineData(5.0, 0.5)]
|
||||
[InlineData(10.0, 1.0)]
|
||||
[InlineData(-1.0, 0.0)] // Clamped
|
||||
[InlineData(15.0, 1.0)] // Clamped
|
||||
public void NormalizeCvss_ReturnsExpectedScore(double cvss, double expected)
|
||||
{
|
||||
// Act
|
||||
var score = _calculator.NormalizeCvss(cvss);
|
||||
|
||||
// Assert
|
||||
score.Should().BeApproximately(expected, 0.01);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ImpactFactorWeights Tests
|
||||
|
||||
[Fact]
|
||||
public void ImpactFactorWeights_Default_IsNormalized()
|
||||
{
|
||||
// Act & Assert
|
||||
ImpactFactorWeights.Default.IsNormalized().Should().BeTrue();
|
||||
ImpactFactorWeights.Default.TotalWeight.Should().BeApproximately(1.0, 0.001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpactFactorWeights_Custom_TotalWeightCalculated()
|
||||
{
|
||||
// Arrange
|
||||
var weights = new ImpactFactorWeights
|
||||
{
|
||||
EnvironmentExposureWeight = 0.1,
|
||||
DataSensitivityWeight = 0.2,
|
||||
FleetPrevalenceWeight = 0.3,
|
||||
SlaTierWeight = 0.15,
|
||||
CvssSeverityWeight = 0.25
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
weights.TotalWeight.Should().BeApproximately(1.0, 0.001);
|
||||
weights.IsNormalized().Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ImpactScore Tests
|
||||
|
||||
[Fact]
|
||||
public void ImpactScore_Create_CalculatesBasisPointsCorrectly()
|
||||
{
|
||||
// Arrange & Act
|
||||
var score = ImpactScore.Create(
|
||||
envExposure: 1.0,
|
||||
dataSensitivity: 0.5,
|
||||
fleetPrevalence: 0.5,
|
||||
slaTier: 0.5,
|
||||
cvssSeverity: 0.5,
|
||||
ImpactFactorWeights.Default,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
// Assert
|
||||
// Score = 1.0*0.2 + 0.5*0.2 + 0.5*0.15 + 0.5*0.15 + 0.5*0.3 = 0.2 + 0.1 + 0.075 + 0.075 + 0.15 = 0.6
|
||||
score.Score.Should().BeApproximately(0.6, 0.01);
|
||||
score.BasisPoints.Should().Be(6000);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring.Triage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring.Triage;
|
||||
|
||||
public sealed class TriageQueueEvaluatorTests
|
||||
{
|
||||
private static readonly DateTimeOffset ReferenceTime = new(2026, 2, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly TriageQueueEvaluator _evaluator;
|
||||
private readonly TriageQueueOptions _options;
|
||||
|
||||
public TriageQueueEvaluatorTests()
|
||||
{
|
||||
_options = new TriageQueueOptions();
|
||||
_evaluator = new TriageQueueEvaluator(
|
||||
NullLogger<TriageQueueEvaluator>.Instance,
|
||||
Options.Create(_options));
|
||||
}
|
||||
|
||||
#region EvaluateSingle Tests
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_FreshObservation_ReturnsNull()
|
||||
{
|
||||
var obs = CreateObservation(ageDays: 0);
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().BeNull("fresh observation should not be queued");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_SlightlyAged_ReturnsNull()
|
||||
{
|
||||
// 5 days old with 14-day half-life => multiplier ≈ 0.78, above approaching threshold 0.70
|
||||
var obs = CreateObservation(ageDays: 5);
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().BeNull("multiplier 0.78 is above approaching threshold 0.70");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_ApproachingStaleness_ReturnsLowPriority()
|
||||
{
|
||||
// 8 days old with 14-day half-life => multiplier ≈ 0.67, between 0.50 and 0.70
|
||||
var obs = CreateObservation(ageDays: 8);
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Priority.Should().Be(TriagePriority.Low);
|
||||
result.CurrentMultiplier.Should().BeApproximately(0.67, 0.05);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_Stale_ReturnsMediumPriority()
|
||||
{
|
||||
// 14 days old (one half-life) => multiplier = 0.50, at staleness threshold
|
||||
// Actually 15 days => multiplier ≈ 0.48, below 0.50 => Medium
|
||||
var obs = CreateObservation(ageDays: 15);
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Priority.Should().Be(TriagePriority.Medium);
|
||||
result.DaysUntilStale.Should().BeNegative("already stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_HeavilyDecayed_ReturnsHighPriority()
|
||||
{
|
||||
// 28 days (two half-lives) => multiplier ≈ 0.25
|
||||
var obs = CreateObservation(ageDays: 28);
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Priority.Should().Be(TriagePriority.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_AtFloor_ReturnsCriticalPriority()
|
||||
{
|
||||
// 100 days => multiplier at floor (0.35 but compare to 0.10 threshold)
|
||||
// With floor=0.35, actual multiplier can't go below 0.35
|
||||
// Need floor < CriticalThreshold to get Critical
|
||||
// Use custom decay with floor=0.05
|
||||
var decay = ObservationDecay.WithSettings(
|
||||
ReferenceTime.AddDays(-200),
|
||||
ReferenceTime.AddDays(-200),
|
||||
halfLifeDays: 14.0,
|
||||
floor: 0.05,
|
||||
stalenessThreshold: 0.50);
|
||||
var obs = new TriageObservation
|
||||
{
|
||||
Cve = "CVE-2026-9999",
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
TenantId = "tenant-1",
|
||||
Decay = decay
|
||||
};
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Priority.Should().Be(TriagePriority.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_PreservesCveAndPurl()
|
||||
{
|
||||
var obs = CreateObservation(ageDays: 20, cve: "CVE-2026-1234", purl: "pkg:maven/org.example/lib@2.0");
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Cve.Should().Be("CVE-2026-1234");
|
||||
result.Purl.Should().Be("pkg:maven/org.example/lib@2.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_SetsEvaluatedAt()
|
||||
{
|
||||
var obs = CreateObservation(ageDays: 20);
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.EvaluatedAt.Should().Be(ReferenceTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_WithSignalGaps_SetsRecommendedAction()
|
||||
{
|
||||
var gaps = new List<SignalGap>
|
||||
{
|
||||
new() { Signal = "EPSS", Reason = SignalGapReason.NotQueried, Weight = 0.20 },
|
||||
new() { Signal = "VEX", Reason = SignalGapReason.NotAvailable, Weight = 0.30 }
|
||||
};
|
||||
var obs = CreateObservation(ageDays: 20, gaps: gaps);
|
||||
|
||||
var result = _evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.RecommendedAction.Should().Contain("EPSS");
|
||||
result.RecommendedAction.Should().Contain("VEX");
|
||||
result.SignalGaps.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvaluateSingle_ApproachingDisabled_ReturnsNull()
|
||||
{
|
||||
var options = new TriageQueueOptions { IncludeApproaching = false };
|
||||
var evaluator = new TriageQueueEvaluator(
|
||||
NullLogger<TriageQueueEvaluator>.Instance,
|
||||
Options.Create(options));
|
||||
|
||||
var obs = CreateObservation(ageDays: 8); // approaching but not stale
|
||||
|
||||
var result = evaluator.EvaluateSingle(obs, ReferenceTime);
|
||||
|
||||
result.Should().BeNull("approaching items should be excluded when IncludeApproaching=false");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EvaluateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EmptyList_ReturnsEmptySnapshot()
|
||||
{
|
||||
var snapshot = await _evaluator.EvaluateAsync([], ReferenceTime);
|
||||
|
||||
snapshot.Items.Should().BeEmpty();
|
||||
snapshot.TotalEvaluated.Should().Be(0);
|
||||
snapshot.StaleCount.Should().Be(0);
|
||||
snapshot.ApproachingCount.Should().Be(0);
|
||||
snapshot.EvaluatedAt.Should().Be(ReferenceTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MixedObservations_SortsByPriorityThenUrgency()
|
||||
{
|
||||
var observations = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 8, cve: "CVE-A"), // Low (approaching)
|
||||
CreateObservation(ageDays: 20, cve: "CVE-B"), // Medium (stale)
|
||||
CreateObservation(ageDays: 30, cve: "CVE-C"), // High (heavily decayed)
|
||||
CreateObservation(ageDays: 2, cve: "CVE-D"), // None (fresh)
|
||||
};
|
||||
|
||||
var snapshot = await _evaluator.EvaluateAsync(observations, ReferenceTime);
|
||||
|
||||
snapshot.TotalEvaluated.Should().Be(4);
|
||||
snapshot.Items.Should().HaveCount(3, "fresh observation should be excluded");
|
||||
snapshot.Items[0].Priority.Should().Be(TriagePriority.High, "highest priority first");
|
||||
snapshot.Items[1].Priority.Should().Be(TriagePriority.Medium);
|
||||
snapshot.Items[2].Priority.Should().Be(TriagePriority.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SamePriority_SortsByDaysUntilStale()
|
||||
{
|
||||
var observations = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 16, cve: "CVE-X"), // Medium, more stale
|
||||
CreateObservation(ageDays: 15, cve: "CVE-Y"), // Medium, less stale
|
||||
};
|
||||
|
||||
var snapshot = await _evaluator.EvaluateAsync(observations, ReferenceTime);
|
||||
|
||||
snapshot.Items.Should().HaveCount(2);
|
||||
// Both Medium, sorted by daysUntilStale ascending (most negative first)
|
||||
snapshot.Items[0].DaysUntilStale.Should().BeLessThan(snapshot.Items[1].DaysUntilStale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_PrioritySummary_IsCorrect()
|
||||
{
|
||||
var observations = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 8, cve: "CVE-1"), // Low
|
||||
CreateObservation(ageDays: 9, cve: "CVE-2"), // Low
|
||||
CreateObservation(ageDays: 20, cve: "CVE-3"), // Medium
|
||||
};
|
||||
|
||||
var snapshot = await _evaluator.EvaluateAsync(observations, ReferenceTime);
|
||||
|
||||
snapshot.PrioritySummary.Should().ContainKey(TriagePriority.Low);
|
||||
snapshot.PrioritySummary[TriagePriority.Low].Should().Be(2);
|
||||
snapshot.PrioritySummary.Should().ContainKey(TriagePriority.Medium);
|
||||
snapshot.PrioritySummary[TriagePriority.Medium].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RespectsMaxSnapshotItems()
|
||||
{
|
||||
var options = new TriageQueueOptions { MaxSnapshotItems = 2 };
|
||||
var evaluator = new TriageQueueEvaluator(
|
||||
NullLogger<TriageQueueEvaluator>.Instance,
|
||||
Options.Create(options));
|
||||
|
||||
var observations = Enumerable.Range(0, 10)
|
||||
.Select(i => CreateObservation(ageDays: 15 + i, cve: $"CVE-{i:D4}"))
|
||||
.ToList();
|
||||
|
||||
var snapshot = await evaluator.EvaluateAsync(observations, ReferenceTime);
|
||||
|
||||
snapshot.Items.Should().HaveCount(2);
|
||||
snapshot.TotalEvaluated.Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_Deterministic_SameInputsSameOutput()
|
||||
{
|
||||
var observations = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 10, cve: "CVE-A"),
|
||||
CreateObservation(ageDays: 20, cve: "CVE-B"),
|
||||
CreateObservation(ageDays: 30, cve: "CVE-C"),
|
||||
};
|
||||
|
||||
var snapshot1 = await _evaluator.EvaluateAsync(observations, ReferenceTime);
|
||||
var snapshot2 = await _evaluator.EvaluateAsync(observations, ReferenceTime);
|
||||
|
||||
snapshot1.Items.Count.Should().Be(snapshot2.Items.Count);
|
||||
for (var i = 0; i < snapshot1.Items.Count; i++)
|
||||
{
|
||||
snapshot1.Items[i].Cve.Should().Be(snapshot2.Items[i].Cve);
|
||||
snapshot1.Items[i].Priority.Should().Be(snapshot2.Items[i].Priority);
|
||||
snapshot1.Items[i].CurrentMultiplier.Should().Be(snapshot2.Items[i].CurrentMultiplier);
|
||||
snapshot1.Items[i].DaysUntilStale.Should().Be(snapshot2.Items[i].DaysUntilStale);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ClassifyPriority Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.95, TriagePriority.None)]
|
||||
[InlineData(0.80, TriagePriority.None)]
|
||||
[InlineData(0.65, TriagePriority.Low)]
|
||||
[InlineData(0.55, TriagePriority.Low)]
|
||||
[InlineData(0.45, TriagePriority.Medium)]
|
||||
[InlineData(0.25, TriagePriority.High)]
|
||||
[InlineData(0.08, TriagePriority.Critical)]
|
||||
[InlineData(0.00, TriagePriority.Critical)]
|
||||
public void ClassifyPriority_ReturnsExpectedTier(double multiplier, TriagePriority expected)
|
||||
{
|
||||
var result = _evaluator.ClassifyPriority(multiplier, stalenessThreshold: 0.50);
|
||||
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CalculateDaysUntilStale Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateDaysUntilStale_FreshObservation_ReturnsPositive()
|
||||
{
|
||||
var refreshedAt = ReferenceTime;
|
||||
var result = TriageQueueEvaluator.CalculateDaysUntilStale(
|
||||
refreshedAt, halfLifeDays: 14.0, stalenessThreshold: 0.50, floor: 0.35, ReferenceTime);
|
||||
|
||||
result.Should().BeApproximately(14.0, 0.1, "one half-life until 0.50 threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDaysUntilStale_AlreadyStale_ReturnsNegative()
|
||||
{
|
||||
var refreshedAt = ReferenceTime.AddDays(-20);
|
||||
var result = TriageQueueEvaluator.CalculateDaysUntilStale(
|
||||
refreshedAt, halfLifeDays: 14.0, stalenessThreshold: 0.50, floor: 0.35, ReferenceTime);
|
||||
|
||||
result.Should().BeNegative("observation is past staleness threshold");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDaysUntilStale_FloorAboveThreshold_ReturnsMaxValue()
|
||||
{
|
||||
var result = TriageQueueEvaluator.CalculateDaysUntilStale(
|
||||
ReferenceTime, halfLifeDays: 14.0, stalenessThreshold: 0.30, floor: 0.50, ReferenceTime);
|
||||
|
||||
result.Should().Be(double.MaxValue, "floor prevents reaching threshold");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static TriageObservation CreateObservation(
|
||||
double ageDays,
|
||||
string cve = "CVE-2026-0001",
|
||||
string purl = "pkg:npm/test@1.0.0",
|
||||
string tenantId = "tenant-1",
|
||||
IReadOnlyList<SignalGap>? gaps = null)
|
||||
{
|
||||
var refreshedAt = ReferenceTime.AddDays(-ageDays);
|
||||
return new TriageObservation
|
||||
{
|
||||
Cve = cve,
|
||||
Purl = purl,
|
||||
TenantId = tenantId,
|
||||
Decay = ObservationDecay.Create(refreshedAt, refreshedAt),
|
||||
SignalGaps = gaps ?? []
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring.Triage;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring.Triage;
|
||||
|
||||
public sealed class UnknownTriageQueueServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset ReferenceTime = new(2026, 2, 8, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly TriageQueueOptions _options = new();
|
||||
private readonly TriageQueueEvaluator _evaluator;
|
||||
private readonly Mock<ITriageObservationSource> _sourceMock;
|
||||
private readonly InMemoryTriageReanalysisSink _sink;
|
||||
private readonly UnknownTriageQueueService _service;
|
||||
|
||||
public UnknownTriageQueueServiceTests()
|
||||
{
|
||||
_evaluator = new TriageQueueEvaluator(
|
||||
NullLogger<TriageQueueEvaluator>.Instance,
|
||||
Options.Create(_options));
|
||||
|
||||
_sourceMock = new Mock<ITriageObservationSource>();
|
||||
_sink = new InMemoryTriageReanalysisSink(NullLogger<InMemoryTriageReanalysisSink>.Instance);
|
||||
|
||||
var fakeTimeProvider = new FakeTimeProvider(ReferenceTime);
|
||||
|
||||
_service = new UnknownTriageQueueService(
|
||||
_evaluator,
|
||||
_sourceMock.Object,
|
||||
_sink,
|
||||
NullLogger<UnknownTriageQueueService>.Instance,
|
||||
Options.Create(_options),
|
||||
fakeTimeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCycleAsync_NoCandidates_ReturnsEmptySnapshot()
|
||||
{
|
||||
_sourceMock
|
||||
.Setup(s => s.GetCandidatesAsync(null, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var snapshot = await _service.ExecuteCycleAsync();
|
||||
|
||||
snapshot.Items.Should().BeEmpty();
|
||||
snapshot.TotalEvaluated.Should().Be(0);
|
||||
_sink.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCycleAsync_StaleObservations_EnqueuedToSink()
|
||||
{
|
||||
var candidates = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 20, cve: "CVE-STALE-1"), // Medium
|
||||
CreateObservation(ageDays: 30, cve: "CVE-STALE-2"), // High
|
||||
};
|
||||
|
||||
_sourceMock
|
||||
.Setup(s => s.GetCandidatesAsync(null, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(candidates);
|
||||
|
||||
var snapshot = await _service.ExecuteCycleAsync();
|
||||
|
||||
snapshot.Items.Should().HaveCount(2);
|
||||
_sink.Count.Should().Be(2, "both stale items should be enqueued");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCycleAsync_OnlyApproaching_NotEnqueued()
|
||||
{
|
||||
var candidates = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 8, cve: "CVE-APPROACH"), // Low priority
|
||||
};
|
||||
|
||||
_sourceMock
|
||||
.Setup(s => s.GetCandidatesAsync(null, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(candidates);
|
||||
|
||||
var snapshot = await _service.ExecuteCycleAsync();
|
||||
|
||||
snapshot.Items.Should().HaveCount(1, "approaching item is in snapshot");
|
||||
_sink.Count.Should().Be(0, "approaching items are not enqueued for re-analysis");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCycleAsync_MixedStaleAndFresh_OnlyStaleEnqueued()
|
||||
{
|
||||
var candidates = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 2, cve: "CVE-FRESH"), // None
|
||||
CreateObservation(ageDays: 8, cve: "CVE-APPROACH"), // Low
|
||||
CreateObservation(ageDays: 20, cve: "CVE-STALE"), // Medium
|
||||
};
|
||||
|
||||
_sourceMock
|
||||
.Setup(s => s.GetCandidatesAsync(null, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(candidates);
|
||||
|
||||
var snapshot = await _service.ExecuteCycleAsync();
|
||||
|
||||
snapshot.TotalEvaluated.Should().Be(3);
|
||||
_sink.Count.Should().Be(1, "only medium+ items are enqueued");
|
||||
|
||||
var enqueued = _sink.DrainAll();
|
||||
enqueued[0].Cve.Should().Be("CVE-STALE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCycleAsync_WithTenantFilter_PassesToSource()
|
||||
{
|
||||
_sourceMock
|
||||
.Setup(s => s.GetCandidatesAsync("tenant-42", It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
await _service.ExecuteCycleAsync(tenantId: "tenant-42");
|
||||
|
||||
_sourceMock.Verify(
|
||||
s => s.GetCandidatesAsync("tenant-42", It.IsAny<int>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateOnDemandAsync_DoesNotEnqueue()
|
||||
{
|
||||
var observations = new List<TriageObservation>
|
||||
{
|
||||
CreateObservation(ageDays: 20, cve: "CVE-DEMAND"),
|
||||
};
|
||||
|
||||
var snapshot = await _service.EvaluateOnDemandAsync(observations, ReferenceTime);
|
||||
|
||||
snapshot.Items.Should().HaveCount(1);
|
||||
_sink.Count.Should().Be(0, "on-demand evaluation should not auto-enqueue");
|
||||
}
|
||||
|
||||
#region InMemoryTriageReanalysisSink Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_EnqueueAndDrain()
|
||||
{
|
||||
var items = new List<TriageItem>
|
||||
{
|
||||
CreateTriageItem("CVE-1", TriagePriority.Medium),
|
||||
CreateTriageItem("CVE-2", TriagePriority.High),
|
||||
};
|
||||
|
||||
var enqueued = await _sink.EnqueueAsync(items);
|
||||
|
||||
enqueued.Should().Be(2);
|
||||
_sink.Count.Should().Be(2);
|
||||
|
||||
var drained = _sink.DrainAll();
|
||||
drained.Should().HaveCount(2);
|
||||
_sink.Count.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InMemorySink_TryDequeue_EmptyQueue_ReturnsFalse()
|
||||
{
|
||||
var result = _sink.TryDequeue(out var item);
|
||||
|
||||
result.Should().BeFalse();
|
||||
item.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InMemorySink_PeekAll_DoesNotRemove()
|
||||
{
|
||||
await _sink.EnqueueAsync([CreateTriageItem("CVE-PEEK", TriagePriority.Critical)]);
|
||||
|
||||
var peeked = _sink.PeekAll();
|
||||
peeked.Should().HaveCount(1);
|
||||
_sink.Count.Should().Be(1, "peek should not remove items");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static TriageObservation CreateObservation(double ageDays, string cve = "CVE-2026-0001")
|
||||
{
|
||||
var refreshedAt = ReferenceTime.AddDays(-ageDays);
|
||||
return new TriageObservation
|
||||
{
|
||||
Cve = cve,
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
TenantId = "tenant-1",
|
||||
Decay = ObservationDecay.Create(refreshedAt, refreshedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private static TriageItem CreateTriageItem(string cve, TriagePriority priority) => new()
|
||||
{
|
||||
Cve = cve,
|
||||
Purl = "pkg:npm/test@1.0.0",
|
||||
TenantId = "tenant-1",
|
||||
Decay = ObservationDecay.Fresh(ReferenceTime),
|
||||
CurrentMultiplier = 0.5,
|
||||
Priority = priority,
|
||||
AgeDays = 10,
|
||||
DaysUntilStale = -5,
|
||||
EvaluatedAt = ReferenceTime,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fake TimeProvider for deterministic testing.
|
||||
/// </summary>
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.TrustLattice;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for TrustScoreAlgebraFacade.
|
||||
/// Verifies the unified scoring pipeline produces deterministic, attestation-ready results.
|
||||
/// </summary>
|
||||
public sealed class TrustScoreAlgebraFacadeTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
private TrustScoreAlgebraFacade CreateFacade()
|
||||
{
|
||||
var aggregator = new TrustScoreAggregator(NullLogger<TrustScoreAggregator>.Instance);
|
||||
var uncertaintyCalculator = new UncertaintyScoreCalculator();
|
||||
return new TrustScoreAlgebraFacade(
|
||||
aggregator,
|
||||
uncertaintyCalculator,
|
||||
NullLogger<TrustScoreAlgebraFacade>.Instance,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
#region Basic Computation Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_EmptySignals_ReturnsValidPredicate()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var request = new TrustScoreRequest
|
||||
{
|
||||
ArtifactId = "pkg:maven/test@1.0",
|
||||
VulnerabilityId = "CVE-2024-1234"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.Should().NotBeNull();
|
||||
result.Predicate.ArtifactId.Should().Be("pkg:maven/test@1.0");
|
||||
result.Predicate.VulnerabilityId.Should().Be("CVE-2024-1234");
|
||||
result.Predicate.TrustScoreBps.Should().BeInRange(0, 10000);
|
||||
result.Predicate.LatticeVerdict.Should().Be(K4Value.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_WithSignals_ReturnsCalculatedScore()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Reachable, 0, 0, null)),
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("affected", null, null, null, null, null))
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
{
|
||||
ArtifactId = "pkg:maven/test@1.0",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
Signals = signals
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.Dimensions.ReachabilityBps.Should().Be(10000); // Reachable = max score
|
||||
result.Predicate.Dimensions.VexBps.Should().Be(10000); // Affected = max risk
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_UnreachableVulnerability_LowerScore()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Unreachable, 0, 0, null)),
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("affected", null, null, null, null, null))
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
{
|
||||
ArtifactId = "pkg:maven/test@1.0",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
Signals = signals
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.Dimensions.ReachabilityBps.Should().Be(0); // Unreachable = no risk from reachability
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region K4 Lattice Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_ConflictingSignals_ReturnsConflict()
|
||||
{
|
||||
// Arrange: VEX says not_affected, EPSS says high probability
|
||||
var facade = CreateFacade();
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("not_affected", null, null, null, null, null)),
|
||||
Epss = SignalState<EpssEvidence>.Present(
|
||||
new EpssEvidence(0.85, 0.95)) // High EPSS = True in K4
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
{
|
||||
ArtifactId = "pkg:maven/test@1.0",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
Signals = signals
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.LatticeVerdict.Should().Be(K4Value.Conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_AllTrueSignals_ReturnsTrueVerdict()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Vex = SignalState<VexClaimSummary>.Present(
|
||||
new VexClaimSummary("affected", null, null, null, null, null)),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Reachable, 0, 0, null)),
|
||||
Epss = SignalState<EpssEvidence>.Present(
|
||||
new EpssEvidence(0.75, 0.90))
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
{
|
||||
ArtifactId = "pkg:maven/test@1.0",
|
||||
Signals = signals
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.LatticeVerdict.Should().Be(K4Value.True);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score.v1 Predicate Format Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_ReturnsCorrectPredicateType()
|
||||
{
|
||||
// Assert
|
||||
ScoreV1Predicate.PredicateType.Should().Be("https://stella-ops.org/predicates/score/v1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_IncludesAllRequiredFields()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var request = new TrustScoreRequest
|
||||
{
|
||||
ArtifactId = "pkg:maven/test@1.0",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
TenantId = "tenant-123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.ArtifactId.Should().NotBeNullOrEmpty();
|
||||
result.Predicate.TrustScoreBps.Should().BeInRange(0, 10000);
|
||||
result.Predicate.Tier.Should().NotBeNullOrEmpty();
|
||||
result.Predicate.UncertaintyBps.Should().BeInRange(0, 10000);
|
||||
result.Predicate.Dimensions.Should().NotBeNull();
|
||||
result.Predicate.WeightsUsed.Should().NotBeNull();
|
||||
result.Predicate.PolicyDigest.Should().NotBeNullOrEmpty();
|
||||
result.Predicate.ComputedAt.Should().Be(_timeProvider.GetUtcNow());
|
||||
result.Predicate.TenantId.Should().Be("tenant-123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_PolicyDigest_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var request = new TrustScoreRequest { ArtifactId = "pkg:maven/test@1.0" };
|
||||
|
||||
// Act
|
||||
var result1 = facade.ComputeTrustScore(request);
|
||||
var result2 = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result1.Predicate.PolicyDigest.Should().Be(result2.Predicate.PolicyDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basis Point Arithmetic Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_WeightsSumTo10000()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var request = new TrustScoreRequest { ArtifactId = "pkg:maven/test@1.0" };
|
||||
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
var weights = result.Predicate.WeightsUsed;
|
||||
var sum = weights.BaseSeverity + weights.Reachability + weights.Evidence + weights.Provenance;
|
||||
sum.Should().Be(10000, "weights must sum to 10000 basis points");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_FinalScoreWithinBounds()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
|
||||
// Test multiple scenarios
|
||||
var scenarios = new[]
|
||||
{
|
||||
new TrustScoreRequest { ArtifactId = "pkg:a@1.0" },
|
||||
new TrustScoreRequest { ArtifactId = "pkg:b@1.0", VulnerabilityId = "CVE-2024-1234" },
|
||||
};
|
||||
|
||||
foreach (var request in scenarios)
|
||||
{
|
||||
// Act
|
||||
var result = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result.Predicate.TrustScoreBps.Should().BeInRange(0, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Risk Tier Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(9500, "Critical")]
|
||||
[InlineData(8000, "High")]
|
||||
[InlineData(5000, "Medium")]
|
||||
[InlineData(2000, "Low")]
|
||||
[InlineData(500, "Info")]
|
||||
public void RiskTier_MapsCorrectly(int scoreBps, string expectedTier)
|
||||
{
|
||||
// The tier is derived from the score; verify tier naming
|
||||
var tier = scoreBps switch
|
||||
{
|
||||
>= 9000 => RiskTier.Critical,
|
||||
>= 7000 => RiskTier.High,
|
||||
>= 4000 => RiskTier.Medium,
|
||||
>= 1000 => RiskTier.Low,
|
||||
_ => RiskTier.Info
|
||||
};
|
||||
|
||||
tier.ToString().Should().Be(expectedTier);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_SameInputs_ProducesSameOutputs()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var signals = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow())
|
||||
with
|
||||
{
|
||||
Epss = SignalState<EpssEvidence>.Present(new EpssEvidence(0.35, 0.65)),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Present(
|
||||
new ReachabilityEvidence(ReachabilityStatus.Reachable, 2, 5, null))
|
||||
};
|
||||
|
||||
var request = new TrustScoreRequest
|
||||
{
|
||||
ArtifactId = "pkg:maven/test@1.0",
|
||||
VulnerabilityId = "CVE-2024-1234",
|
||||
Signals = signals
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = facade.ComputeTrustScore(request);
|
||||
var result2 = facade.ComputeTrustScore(request);
|
||||
|
||||
// Assert
|
||||
result1.Predicate.TrustScoreBps.Should().Be(result2.Predicate.TrustScoreBps);
|
||||
result1.Predicate.LatticeVerdict.Should().Be(result2.Predicate.LatticeVerdict);
|
||||
result1.Predicate.Dimensions.Should().BeEquivalentTo(result2.Predicate.Dimensions);
|
||||
result1.Predicate.PolicyDigest.Should().Be(result2.Predicate.PolicyDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Async API Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeTrustScoreAsync_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var request = new TrustScoreRequest { ArtifactId = "pkg:maven/test@1.0" };
|
||||
|
||||
// Act
|
||||
var result = await facade.ComputeTrustScoreAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Predicate.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_NullArtifactId_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var request = new TrustScoreRequest { ArtifactId = null! };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => facade.ComputeTrustScore(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeTrustScore_EmptyArtifactId_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var facade = CreateFacade();
|
||||
var request = new TrustScoreRequest { ArtifactId = "" };
|
||||
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => facade.ComputeTrustScore(request));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WeightManifestCommandsTests.cs
|
||||
// Sprint: SPRINT_20260208_051_Policy_versioned_weight_manifests
|
||||
// Task: T1 - Unit tests for CLI weight commands
|
||||
// Description: Tests for list, validate, diff, activate, and hash commands.
|
||||
// Uses temp directories for offline, deterministic execution.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Determinization.Scoring.WeightManifest;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring.WeightManifest;
|
||||
|
||||
public sealed class WeightManifestCommandsTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly WeightManifestLoader _loader;
|
||||
private readonly WeightManifestCommands _commands;
|
||||
|
||||
public WeightManifestCommandsTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"stella-wm-cmd-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
var options = Options.Create(new WeightManifestLoaderOptions
|
||||
{
|
||||
ManifestDirectory = _tempDir,
|
||||
RequireComputedHash = false,
|
||||
StrictHashVerification = false
|
||||
});
|
||||
|
||||
_loader = new WeightManifestLoader(options, NullLogger<WeightManifestLoader>.Instance);
|
||||
_commands = new WeightManifestCommands(_loader);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private string WriteManifest(string filename, string version, string effectiveFrom,
|
||||
double rch = 0.50, double mit = 0.50)
|
||||
{
|
||||
var path = Path.Combine(_tempDir, filename);
|
||||
var json = $$"""
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "{{version}}",
|
||||
"effectiveFrom": "{{effectiveFrom}}",
|
||||
"profile": "production",
|
||||
"contentHash": "sha256:auto",
|
||||
"weights": {
|
||||
"legacy": { "rch": {{rch}}, "mit": {{mit}} },
|
||||
"advisory": {}
|
||||
}
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── ListAsync ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_EmptyDirectory_ReturnsEmptyEntries()
|
||||
{
|
||||
var result = await _commands.ListAsync();
|
||||
Assert.True(result.Entries.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReturnsAllManifests()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
WriteManifest("b.weights.json", "v2", "2026-02-01T00:00:00Z");
|
||||
|
||||
var result = await _commands.ListAsync();
|
||||
|
||||
Assert.Equal(2, result.Entries.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_ReportsHashStatus()
|
||||
{
|
||||
WriteManifest("auto.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
var result = await _commands.ListAsync();
|
||||
|
||||
Assert.Equal("auto", result.Entries[0].HashStatus);
|
||||
}
|
||||
|
||||
// ── ValidateAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_AllManifests_AllValid()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
WriteManifest("b.weights.json", "v2", "2026-02-01T00:00:00Z");
|
||||
|
||||
var result = await _commands.ValidateAsync();
|
||||
|
||||
Assert.True(result.AllValid);
|
||||
Assert.Equal(2, result.Entries.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_SpecificFile_ValidatesOnly()
|
||||
{
|
||||
var path = WriteManifest("a.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
var result = await _commands.ValidateAsync(path);
|
||||
|
||||
Assert.Single(result.Entries);
|
||||
Assert.True(result.Entries[0].IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_InvalidManifest_ReportsIssues()
|
||||
{
|
||||
var path = WriteManifest("bad.weights.json", "v1", "2026-01-01T00:00:00Z",
|
||||
rch: 0.90, mit: 0.90); // Sum > 1.0
|
||||
|
||||
var result = await _commands.ValidateAsync(path);
|
||||
|
||||
Assert.False(result.AllValid);
|
||||
Assert.False(result.Entries[0].IsValid);
|
||||
Assert.Contains(result.Entries[0].Issues, i => i.Contains("Legacy weights sum"));
|
||||
}
|
||||
|
||||
// ── DiffAsync ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task DiffAsync_TwoFiles_ReturnsDiff()
|
||||
{
|
||||
var path1 = WriteManifest("a.weights.json", "v1", "2026-01-01T00:00:00Z", rch: 0.30, mit: 0.70);
|
||||
var path2 = WriteManifest("b.weights.json", "v2", "2026-02-01T00:00:00Z", rch: 0.50, mit: 0.50);
|
||||
|
||||
var diff = await _commands.DiffAsync(path1, path2);
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
Assert.Equal("v1", diff.FromVersion);
|
||||
Assert.Equal("v2", diff.ToVersion);
|
||||
Assert.Contains(diff.Differences, d => d.Path == "weights.legacy.rch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiffByVersionAsync_FindsByVersionString()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v2026-01-01", "2026-01-01T00:00:00Z", rch: 0.30, mit: 0.70);
|
||||
WriteManifest("b.weights.json", "v2026-02-01", "2026-02-01T00:00:00Z", rch: 0.50, mit: 0.50);
|
||||
|
||||
var diff = await _commands.DiffByVersionAsync("v2026-01-01", "v2026-02-01");
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiffByVersionAsync_MissingVersion_Throws()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
await Assert.ThrowsAsync<WeightManifestLoadException>(() =>
|
||||
_commands.DiffByVersionAsync("v1", "v-nonexistent"));
|
||||
}
|
||||
|
||||
// ── ActivateAsync ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_NoManifests_ReturnsNotFound()
|
||||
{
|
||||
var result = await _commands.ActivateAsync(DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.False(result.Found);
|
||||
Assert.Null(result.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_SelectsCorrectManifest()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v2026-01-01", "2026-01-01T00:00:00Z");
|
||||
WriteManifest("b.weights.json", "v2026-02-01", "2026-02-01T00:00:00Z");
|
||||
|
||||
var result = await _commands.ActivateAsync(DateTimeOffset.Parse("2026-01-15T00:00:00Z"));
|
||||
|
||||
Assert.True(result.Found);
|
||||
Assert.Equal("v2026-01-01", result.Version);
|
||||
}
|
||||
|
||||
// ── HashAsync ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task HashAsync_ComputesHash()
|
||||
{
|
||||
var path = WriteManifest("test.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
var result = await _commands.HashAsync(path);
|
||||
|
||||
Assert.StartsWith("sha256:", result.ComputedHash);
|
||||
Assert.True(result.HadPlaceholder);
|
||||
Assert.False(result.WrittenBack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HashAsync_WriteBack_ReplacesPlaceholder()
|
||||
{
|
||||
var path = WriteManifest("test.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
var result = await _commands.HashAsync(path, writeBack: true);
|
||||
|
||||
Assert.True(result.WrittenBack);
|
||||
|
||||
var updatedContent = File.ReadAllText(path);
|
||||
Assert.DoesNotContain("sha256:auto", updatedContent);
|
||||
Assert.Contains(result.ComputedHash, updatedContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HashAsync_WriteBack_Idempotent()
|
||||
{
|
||||
var path = WriteManifest("test.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
var result1 = await _commands.HashAsync(path, writeBack: true);
|
||||
var result2 = await _commands.HashAsync(path, writeBack: false);
|
||||
|
||||
// Hash computed from already-replaced content should be the same
|
||||
Assert.Equal(result1.ComputedHash, result2.ComputedHash);
|
||||
Assert.False(result2.HadPlaceholder); // Placeholder is gone now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WeightManifestHashComputerTests.cs
|
||||
// Sprint: SPRINT_20260208_051_Policy_versioned_weight_manifests
|
||||
// Task: T1 - Unit tests for content hash computation
|
||||
// Description: Deterministic tests for SHA-256 content hashing of weight
|
||||
// manifests, including canonical serialization and auto-replace.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Policy.Determinization.Scoring.WeightManifest;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring.WeightManifest;
|
||||
|
||||
public sealed class WeightManifestHashComputerTests
|
||||
{
|
||||
private const string MinimalManifest = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "v2026-01-01",
|
||||
"effectiveFrom": "2026-01-01T00:00:00Z",
|
||||
"contentHash": "sha256:auto",
|
||||
"weights": {
|
||||
"legacy": { "rch": 0.50, "mit": 0.50 },
|
||||
"advisory": {}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// ── Determinism ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromJson_IsDeterministic_SameInputSameHash()
|
||||
{
|
||||
var hash1 = WeightManifestHashComputer.ComputeFromJson(MinimalManifest);
|
||||
var hash2 = WeightManifestHashComputer.ComputeFromJson(MinimalManifest);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromJson_ReturnsCorrectFormat()
|
||||
{
|
||||
var hash = WeightManifestHashComputer.ComputeFromJson(MinimalManifest);
|
||||
|
||||
Assert.StartsWith("sha256:", hash);
|
||||
Assert.Equal(71, hash.Length); // "sha256:" (7) + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromJson_ExcludesContentHashField()
|
||||
{
|
||||
// Two manifests identical except for contentHash should produce same hash
|
||||
var manifestWithAuto = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "v2026-01-01",
|
||||
"contentHash": "sha256:auto",
|
||||
"weights": { "legacy": {}, "advisory": {} }
|
||||
}
|
||||
""";
|
||||
|
||||
var manifestWithDifferentHash = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "v2026-01-01",
|
||||
"contentHash": "sha256:aaaa",
|
||||
"weights": { "legacy": {}, "advisory": {} }
|
||||
}
|
||||
""";
|
||||
|
||||
var hash1 = WeightManifestHashComputer.ComputeFromJson(manifestWithAuto);
|
||||
var hash2 = WeightManifestHashComputer.ComputeFromJson(manifestWithDifferentHash);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromJson_DifferentContent_DifferentHash()
|
||||
{
|
||||
var manifestA = """
|
||||
{ "schemaVersion": "1.0.0", "version": "v1", "contentHash": "sha256:auto",
|
||||
"weights": { "legacy": { "rch": 0.30 }, "advisory": {} } }
|
||||
""";
|
||||
|
||||
var manifestB = """
|
||||
{ "schemaVersion": "1.0.0", "version": "v1", "contentHash": "sha256:auto",
|
||||
"weights": { "legacy": { "rch": 0.70 }, "advisory": {} } }
|
||||
""";
|
||||
|
||||
var hashA = WeightManifestHashComputer.ComputeFromJson(manifestA);
|
||||
var hashB = WeightManifestHashComputer.ComputeFromJson(manifestB);
|
||||
|
||||
Assert.NotEqual(hashA, hashB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromJson_PropertyOrderDoesNotMatter()
|
||||
{
|
||||
// JSON with properties in different order should produce same hash
|
||||
var manifestOrdered = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "v1",
|
||||
"contentHash": "sha256:auto",
|
||||
"weights": { "legacy": { "a": 0.5, "b": 0.5 }, "advisory": {} }
|
||||
}
|
||||
""";
|
||||
|
||||
var manifestReversed = """
|
||||
{
|
||||
"weights": { "advisory": {}, "legacy": { "b": 0.5, "a": 0.5 } },
|
||||
"contentHash": "sha256:auto",
|
||||
"version": "v1",
|
||||
"schemaVersion": "1.0.0"
|
||||
}
|
||||
""";
|
||||
|
||||
var hash1 = WeightManifestHashComputer.ComputeFromJson(manifestOrdered);
|
||||
var hash2 = WeightManifestHashComputer.ComputeFromJson(manifestReversed);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
// ── Verify ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Verify_AutoPlaceholder_ReturnsFalse()
|
||||
{
|
||||
Assert.False(WeightManifestHashComputer.Verify(MinimalManifest, "sha256:auto"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_EmptyHash_ReturnsFalse()
|
||||
{
|
||||
Assert.False(WeightManifestHashComputer.Verify(MinimalManifest, ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_CorrectHash_ReturnsTrue()
|
||||
{
|
||||
var hash = WeightManifestHashComputer.ComputeFromJson(MinimalManifest);
|
||||
Assert.True(WeightManifestHashComputer.Verify(MinimalManifest, hash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WrongHash_ReturnsFalse()
|
||||
{
|
||||
Assert.False(WeightManifestHashComputer.Verify(
|
||||
MinimalManifest, "sha256:0000000000000000000000000000000000000000000000000000000000000000"));
|
||||
}
|
||||
|
||||
// ── ReplaceAutoHash ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAutoHash_ReplacesPlaceholder()
|
||||
{
|
||||
var (updatedJson, computedHash) = WeightManifestHashComputer.ReplaceAutoHash(MinimalManifest);
|
||||
|
||||
Assert.DoesNotContain("sha256:auto", updatedJson);
|
||||
Assert.Contains(computedHash, updatedJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceAutoHash_ComputedHashVerifies()
|
||||
{
|
||||
var (updatedJson, computedHash) = WeightManifestHashComputer.ReplaceAutoHash(MinimalManifest);
|
||||
|
||||
// After replacement, the hash stored in the JSON should match
|
||||
// what ComputeFromJson produces for the original content
|
||||
Assert.True(WeightManifestHashComputer.Verify(updatedJson, computedHash));
|
||||
}
|
||||
|
||||
// ── ComputeFromManifest ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromManifest_ProducesSameHashAsComputeFromJson()
|
||||
{
|
||||
// Build a manifest that matches our minimal JSON
|
||||
var manifest = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v2026-01-01",
|
||||
EffectiveFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
ContentHash = "sha256:auto",
|
||||
Weights = new WeightManifestWeights()
|
||||
};
|
||||
|
||||
// Both paths should produce a valid sha256 hash
|
||||
var hashFromManifest = WeightManifestHashComputer.ComputeFromManifest(manifest);
|
||||
Assert.StartsWith("sha256:", hashFromManifest);
|
||||
Assert.Equal(71, hashFromManifest.Length);
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromJson_ThrowsOnEmpty()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
WeightManifestHashComputer.ComputeFromJson(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromJson_ThrowsOnNull()
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
WeightManifestHashComputer.ComputeFromJson(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFromManifest_ThrowsOnNull()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
WeightManifestHashComputer.ComputeFromManifest(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// WeightManifestLoaderTests.cs
|
||||
// Sprint: SPRINT_20260208_051_Policy_versioned_weight_manifests
|
||||
// Task: T1 - Unit tests for manifest loader
|
||||
// Description: Tests for file-based manifest discovery, loading, validation,
|
||||
// effectiveFrom selection, and diff computation. Uses temp dirs
|
||||
// for offline, deterministic test execution.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Determinization.Scoring.WeightManifest;
|
||||
|
||||
namespace StellaOps.Policy.Determinization.Tests.Scoring.WeightManifest;
|
||||
|
||||
public sealed class WeightManifestLoaderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly WeightManifestLoader _loader;
|
||||
|
||||
public WeightManifestLoaderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"stella-wm-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
var options = Options.Create(new WeightManifestLoaderOptions
|
||||
{
|
||||
ManifestDirectory = _tempDir,
|
||||
RequireComputedHash = false,
|
||||
StrictHashVerification = false
|
||||
});
|
||||
|
||||
_loader = new WeightManifestLoader(options, NullLogger<WeightManifestLoader>.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private string WriteManifest(string filename, string version, string effectiveFrom,
|
||||
double rch = 0.50, double mit = 0.50, string contentHash = "sha256:auto")
|
||||
{
|
||||
var path = Path.Combine(_tempDir, filename);
|
||||
var json = $$"""
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "{{version}}",
|
||||
"effectiveFrom": "{{effectiveFrom}}",
|
||||
"profile": "production",
|
||||
"contentHash": "{{contentHash}}",
|
||||
"weights": {
|
||||
"legacy": { "rch": {{rch}}, "mit": {{mit}} },
|
||||
"advisory": {}
|
||||
}
|
||||
}
|
||||
""";
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── ListAsync ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_EmptyDirectory_ReturnsEmpty()
|
||||
{
|
||||
var results = await _loader.ListAsync();
|
||||
Assert.True(results.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_DiscoversSingleManifest()
|
||||
{
|
||||
WriteManifest("v2026-01-01.weights.json", "v2026-01-01", "2026-01-01T00:00:00Z");
|
||||
|
||||
var results = await _loader.ListAsync();
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal("v2026-01-01", results[0].Manifest.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_MultipleManifests_SortedByEffectiveFromDescending()
|
||||
{
|
||||
WriteManifest("v2026-01-01.weights.json", "v2026-01-01", "2026-01-01T00:00:00Z");
|
||||
WriteManifest("v2026-02-01.weights.json", "v2026-02-01", "2026-02-01T00:00:00Z");
|
||||
WriteManifest("v2026-01-15.weights.json", "v2026-01-15", "2026-01-15T00:00:00Z");
|
||||
|
||||
var results = await _loader.ListAsync();
|
||||
|
||||
Assert.Equal(3, results.Length);
|
||||
Assert.Equal("v2026-02-01", results[0].Manifest.Version);
|
||||
Assert.Equal("v2026-01-15", results[1].Manifest.Version);
|
||||
Assert.Equal("v2026-01-01", results[2].Manifest.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_SkipsInvalidFiles()
|
||||
{
|
||||
WriteManifest("valid.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
File.WriteAllText(Path.Combine(_tempDir, "invalid.weights.json"), "not valid json {{{");
|
||||
|
||||
var results = await _loader.ListAsync();
|
||||
|
||||
Assert.Single(results);
|
||||
Assert.Equal("v1", results[0].Manifest.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListAsync_NonexistentDirectory_ReturnsEmpty()
|
||||
{
|
||||
var options = Options.Create(new WeightManifestLoaderOptions
|
||||
{
|
||||
ManifestDirectory = Path.Combine(_tempDir, "nonexistent")
|
||||
});
|
||||
var loader = new WeightManifestLoader(options, NullLogger<WeightManifestLoader>.Instance);
|
||||
|
||||
var results = await loader.ListAsync();
|
||||
|
||||
Assert.True(results.IsEmpty);
|
||||
}
|
||||
|
||||
// ── LoadAsync ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ValidFile_ReturnsLoadResult()
|
||||
{
|
||||
var path = WriteManifest("test.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
var result = await _loader.LoadAsync(path);
|
||||
|
||||
Assert.Equal("v1", result.Manifest.Version);
|
||||
Assert.Equal("1.0.0", result.Manifest.SchemaVersion);
|
||||
Assert.StartsWith("sha256:", result.ComputedHash);
|
||||
Assert.False(result.HashVerified); // auto placeholder, not computed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_WithComputedHash_VerifiesCorrectly()
|
||||
{
|
||||
var path = WriteManifest("test.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
// Load, compute hash, rewrite with correct hash
|
||||
var json = File.ReadAllText(path);
|
||||
var (updatedJson, computedHash) = WeightManifestHashComputer.ReplaceAutoHash(json);
|
||||
File.WriteAllText(path, updatedJson);
|
||||
|
||||
var result = await _loader.LoadAsync(path);
|
||||
|
||||
Assert.True(result.HashVerified);
|
||||
Assert.Equal(computedHash, result.ComputedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NonexistentFile_Throws()
|
||||
{
|
||||
await Assert.ThrowsAsync<WeightManifestLoadException>(() =>
|
||||
_loader.LoadAsync(Path.Combine(_tempDir, "missing.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_InvalidJson_Throws()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "bad.json");
|
||||
File.WriteAllText(path, "not json");
|
||||
|
||||
await Assert.ThrowsAsync<WeightManifestLoadException>(() =>
|
||||
_loader.LoadAsync(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_StrictMode_HashMismatch_Throws()
|
||||
{
|
||||
var path = WriteManifest("test.weights.json", "v1", "2026-01-01T00:00:00Z",
|
||||
contentHash: "sha256:0000000000000000000000000000000000000000000000000000000000000000");
|
||||
|
||||
var strictOptions = Options.Create(new WeightManifestLoaderOptions
|
||||
{
|
||||
ManifestDirectory = _tempDir,
|
||||
StrictHashVerification = true
|
||||
});
|
||||
var strictLoader = new WeightManifestLoader(strictOptions, NullLogger<WeightManifestLoader>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<WeightManifestLoadException>(() =>
|
||||
strictLoader.LoadAsync(path));
|
||||
}
|
||||
|
||||
// ── SelectEffectiveAsync ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SelectEffectiveAsync_NoManifests_ReturnsNull()
|
||||
{
|
||||
var result = await _loader.SelectEffectiveAsync(DateTimeOffset.UtcNow);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectEffectiveAsync_SelectsMostRecentEffective()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v2026-01-01", "2026-01-01T00:00:00Z");
|
||||
WriteManifest("b.weights.json", "v2026-02-01", "2026-02-01T00:00:00Z");
|
||||
WriteManifest("c.weights.json", "v2026-03-01", "2026-03-01T00:00:00Z");
|
||||
|
||||
var referenceDate = DateTimeOffset.Parse("2026-02-15T00:00:00Z");
|
||||
var result = await _loader.SelectEffectiveAsync(referenceDate);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("v2026-02-01", result.Manifest.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectEffectiveAsync_DateBeforeAll_ReturnsNull()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v2026-06-01", "2026-06-01T00:00:00Z");
|
||||
|
||||
var referenceDate = DateTimeOffset.Parse("2026-01-01T00:00:00Z");
|
||||
var result = await _loader.SelectEffectiveAsync(referenceDate);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SelectEffectiveAsync_ExactDate_Matches()
|
||||
{
|
||||
WriteManifest("a.weights.json", "v2026-01-15", "2026-01-15T00:00:00Z");
|
||||
|
||||
var referenceDate = DateTimeOffset.Parse("2026-01-15T00:00:00Z");
|
||||
var result = await _loader.SelectEffectiveAsync(referenceDate);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("v2026-01-15", result.Manifest.Version);
|
||||
}
|
||||
|
||||
// ── Validate ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_ValidManifest_NoIssues()
|
||||
{
|
||||
var path = WriteManifest("valid.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
var result = await _loader.LoadAsync(path);
|
||||
|
||||
var issues = _loader.Validate(result);
|
||||
|
||||
Assert.True(issues.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_UnsupportedSchema_ReportsIssue()
|
||||
{
|
||||
var path = Path.Combine(_tempDir, "bad-schema.weights.json");
|
||||
File.WriteAllText(path, """
|
||||
{
|
||||
"schemaVersion": "2.0.0",
|
||||
"version": "v1",
|
||||
"effectiveFrom": "2026-01-01T00:00:00Z",
|
||||
"contentHash": "sha256:auto",
|
||||
"weights": { "legacy": {}, "advisory": {} }
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _loader.LoadAsync(path);
|
||||
var issues = _loader.Validate(result);
|
||||
|
||||
Assert.Single(issues);
|
||||
Assert.Contains("Unsupported schema version", issues[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_UnnormalizedLegacyWeights_ReportsIssue()
|
||||
{
|
||||
var path = WriteManifest("bad-weights.weights.json", "v1", "2026-01-01T00:00:00Z",
|
||||
rch: 0.80, mit: 0.80); // Sum = 1.60
|
||||
|
||||
var result = await _loader.LoadAsync(path);
|
||||
var issues = _loader.Validate(result);
|
||||
|
||||
Assert.Contains(issues, i => i.Contains("Legacy weights sum"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_RequireComputedHash_AutoPlaceholder_ReportsIssue()
|
||||
{
|
||||
var path = WriteManifest("auto.weights.json", "v1", "2026-01-01T00:00:00Z");
|
||||
|
||||
var strictOptions = Options.Create(new WeightManifestLoaderOptions
|
||||
{
|
||||
ManifestDirectory = _tempDir,
|
||||
RequireComputedHash = true
|
||||
});
|
||||
var strictLoader = new WeightManifestLoader(strictOptions, NullLogger<WeightManifestLoader>.Instance);
|
||||
|
||||
var result = await strictLoader.LoadAsync(path);
|
||||
var issues = strictLoader.Validate(result);
|
||||
|
||||
Assert.Contains(issues, i => i.Contains("sha256:auto"));
|
||||
}
|
||||
|
||||
// ── Diff ─────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Diff_IdenticalManifests_NoDifferences()
|
||||
{
|
||||
var manifest = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
EffectiveFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
ContentHash = "sha256:auto",
|
||||
Weights = new WeightManifestWeights
|
||||
{
|
||||
Legacy = ImmutableDictionary<string, double>.Empty.Add("rch", 0.50).Add("mit", 0.50),
|
||||
Advisory = ImmutableDictionary<string, double>.Empty
|
||||
}
|
||||
};
|
||||
|
||||
var diff = _loader.Diff(manifest, manifest);
|
||||
|
||||
Assert.False(diff.HasDifferences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_DifferentVersions_ShowsDifference()
|
||||
{
|
||||
var from = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
EffectiveFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
ContentHash = "sha256:auto",
|
||||
Weights = new WeightManifestWeights()
|
||||
};
|
||||
|
||||
var to = from with { Version = "v2" };
|
||||
|
||||
var diff = _loader.Diff(from, to);
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
Assert.Contains(diff.Differences, d => d.Path == "version" && d.OldValue == "v1" && d.NewValue == "v2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_DifferentWeights_ShowsDifferences()
|
||||
{
|
||||
var from = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
EffectiveFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
ContentHash = "sha256:auto",
|
||||
Weights = new WeightManifestWeights
|
||||
{
|
||||
Legacy = ImmutableDictionary<string, double>.Empty.Add("rch", 0.30),
|
||||
Advisory = ImmutableDictionary<string, double>.Empty
|
||||
}
|
||||
};
|
||||
|
||||
var to = from with
|
||||
{
|
||||
Version = "v2",
|
||||
Weights = new WeightManifestWeights
|
||||
{
|
||||
Legacy = ImmutableDictionary<string, double>.Empty.Add("rch", 0.50),
|
||||
Advisory = ImmutableDictionary<string, double>.Empty
|
||||
}
|
||||
};
|
||||
|
||||
var diff = _loader.Diff(from, to);
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
Assert.Contains(diff.Differences, d => d.Path == "weights.legacy.rch");
|
||||
Assert.Equal("v1", diff.FromVersion);
|
||||
Assert.Equal("v2", diff.ToVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diff_AddedWeight_ShowsAsNewField()
|
||||
{
|
||||
var from = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
EffectiveFrom = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
||||
ContentHash = "sha256:auto",
|
||||
Weights = new WeightManifestWeights
|
||||
{
|
||||
Legacy = ImmutableDictionary<string, double>.Empty.Add("rch", 0.30),
|
||||
Advisory = ImmutableDictionary<string, double>.Empty
|
||||
}
|
||||
};
|
||||
|
||||
var to = from with
|
||||
{
|
||||
Version = "v2",
|
||||
Weights = new WeightManifestWeights
|
||||
{
|
||||
Legacy = ImmutableDictionary<string, double>.Empty
|
||||
.Add("rch", 0.30)
|
||||
.Add("mit", 0.20),
|
||||
Advisory = ImmutableDictionary<string, double>.Empty
|
||||
}
|
||||
};
|
||||
|
||||
var diff = _loader.Diff(from, to);
|
||||
|
||||
Assert.True(diff.HasDifferences);
|
||||
var mitDiff = diff.Differences.First(d => d.Path == "weights.legacy.mit");
|
||||
Assert.Null(mitDiff.OldValue);
|
||||
Assert.NotNull(mitDiff.NewValue);
|
||||
}
|
||||
|
||||
// ── WeightManifestDocument model ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HasComputedHash_AutoPlaceholder_ReturnsFalse()
|
||||
{
|
||||
var manifest = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
EffectiveFrom = DateTimeOffset.UtcNow,
|
||||
ContentHash = "sha256:auto",
|
||||
Weights = new WeightManifestWeights()
|
||||
};
|
||||
|
||||
Assert.False(manifest.HasComputedHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasComputedHash_RealHash_ReturnsTrue()
|
||||
{
|
||||
var manifest = new WeightManifestDocument
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v1",
|
||||
EffectiveFrom = DateTimeOffset.UtcNow,
|
||||
ContentHash = "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
Weights = new WeightManifestWeights()
|
||||
};
|
||||
|
||||
Assert.True(manifest.HasComputedHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// <copyright file="DeltaIfPresentIntegrationTests.cs" company="StellaOps">
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Sprint: SPRINT_20260208_043_Policy_delta_if_present_calculations_for_missing_signals (TSF-004)
|
||||
// Task: T2 - Wire API/CLI/UI integration tests
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Determinization;
|
||||
using StellaOps.Policy.Determinization.Evidence;
|
||||
using StellaOps.Policy.Determinization.Models;
|
||||
using StellaOps.Policy.Determinization.Scoring;
|
||||
using StellaOps.Policy.Engine.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for delta-if-present service DI wiring and functionality.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "20260208.043")]
|
||||
[Trait("Task", "T2")]
|
||||
public sealed class DeltaIfPresentIntegrationTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
|
||||
private static ServiceCollection CreateServicesWithConfiguration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection()
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
return services;
|
||||
}
|
||||
|
||||
#region DI Wiring Tests
|
||||
|
||||
[Fact(DisplayName = "AddDeterminization registers IDeltaIfPresentCalculator")]
|
||||
public void AddDeterminization_RegistersDeltaIfPresentCalculator()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
|
||||
// Act
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var calculator = provider.GetService<IDeltaIfPresentCalculator>();
|
||||
calculator.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "DeltaIfPresentCalculator is registered as singleton")]
|
||||
public void DeltaIfPresentCalculator_IsRegisteredAsSingleton()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var first = provider.GetService<IDeltaIfPresentCalculator>();
|
||||
var second = provider.GetService<IDeltaIfPresentCalculator>();
|
||||
|
||||
// Assert
|
||||
first.Should().BeSameAs(second);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "AddDeterminizationEngine also registers delta-if-present")]
|
||||
public void AddDeterminizationEngine_IncludesDeltaIfPresentCalculator()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminizationEngine();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var calculator = provider.GetService<IDeltaIfPresentCalculator>();
|
||||
calculator.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Service Tests
|
||||
|
||||
[Fact(DisplayName = "CalculateSingleSignalDelta works through DI container")]
|
||||
public void CalculateSingleSignalDelta_WorksThroughDI()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var calculator = provider.GetRequiredService<IDeltaIfPresentCalculator>();
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var result = calculator.CalculateSingleSignalDelta(snapshot, "VEX", 0.0);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Signal.Should().Be("VEX");
|
||||
result.HypotheticalEntropy.Should().BeLessThan(result.CurrentEntropy);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CalculateFullAnalysis returns prioritized gaps")]
|
||||
public void CalculateFullAnalysis_ReturnsPrioritizedGaps()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var calculator = provider.GetRequiredService<IDeltaIfPresentCalculator>();
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var analysis = calculator.CalculateFullAnalysis(snapshot);
|
||||
|
||||
// Assert
|
||||
analysis.Should().NotBeNull();
|
||||
analysis.GapAnalysis.Should().HaveCountGreaterThan(0);
|
||||
analysis.PrioritizedGaps.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "CalculateScoreBounds returns valid range")]
|
||||
public void CalculateScoreBounds_ReturnsValidRange()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var calculator = provider.GetRequiredService<IDeltaIfPresentCalculator>();
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var bounds = calculator.CalculateScoreBounds(snapshot);
|
||||
|
||||
// Assert
|
||||
bounds.Should().NotBeNull();
|
||||
bounds.MinimumScore.Should().BeLessThanOrEqualTo(bounds.MaximumScore);
|
||||
bounds.Range.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Calculator produces deterministic results through DI")]
|
||||
public void Calculator_ProducesDeterministicResults()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var calculator = provider.GetRequiredService<IDeltaIfPresentCalculator>();
|
||||
var snapshot = CreatePartialSnapshot();
|
||||
|
||||
// Act
|
||||
var result1 = calculator.CalculateSingleSignalDelta(snapshot, "EPSS", 0.5);
|
||||
var result2 = calculator.CalculateSingleSignalDelta(snapshot, "EPSS", 0.5);
|
||||
|
||||
// Assert - Results should be identical
|
||||
result1.CurrentScore.Should().Be(result2.CurrentScore);
|
||||
result1.HypotheticalScore.Should().Be(result2.HypotheticalScore);
|
||||
result1.CurrentEntropy.Should().Be(result2.CurrentEntropy);
|
||||
result1.HypotheticalEntropy.Should().Be(result2.HypotheticalEntropy);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "All signals can be analyzed without exceptions")]
|
||||
public void AllSignals_CanBeAnalyzed()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var calculator = provider.GetRequiredService<IDeltaIfPresentCalculator>();
|
||||
var snapshot = CreateEmptySnapshot();
|
||||
var signals = new[] { "VEX", "EPSS", "Reachability", "Runtime", "Backport", "SBOMLineage" };
|
||||
|
||||
// Act & Assert - All signals should be analyzable
|
||||
foreach (var signal in signals)
|
||||
{
|
||||
var result = calculator.CalculateSingleSignalDelta(snapshot, signal, 0.5);
|
||||
result.Signal.Should().Be(signal);
|
||||
result.SignalWeight.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration with Dependencies
|
||||
|
||||
[Fact(DisplayName = "Calculator uses injected UncertaintyScoreCalculator")]
|
||||
public void Calculator_UsesInjectedDependencies()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Act - Get both services
|
||||
var calculator = provider.GetRequiredService<IDeltaIfPresentCalculator>();
|
||||
var uncertaintyCalc = provider.GetRequiredService<IUncertaintyScoreCalculator>();
|
||||
|
||||
// Assert - Both should be available
|
||||
calculator.Should().NotBeNull();
|
||||
uncertaintyCalc.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Calculator uses injected TrustScoreAggregator")]
|
||||
public void Calculator_UsesInjectedTrustAggregator()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<TimeProvider>(_timeProvider);
|
||||
services.AddDeterminization();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Act - Get both services
|
||||
var calculator = provider.GetRequiredService<IDeltaIfPresentCalculator>();
|
||||
var aggregator = provider.GetRequiredService<TrustScoreAggregator>();
|
||||
|
||||
// Assert - Both should be available
|
||||
calculator.Should().NotBeNull();
|
||||
aggregator.Should().NotBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private SignalSnapshot CreateEmptySnapshot()
|
||||
{
|
||||
return SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", _timeProvider.GetUtcNow());
|
||||
}
|
||||
|
||||
private SignalSnapshot CreatePartialSnapshot()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return new SignalSnapshot
|
||||
{
|
||||
Cve = "CVE-2024-1234",
|
||||
Purl = "pkg:maven/test@1.0",
|
||||
Vex = SignalState<VexClaimSummary>.NotQueried(),
|
||||
Epss = SignalState<EpssEvidence>.NotQueried(),
|
||||
Reachability = SignalState<ReachabilityEvidence>.Queried(
|
||||
new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = now }, now),
|
||||
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
|
||||
Backport = SignalState<BackportEvidence>.NotQueried(),
|
||||
Sbom = SignalState<SbomLineageEvidence>.Queried(
|
||||
new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = now, HasProvenance = true }, now),
|
||||
Cvss = SignalState<CvssEvidence>.NotQueried(),
|
||||
SnapshotAt = now
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProofGraphBuilderTests.cs
|
||||
// Sprint: SPRINT_20260208_049_Policy_proof_studio_ux
|
||||
// Task: T1 - Unit tests for proof graph builder
|
||||
// Description: Deterministic tests for proof graph construction, path finding,
|
||||
// counterfactual overlays, and content-addressed IDs.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Explainability.Tests;
|
||||
|
||||
public sealed class ProofGraphBuilderTests
|
||||
{
|
||||
private readonly ProofGraphBuilder _builder;
|
||||
|
||||
public ProofGraphBuilderTests()
|
||||
{
|
||||
_builder = new ProofGraphBuilder(NullLogger<ProofGraphBuilder>.Instance);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static VerdictRationale CreateTestRationale(
|
||||
string cve = "CVE-2026-0001",
|
||||
string verdict = "Affected",
|
||||
double? score = 75.0,
|
||||
bool includeReachability = true,
|
||||
bool includeVex = true,
|
||||
bool includeProvenance = true,
|
||||
bool includePathWitness = false)
|
||||
{
|
||||
return new VerdictRationale
|
||||
{
|
||||
RationaleId = "rat:sha256:test",
|
||||
VerdictRef = new VerdictReference
|
||||
{
|
||||
AttestationId = "att-001",
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
PolicyId = "policy-001",
|
||||
Cve = cve,
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.20"
|
||||
},
|
||||
Evidence = new RationaleEvidence
|
||||
{
|
||||
Cve = cve,
|
||||
Component = new ComponentIdentity
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
Name = "lodash",
|
||||
Version = "4.17.20",
|
||||
Ecosystem = "npm"
|
||||
},
|
||||
Reachability = includeReachability
|
||||
? new ReachabilityDetail
|
||||
{
|
||||
VulnerableFunction = "merge()",
|
||||
EntryPoint = "app.js",
|
||||
PathSummary = "app.js -> utils.js -> lodash.merge()"
|
||||
}
|
||||
: null,
|
||||
FormattedText = $"{cve} in lodash@4.17.20"
|
||||
},
|
||||
PolicyClause = new RationalePolicyClause
|
||||
{
|
||||
ClauseId = "S2.1",
|
||||
RuleDescription = "Block on reachable critical CVEs",
|
||||
Conditions = ["severity >= high", "reachability == direct"],
|
||||
FormattedText = "Policy S2.1: Block on reachable critical CVEs"
|
||||
},
|
||||
Attestations = new RationaleAttestations
|
||||
{
|
||||
PathWitness = includePathWitness
|
||||
? new AttestationReference
|
||||
{
|
||||
Id = "pw-001",
|
||||
Type = "path_witness",
|
||||
Digest = "sha256:pw1",
|
||||
Summary = "Path verified by static analysis"
|
||||
}
|
||||
: null,
|
||||
VexStatements = includeVex
|
||||
? [new AttestationReference
|
||||
{
|
||||
Id = "vex-001",
|
||||
Type = "vex",
|
||||
Digest = "sha256:vex1",
|
||||
Summary = "Vendor confirms affected"
|
||||
}]
|
||||
: null,
|
||||
Provenance = includeProvenance
|
||||
? new AttestationReference
|
||||
{
|
||||
Id = "prov-001",
|
||||
Type = "provenance",
|
||||
Digest = "sha256:prov1",
|
||||
Summary = "SLSA Level 3"
|
||||
}
|
||||
: null,
|
||||
FormattedText = "Attestations verified"
|
||||
},
|
||||
Decision = new RationaleDecision
|
||||
{
|
||||
Verdict = verdict,
|
||||
Score = score,
|
||||
Recommendation = "Upgrade to lodash@4.17.21",
|
||||
Mitigation = new MitigationGuidance
|
||||
{
|
||||
Action = "upgrade",
|
||||
Details = "Patch available in 4.17.21"
|
||||
},
|
||||
FormattedText = $"{verdict} (score {score:F2})"
|
||||
},
|
||||
GeneratedAt = new DateTimeOffset(2026, 2, 9, 12, 0, 0, TimeSpan.Zero),
|
||||
InputDigests = new RationaleInputDigests
|
||||
{
|
||||
VerdictDigest = "sha256:verdict1",
|
||||
PolicyDigest = "sha256:policy1",
|
||||
EvidenceDigest = "sha256:evidence1"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ScoreBreakdownDashboard CreateTestBreakdown()
|
||||
{
|
||||
return new ScoreBreakdownDashboard
|
||||
{
|
||||
DashboardId = "dash-001",
|
||||
VerdictRef = new VerdictReference
|
||||
{
|
||||
AttestationId = "att-001",
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
PolicyId = "policy-001"
|
||||
},
|
||||
CompositeScore = 75,
|
||||
ActionBucket = "Schedule Next",
|
||||
Factors =
|
||||
[
|
||||
new FactorContribution
|
||||
{
|
||||
FactorId = "rch",
|
||||
FactorName = "Reachability",
|
||||
RawScore = 85,
|
||||
Weight = 0.30,
|
||||
Confidence = 0.90,
|
||||
Explanation = "Direct reachability confirmed"
|
||||
},
|
||||
new FactorContribution
|
||||
{
|
||||
FactorId = "rts",
|
||||
FactorName = "Runtime Signal",
|
||||
RawScore = 60,
|
||||
Weight = 0.25,
|
||||
Confidence = 0.70,
|
||||
Explanation = "Runtime detection moderate"
|
||||
},
|
||||
new FactorContribution
|
||||
{
|
||||
FactorId = "mit",
|
||||
FactorName = "Mitigation",
|
||||
RawScore = 30,
|
||||
Weight = 0.10,
|
||||
Confidence = 0.95,
|
||||
IsSubtractive = true,
|
||||
Explanation = "Patch available"
|
||||
}
|
||||
],
|
||||
GuardrailsApplied =
|
||||
[
|
||||
new GuardrailApplication
|
||||
{
|
||||
GuardrailName = "speculativeCap",
|
||||
ScoreBefore = 80,
|
||||
ScoreAfter = 45,
|
||||
Reason = "No runtime evidence, capped at 45",
|
||||
Conditions = ["rch == 0", "rts == 0"]
|
||||
}
|
||||
],
|
||||
PreGuardrailScore = 80,
|
||||
Entropy = 0.35,
|
||||
NeedsReview = false,
|
||||
ComputedAt = new DateTimeOffset(2026, 2, 9, 12, 0, 0, TimeSpan.Zero)
|
||||
};
|
||||
}
|
||||
|
||||
// ── Build basic graph ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Build_MinimalInput_CreatesGraph()
|
||||
{
|
||||
var rationale = CreateTestRationale(
|
||||
includeReachability: false,
|
||||
includeVex: false,
|
||||
includeProvenance: false);
|
||||
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.Should().NotBeNull();
|
||||
graph.GraphId.Should().StartWith("pg:sha256:");
|
||||
graph.RootNodeId.Should().StartWith("verdict:");
|
||||
graph.Nodes.Should().HaveCountGreaterThanOrEqualTo(2); // verdict + policy
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithReachability_AddsEvidenceNode()
|
||||
{
|
||||
var rationale = CreateTestRationale(includeReachability: true);
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.Nodes.Should().Contain(n => n.Type == ProofNodeType.ReachabilityAnalysis);
|
||||
graph.LeafNodeIds.Should().Contain(id => id.Contains("reachability"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithVex_AddsVexNode()
|
||||
{
|
||||
var rationale = CreateTestRationale(includeVex: true);
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.Nodes.Should().Contain(n => n.Type == ProofNodeType.VexStatement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithProvenance_AddsProvenanceNode()
|
||||
{
|
||||
var rationale = CreateTestRationale(includeProvenance: true);
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.Nodes.Should().Contain(n => n.Type == ProofNodeType.Provenance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithPathWitness_AddsPathWitnessNode()
|
||||
{
|
||||
var rationale = CreateTestRationale(includePathWitness: true);
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.LeafNodeIds.Should().Contain(id => id.Contains("pathwitness"));
|
||||
}
|
||||
|
||||
// ── Score breakdown integration ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Build_WithScoreBreakdown_AddsScoreNodes()
|
||||
{
|
||||
var rationale = CreateTestRationale();
|
||||
var breakdown = CreateTestBreakdown();
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ScoreBreakdown = breakdown,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.Nodes.Should().Contain(n => n.Id == "score:rch");
|
||||
graph.Nodes.Should().Contain(n => n.Id == "score:rts");
|
||||
graph.Nodes.Should().Contain(n => n.Id == "score:mit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WithGuardrails_AddsGuardrailNodes()
|
||||
{
|
||||
var rationale = CreateTestRationale();
|
||||
var breakdown = CreateTestBreakdown();
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ScoreBreakdown = breakdown,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.Nodes.Should().Contain(n => n.Type == ProofNodeType.Guardrail);
|
||||
graph.Edges.Should().Contain(e => e.Relation == ProofEdgeRelation.GuardrailApplied);
|
||||
}
|
||||
|
||||
// ── Determinism ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Build_IsDeterministic_SameInputsSameGraphId()
|
||||
{
|
||||
var rationale = CreateTestRationale();
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph1 = _builder.Build(input);
|
||||
var graph2 = _builder.Build(input);
|
||||
|
||||
graph1.GraphId.Should().Be(graph2.GraphId);
|
||||
graph1.Nodes.Length.Should().Be(graph2.Nodes.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_DifferentInputs_DifferentGraphIds()
|
||||
{
|
||||
var rationale1 = CreateTestRationale(cve: "CVE-2026-0001");
|
||||
var rationale2 = CreateTestRationale(cve: "CVE-2026-0002");
|
||||
|
||||
var graph1 = _builder.Build(new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale1,
|
||||
ComputedAt = rationale1.GeneratedAt
|
||||
});
|
||||
var graph2 = _builder.Build(new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale2,
|
||||
ComputedAt = rationale2.GeneratedAt
|
||||
});
|
||||
|
||||
graph1.GraphId.Should().NotBe(graph2.GraphId);
|
||||
}
|
||||
|
||||
// ── Depth hierarchy ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Build_NodeDepths_FollowHierarchy()
|
||||
{
|
||||
var rationale = CreateTestRationale();
|
||||
var breakdown = CreateTestBreakdown();
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ScoreBreakdown = breakdown,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
var verdictNode = graph.Nodes.First(n => n.Type == ProofNodeType.Verdict);
|
||||
var policyNode = graph.Nodes.First(n => n.Type == ProofNodeType.PolicyRule);
|
||||
var scoreNodes = graph.Nodes.Where(n => n.Type == ProofNodeType.ScoreComputation);
|
||||
var leafNodes = graph.Nodes.Where(n => n.Depth == 3);
|
||||
|
||||
verdictNode.Depth.Should().Be(0);
|
||||
policyNode.Depth.Should().Be(1);
|
||||
scoreNodes.Should().AllSatisfy(n => n.Depth.Should().Be(2));
|
||||
leafNodes.Should().AllSatisfy(n => n.Depth.Should().Be(3));
|
||||
}
|
||||
|
||||
// ── Critical paths ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Build_FullEvidence_HasCriticalPaths()
|
||||
{
|
||||
var rationale = CreateTestRationale(
|
||||
includeReachability: true,
|
||||
includeVex: true,
|
||||
includeProvenance: true);
|
||||
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
graph.CriticalPaths.Should().NotBeEmpty();
|
||||
graph.CriticalPaths.Should().Contain(p => p.IsCritical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CriticalPaths_StartFromLeafAndEndAtRoot()
|
||||
{
|
||||
var rationale = CreateTestRationale(includeVex: true);
|
||||
var input = new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
};
|
||||
|
||||
var graph = _builder.Build(input);
|
||||
|
||||
foreach (var path in graph.CriticalPaths)
|
||||
{
|
||||
path.NodeIds.Should().NotBeEmpty();
|
||||
graph.LeafNodeIds.Should().Contain(path.NodeIds[0]);
|
||||
path.NodeIds[^1].Should().Be(graph.RootNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Counterfactual overlay ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AddCounterfactualOverlay_AddsCounterfactualNode()
|
||||
{
|
||||
var rationale = CreateTestRationale();
|
||||
var breakdown = CreateTestBreakdown();
|
||||
var baseGraph = _builder.Build(new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ScoreBreakdown = breakdown,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
});
|
||||
|
||||
var scenario = new CounterfactualScenario
|
||||
{
|
||||
Label = "Full Mitigation",
|
||||
FactorOverrides = ImmutableDictionary<string, int>.Empty
|
||||
.Add("mit", 100),
|
||||
ResultingScore = 50
|
||||
};
|
||||
|
||||
var overlayGraph = _builder.AddCounterfactualOverlay(baseGraph, scenario);
|
||||
|
||||
overlayGraph.Nodes.Should().Contain(n => n.Type == ProofNodeType.Counterfactual);
|
||||
overlayGraph.GraphId.Should().NotBe(baseGraph.GraphId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCounterfactualOverlay_ConnectsOverriddenFactors()
|
||||
{
|
||||
var rationale = CreateTestRationale();
|
||||
var breakdown = CreateTestBreakdown();
|
||||
var baseGraph = _builder.Build(new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ScoreBreakdown = breakdown,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
});
|
||||
|
||||
var scenario = new CounterfactualScenario
|
||||
{
|
||||
Label = "Patch Applied",
|
||||
FactorOverrides = ImmutableDictionary<string, int>.Empty
|
||||
.Add("mit", 100)
|
||||
.Add("rch", 0),
|
||||
ResultingScore = 30
|
||||
};
|
||||
|
||||
var overlayGraph = _builder.AddCounterfactualOverlay(baseGraph, scenario);
|
||||
|
||||
overlayGraph.Edges.Should().Contain(e => e.Relation == ProofEdgeRelation.Overrides);
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Build_ThrowsOnNullInput()
|
||||
{
|
||||
var act = () => _builder.Build(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCounterfactualOverlay_ThrowsOnNullGraph()
|
||||
{
|
||||
var scenario = new CounterfactualScenario
|
||||
{
|
||||
Label = "test",
|
||||
FactorOverrides = ImmutableDictionary<string, int>.Empty
|
||||
};
|
||||
|
||||
var act = () => _builder.AddCounterfactualOverlay(null!, scenario);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCounterfactualOverlay_ThrowsOnNullScenario()
|
||||
{
|
||||
var rationale = CreateTestRationale();
|
||||
var graph = _builder.Build(new ProofGraphInput
|
||||
{
|
||||
Rationale = rationale,
|
||||
ComputedAt = rationale.GeneratedAt
|
||||
});
|
||||
|
||||
var act = () => _builder.AddCounterfactualOverlay(graph, null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ProofStudioServiceTests.cs
|
||||
// Sprint: SPRINT_20260208_049_Policy_proof_studio_ux
|
||||
// Task: T2 - Integration tests for proof studio service
|
||||
// Description: Tests for the ProofStudioService integration layer that
|
||||
// composes proof graphs and score breakdowns from policy
|
||||
// engine data.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace StellaOps.Policy.Explainability.Tests;
|
||||
|
||||
public sealed class ProofStudioServiceTests
|
||||
{
|
||||
private readonly IProofStudioService _service;
|
||||
|
||||
public ProofStudioServiceTests()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddVerdictExplainability();
|
||||
services.AddLogging();
|
||||
services.AddMetrics();
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
_service = provider.GetRequiredService<IProofStudioService>();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private static VerdictRationale CreateTestRationale(string cve = "CVE-2026-0001")
|
||||
{
|
||||
return new VerdictRationale
|
||||
{
|
||||
RationaleId = "rat:sha256:test",
|
||||
VerdictRef = new VerdictReference
|
||||
{
|
||||
AttestationId = "att-001",
|
||||
ArtifactDigest = "sha256:abc123",
|
||||
PolicyId = "policy-001",
|
||||
Cve = cve
|
||||
},
|
||||
Evidence = new RationaleEvidence
|
||||
{
|
||||
Cve = cve,
|
||||
Component = new ComponentIdentity
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
Name = "lodash",
|
||||
Version = "4.17.20",
|
||||
Ecosystem = "npm"
|
||||
},
|
||||
Reachability = new ReachabilityDetail
|
||||
{
|
||||
VulnerableFunction = "merge()",
|
||||
EntryPoint = "app.js",
|
||||
PathSummary = "app.js -> lodash.merge()"
|
||||
},
|
||||
FormattedText = $"{cve} in lodash@4.17.20"
|
||||
},
|
||||
PolicyClause = new RationalePolicyClause
|
||||
{
|
||||
ClauseId = "S2.1",
|
||||
RuleDescription = "Block on reachable critical CVEs",
|
||||
Conditions = ["severity >= high"],
|
||||
FormattedText = "Policy S2.1"
|
||||
},
|
||||
Attestations = new RationaleAttestations
|
||||
{
|
||||
VexStatements =
|
||||
[
|
||||
new AttestationReference
|
||||
{
|
||||
Id = "vex-001", Type = "vex",
|
||||
Digest = "sha256:vex1",
|
||||
Summary = "Vendor confirms affected"
|
||||
}
|
||||
],
|
||||
FormattedText = "Attestations verified"
|
||||
},
|
||||
Decision = new RationaleDecision
|
||||
{
|
||||
Verdict = "Affected",
|
||||
Score = 75.0,
|
||||
Recommendation = "Upgrade lodash",
|
||||
FormattedText = "Affected (score 75.00)"
|
||||
},
|
||||
GeneratedAt = new DateTimeOffset(2026, 2, 9, 12, 0, 0, TimeSpan.Zero),
|
||||
InputDigests = new RationaleInputDigests
|
||||
{
|
||||
VerdictDigest = "sha256:verdict1",
|
||||
PolicyDigest = "sha256:policy1"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ProofStudioRequest CreateFullRequest()
|
||||
{
|
||||
return new ProofStudioRequest
|
||||
{
|
||||
Rationale = CreateTestRationale(),
|
||||
CompositeScore = 75,
|
||||
ActionBucket = "Schedule Next",
|
||||
ScoreFactors =
|
||||
[
|
||||
new ScoreFactorInput
|
||||
{
|
||||
Factor = "reachability",
|
||||
Value = 85,
|
||||
Weight = 0.30,
|
||||
Confidence = 0.90,
|
||||
Reason = "Direct reachability confirmed"
|
||||
},
|
||||
new ScoreFactorInput
|
||||
{
|
||||
Factor = "evidence",
|
||||
Value = 60,
|
||||
Weight = 0.25,
|
||||
Confidence = 0.70,
|
||||
Reason = "Runtime evidence moderate"
|
||||
},
|
||||
new ScoreFactorInput
|
||||
{
|
||||
Factor = "mitigation",
|
||||
Value = 30,
|
||||
Weight = 0.10,
|
||||
Confidence = 0.95,
|
||||
IsSubtractive = true,
|
||||
Reason = "Patch available"
|
||||
}
|
||||
],
|
||||
Guardrails =
|
||||
[
|
||||
new GuardrailInput
|
||||
{
|
||||
Name = "speculativeCap",
|
||||
ScoreBefore = 80,
|
||||
ScoreAfter = 45,
|
||||
Reason = "No runtime evidence, capped",
|
||||
Conditions = ["rch == 0"]
|
||||
}
|
||||
],
|
||||
Entropy = 0.35,
|
||||
NeedsReview = false
|
||||
};
|
||||
}
|
||||
|
||||
// ── Compose tests ────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Compose_MinimalRequest_ReturnsView()
|
||||
{
|
||||
var request = new ProofStudioRequest
|
||||
{
|
||||
Rationale = CreateTestRationale()
|
||||
};
|
||||
|
||||
var view = _service.Compose(request);
|
||||
|
||||
view.Should().NotBeNull();
|
||||
view.ProofGraph.Should().NotBeNull();
|
||||
view.ProofGraph.GraphId.Should().StartWith("pg:sha256:");
|
||||
view.ScoreBreakdown.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_WithScoreFactors_BuildsDashboard()
|
||||
{
|
||||
var request = CreateFullRequest();
|
||||
|
||||
var view = _service.Compose(request);
|
||||
|
||||
view.ScoreBreakdown.Should().NotBeNull();
|
||||
view.ScoreBreakdown!.Factors.Should().HaveCount(3);
|
||||
view.ScoreBreakdown.CompositeScore.Should().Be(75);
|
||||
view.ScoreBreakdown.ActionBucket.Should().Be("Schedule Next");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_WithGuardrails_IncludesGuardrailsInDashboard()
|
||||
{
|
||||
var request = CreateFullRequest();
|
||||
|
||||
var view = _service.Compose(request);
|
||||
|
||||
view.ScoreBreakdown!.GuardrailsApplied.Should().HaveCount(1);
|
||||
view.ScoreBreakdown.GuardrailsApplied[0].GuardrailName.Should().Be("speculativeCap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_FactorNamesAreFormatted()
|
||||
{
|
||||
var request = CreateFullRequest();
|
||||
|
||||
var view = _service.Compose(request);
|
||||
|
||||
var names = view.ScoreBreakdown!.Factors.Select(f => f.FactorName).ToArray();
|
||||
names.Should().Contain("Reachability");
|
||||
names.Should().Contain("Evidence");
|
||||
names.Should().Contain("Mitigation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_GraphContainsScoreNodes()
|
||||
{
|
||||
var request = CreateFullRequest();
|
||||
|
||||
var view = _service.Compose(request);
|
||||
|
||||
view.ProofGraph.Nodes.Should().Contain(n => n.Id == "score:reachability");
|
||||
view.ProofGraph.Nodes.Should().Contain(n => n.Id == "score:evidence");
|
||||
view.ProofGraph.Nodes.Should().Contain(n => n.Id == "score:mitigation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_ThrowsOnNullRequest()
|
||||
{
|
||||
var act = () => _service.Compose(null!);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── Counterfactual tests ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ApplyCounterfactual_AddsOverlay()
|
||||
{
|
||||
var request = CreateFullRequest();
|
||||
var view = _service.Compose(request);
|
||||
|
||||
var scenario = new CounterfactualScenario
|
||||
{
|
||||
Label = "Full Patch",
|
||||
FactorOverrides = ImmutableDictionary<string, int>.Empty
|
||||
.Add("mitigation", 100),
|
||||
ResultingScore = 50
|
||||
};
|
||||
|
||||
var updatedView = _service.ApplyCounterfactual(view, scenario);
|
||||
|
||||
updatedView.ProofGraph.Nodes.Should()
|
||||
.Contain(n => n.Type == ProofNodeType.Counterfactual);
|
||||
updatedView.ProofGraph.GraphId.Should()
|
||||
.NotBe(view.ProofGraph.GraphId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyCounterfactual_ThrowsOnNullView()
|
||||
{
|
||||
var scenario = new CounterfactualScenario
|
||||
{
|
||||
Label = "test",
|
||||
FactorOverrides = ImmutableDictionary<string, int>.Empty
|
||||
};
|
||||
|
||||
var act = () => _service.ApplyCounterfactual(null!, scenario);
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
|
||||
// ── DI integration ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void DI_ResolvesAllExplainabilityServices()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddVerdictExplainability();
|
||||
services.AddLogging();
|
||||
services.AddMetrics();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
provider.GetService<IVerdictRationaleRenderer>().Should().NotBeNull();
|
||||
provider.GetService<IProofGraphBuilder>().Should().NotBeNull();
|
||||
provider.GetService<IProofStudioService>().Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Explainability\StellaOps.Policy.Explainability.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user