This commit is contained in:
master
2026-01-07 10:25:34 +02:00
726 changed files with 147397 additions and 1364 deletions

View File

@@ -0,0 +1,114 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests;
public class DecayedConfidenceCalculatorTests
{
private readonly DecayedConfidenceCalculator _calculator;
public DecayedConfidenceCalculatorTests()
{
_calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
}
[Fact]
public void Calculate_ZeroAge_ReturnsBaseConfidence()
{
// Arrange
var baseConfidence = 0.8;
var ageDays = 0.0;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays);
// Assert
result.Should().Be(baseConfidence);
}
[Fact]
public void Calculate_HalfLife_ReturnsHalfConfidence()
{
// Arrange
var baseConfidence = 1.0;
var halfLifeDays = 14.0;
var ageDays = 14.0;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays);
// Assert
result.Should().BeApproximately(0.5, 0.01);
}
[Fact]
public void Calculate_TwoHalfLives_ReturnsQuarterConfidence()
{
// Arrange
var baseConfidence = 1.0;
var halfLifeDays = 14.0;
var ageDays = 28.0;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays);
// Assert
result.Should().BeApproximately(0.25, 0.01);
}
[Fact]
public void Calculate_VeryOld_ReturnsFloor()
{
// Arrange
var baseConfidence = 1.0;
var halfLifeDays = 14.0;
var ageDays = 200.0;
var floor = 0.1;
// Act
var result = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays, floor);
// Assert
result.Should().Be(floor);
}
[Fact]
public void CalculateDecayFactor_ZeroAge_ReturnsOne()
{
// Act
var factor = _calculator.CalculateDecayFactor(0.0);
// Assert
factor.Should().Be(1.0);
}
[Fact]
public void CalculateDecayFactor_HalfLife_ReturnsHalf()
{
// Act
var factor = _calculator.CalculateDecayFactor(14.0, 14.0);
// Assert
factor.Should().BeApproximately(0.5, 0.01);
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.1)]
public void Calculate_InvalidBaseConfidence_ThrowsArgumentOutOfRange(double invalidConfidence)
{
// Act & Assert
var act = () => _calculator.Calculate(invalidConfidence, 10.0);
act.Should().Throw<ArgumentOutOfRangeException>();
}
[Fact]
public void Calculate_NegativeAge_ThrowsArgumentOutOfRange()
{
// Act & Assert
var act = () => _calculator.Calculate(0.8, -1.0);
act.Should().Throw<ArgumentOutOfRangeException>();
}
}

View File

@@ -0,0 +1,122 @@
// Copyright © 2025 StellaOps Contributors
// Licensed under AGPL-3.0-or-later
using FluentAssertions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Integration;
[Trait("Category", "Unit")]
public sealed class ServiceRegistrationIntegrationTests
{
[Fact]
public void AddDeterminization_WithConfiguration_RegistersAllServices()
{
// Arrange
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Determinization:ConfidenceHalfLifeDays"] = "21",
["Determinization:ConfidenceFloor"] = "0.15",
["Determinization:ManualReviewEntropyThreshold"] = "0.65",
["Determinization:SignalWeights:VexWeight"] = "0.30",
["Determinization:SignalWeights:EpssWeight"] = "0.15"
})
.Build();
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging();
// Act
services.AddDeterminization(configuration);
var provider = services.BuildServiceProvider();
// Assert - verify all services are registered
provider.GetService<IUncertaintyScoreCalculator>().Should().NotBeNull();
provider.GetService<IDecayedConfidenceCalculator>().Should().NotBeNull();
provider.GetService<TrustScoreAggregator>().Should().NotBeNull();
}
[Fact]
public void AddDeterminization_WithConfigureAction_RegistersAllServices()
{
// Arrange
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging();
// Act
services.AddDeterminization(options =>
{
// Options are immutable records, so can't mutate
// This tests that the configure action is called
});
var provider = services.BuildServiceProvider();
// Assert
provider.GetService<IUncertaintyScoreCalculator>().Should().NotBeNull();
provider.GetService<IDecayedConfidenceCalculator>().Should().NotBeNull();
provider.GetService<TrustScoreAggregator>().Should().NotBeNull();
}
[Fact]
public void RegisteredServices_AreResolvableAndFunctional()
{
// Arrange
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance));
services.AddDeterminization(configuration);
var provider = services.BuildServiceProvider();
// Act - resolve and use services
var uncertaintyCalc = provider.GetRequiredService<IUncertaintyScoreCalculator>();
var decayCalc = provider.GetRequiredService<IDecayedConfidenceCalculator>();
var trustAgg = provider.GetRequiredService<TrustScoreAggregator>();
// Test uncertainty calculator
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
// Assert - verify they work
var score = uncertaintyCalc.Calculate(snapshot);
score.Entropy.Should().Be(1.0); // All signals missing = maximum entropy
var decayed = decayCalc.Calculate(baseConfidence: 0.9, ageDays: 14.0, halfLifeDays: 14.0);
decayed.Should().BeApproximately(0.45, 0.01); // Half-life decay
// Trust aggregator requires an uncertainty score
var trust = trustAgg.Aggregate(snapshot, score);
trust.Should().BeInRange(0.0, 1.0);
}
[Fact]
public void RegisteredServices_AreSingletons()
{
// Arrange
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>()).Build();
var services = new ServiceCollection();
services.AddSingleton(TimeProvider.System);
services.AddLogging();
services.AddDeterminization(configuration);
var provider = services.BuildServiceProvider();
// Act - resolve same service multiple times
var calc1 = provider.GetService<IUncertaintyScoreCalculator>();
var calc2 = provider.GetService<IUncertaintyScoreCalculator>();
// Assert - should be same instance (singleton)
calc1.Should().BeSameAs(calc2);
}
}

View File

@@ -0,0 +1,80 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class DeterminizationResultTests
{
[Fact]
public void Determined_Should_CreateCorrectResult()
{
// Arrange
var uncertainty = UncertaintyScore.Zero(1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Fresh(DateTimeOffset.UtcNow);
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
// Act
var result = DeterminizationResult.Determined(uncertainty, decay, context, evaluatedAt);
// Assert
result.State.Should().Be(ObservationState.Determined);
result.Guardrails.Should().NotBeNull();
result.Guardrails!.EnableMonitoring.Should().BeFalse();
}
[Fact]
public void Pending_Should_ApplyGuardrails()
{
// Arrange
var uncertainty = UncertaintyScore.Create(0.6, Array.Empty<SignalGap>(), 0.4, 1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Fresh(DateTimeOffset.UtcNow);
var guardrails = GuardRails.Strict();
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
// Act
var result = DeterminizationResult.Pending(uncertainty, decay, guardrails, context, evaluatedAt);
// Assert
result.State.Should().Be(ObservationState.PendingDeterminization);
result.Guardrails.Should().NotBeNull();
result.Guardrails!.EnableMonitoring.Should().BeTrue();
}
[Fact]
public void Stale_Should_RequireRefresh()
{
// Arrange
var uncertainty = UncertaintyScore.Zero(1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Create(DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow.AddDays(-30));
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
// Act
var result = DeterminizationResult.Stale(uncertainty, decay, context, evaluatedAt);
// Assert
result.State.Should().Be(ObservationState.StaleRequiresRefresh);
result.Guardrails.Should().NotBeNull();
}
[Fact]
public void Disputed_Should_IncludeReason()
{
// Arrange
var uncertainty = UncertaintyScore.Create(0.7, Array.Empty<SignalGap>(), 0.3, 1.0, DateTimeOffset.UtcNow);
var decay = ObservationDecay.Fresh(DateTimeOffset.UtcNow);
var context = DeterminizationContext.Production();
var evaluatedAt = DateTimeOffset.UtcNow;
var reason = "VEX says not_affected but reachability analysis shows vulnerable path";
// Act
var result = DeterminizationResult.Disputed(uncertainty, decay, context, evaluatedAt, reason);
// Assert
result.State.Should().Be(ObservationState.Disputed);
result.Rationale.Should().Contain(reason);
}
}

View File

@@ -0,0 +1,87 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class ObservationDecayTests
{
[Fact]
public void Fresh_Should_CreateZeroAgeDecay()
{
// Arrange
var now = DateTimeOffset.UtcNow;
// Act
var decay = ObservationDecay.Fresh(now);
// Assert
decay.ObservedAt.Should().Be(now);
decay.RefreshedAt.Should().Be(now);
decay.CalculateDecay(now).Should().Be(1.0);
}
[Fact]
public void CalculateDecay_Should_ApplyHalfLifeFormula()
{
// Arrange
var observedAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var decay = ObservationDecay.Create(observedAt, observedAt);
// After 14 days (one half-life), decay should be ~0.5
var after14Days = observedAt.AddDays(14);
// Act
var decayValue = decay.CalculateDecay(after14Days);
// Assert
decayValue.Should().BeApproximately(0.5, 0.01);
}
[Fact]
public void CalculateDecay_Should_NotDropBelowFloor()
{
// Arrange
var observedAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var decay = ObservationDecay.Create(observedAt, observedAt);
// Very old observation (1 year)
var afterYear = observedAt.AddDays(365);
// Act
var decayValue = decay.CalculateDecay(afterYear);
// Assert
decayValue.Should().BeGreaterThanOrEqualTo(decay.Floor);
}
[Fact]
public void IsStale_Should_DetectStaleObservations()
{
// Arrange
var observedAt = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var decay = ObservationDecay.Create(observedAt, observedAt);
// Decay drops below 0.5 threshold around 14 days
var before = observedAt.AddDays(10);
var after = observedAt.AddDays(20);
// Act & Assert
decay.IsStale(before).Should().BeFalse();
decay.IsStale(after).Should().BeTrue();
}
[Fact]
public void CalculateDecay_Should_ReturnOneForFutureDates()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var decay = ObservationDecay.Fresh(now);
// Act (future date, should not decay)
var futureDecay = decay.CalculateDecay(now.AddDays(-1));
// Assert
futureDecay.Should().Be(1.0);
}
}

View File

@@ -0,0 +1,32 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class SignalSnapshotTests
{
[Fact]
public void Empty_Should_CreateAllNotQueriedSignals()
{
// Arrange
var cve = "CVE-2024-1234";
var purl = "pkg:maven/org.example/lib@1.0.0";
var snapshotAt = DateTimeOffset.UtcNow;
// Act
var snapshot = SignalSnapshot.Empty(cve, purl, snapshotAt);
// Assert
snapshot.Cve.Should().Be(cve);
snapshot.Purl.Should().Be(purl);
snapshot.SnapshotAt.Should().Be(snapshotAt);
snapshot.Epss.IsNotQueried.Should().BeTrue();
snapshot.Vex.IsNotQueried.Should().BeTrue();
snapshot.Reachability.IsNotQueried.Should().BeTrue();
snapshot.Runtime.IsNotQueried.Should().BeTrue();
snapshot.Backport.IsNotQueried.Should().BeTrue();
snapshot.Sbom.IsNotQueried.Should().BeTrue();
snapshot.Cvss.IsNotQueried.Should().BeTrue();
}
}

View File

@@ -0,0 +1,83 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class SignalStateTests
{
[Fact]
public void NotQueried_Should_CreateCorrectState()
{
// Act
var state = SignalState<string>.NotQueried();
// Assert
state.Status.Should().Be(SignalQueryStatus.NotQueried);
state.Value.Should().BeNull();
state.QueriedAt.Should().BeNull();
state.Error.Should().BeNull();
state.IsNotQueried.Should().BeTrue();
state.HasValue.Should().BeFalse();
state.IsFailed.Should().BeFalse();
}
[Fact]
public void Queried_WithValue_Should_CreateCorrectState()
{
// Arrange
var value = "test-value";
var queriedAt = DateTimeOffset.UtcNow;
// Act
var state = SignalState<string>.Queried(value, queriedAt);
// Assert
state.Status.Should().Be(SignalQueryStatus.Queried);
state.Value.Should().Be(value);
state.QueriedAt.Should().Be(queriedAt);
state.Error.Should().BeNull();
state.HasValue.Should().BeTrue();
state.IsNotQueried.Should().BeFalse();
state.IsFailed.Should().BeFalse();
}
[Fact]
public void Queried_WithNull_Should_CreateCorrectState()
{
// Arrange
var queriedAt = DateTimeOffset.UtcNow;
// Act
var state = SignalState<string>.Queried(null, queriedAt);
// Assert
state.Status.Should().Be(SignalQueryStatus.Queried);
state.Value.Should().BeNull();
state.QueriedAt.Should().Be(queriedAt);
state.Error.Should().BeNull();
state.HasValue.Should().BeFalse();
state.IsNotQueried.Should().BeFalse();
state.IsFailed.Should().BeFalse();
}
[Fact]
public void Failed_Should_CreateCorrectState()
{
// Arrange
var error = "Network timeout";
var attemptedAt = DateTimeOffset.UtcNow;
// Act
var state = SignalState<string>.Failed(error, attemptedAt);
// Assert
state.Status.Should().Be(SignalQueryStatus.Failed);
state.Value.Should().BeNull();
state.QueriedAt.Should().Be(attemptedAt);
state.Error.Should().Be(error);
state.IsFailed.Should().BeTrue();
state.HasValue.Should().BeFalse();
state.IsNotQueried.Should().BeFalse();
}
}

View File

@@ -0,0 +1,66 @@
using FluentAssertions;
using StellaOps.Policy.Determinization.Models;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.Models;
public class UncertaintyScoreTests
{
[Theory]
[InlineData(0.0, UncertaintyTier.Minimal)]
[InlineData(0.1, UncertaintyTier.Minimal)]
[InlineData(0.2, UncertaintyTier.Low)]
[InlineData(0.3, UncertaintyTier.Low)]
[InlineData(0.4, UncertaintyTier.Moderate)]
[InlineData(0.5, UncertaintyTier.Moderate)]
[InlineData(0.6, UncertaintyTier.High)]
[InlineData(0.7, UncertaintyTier.High)]
[InlineData(0.8, UncertaintyTier.Critical)]
[InlineData(0.9, UncertaintyTier.Critical)]
[InlineData(1.0, UncertaintyTier.Critical)]
public void Create_Should_MapEntropyToCorrectTier(double entropy, UncertaintyTier expectedTier)
{
// Arrange
var gaps = Array.Empty<SignalGap>();
var calculatedAt = DateTimeOffset.UtcNow;
// Act
var score = UncertaintyScore.Create(entropy, gaps, 1.0, 1.0, calculatedAt);
// Assert
score.Tier.Should().Be(expectedTier);
score.Entropy.Should().Be(entropy);
}
[Fact]
public void Create_Should_ThrowOnInvalidEntropy()
{
// Arrange
var gaps = Array.Empty<SignalGap>();
var calculatedAt = DateTimeOffset.UtcNow;
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(() =>
UncertaintyScore.Create(-0.1, gaps, 1.0, 1.0, calculatedAt));
Assert.Throws<ArgumentOutOfRangeException>(() =>
UncertaintyScore.Create(1.1, gaps, 1.0, 1.0, calculatedAt));
}
[Fact]
public void Zero_Should_CreateMinimalUncertainty()
{
// Arrange
var maxWeight = 1.0;
var calculatedAt = DateTimeOffset.UtcNow;
// Act
var score = UncertaintyScore.Zero(maxWeight, calculatedAt);
// Assert
score.Entropy.Should().Be(0.0);
score.Tier.Should().Be(UncertaintyTier.Minimal);
score.Gaps.Should().BeEmpty();
score.PresentWeight.Should().Be(maxWeight);
score.MaxWeight.Should().Be(maxWeight);
}
}

View File

@@ -0,0 +1,245 @@
// -----------------------------------------------------------------------------
// DecayPropertyTests.cs
// Sprint: SPRINT_20260106_001_002_LB_determinization_scoring
// Task: DCS-022 - Write property tests: decay monotonically decreasing
// Description: Property-based tests ensuring decay is monotonically decreasing
// as age increases.
// -----------------------------------------------------------------------------
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Policy.Determinization.Scoring;
using Xunit;
namespace StellaOps.Policy.Determinization.Tests.PropertyTests;
/// <summary>
/// Property tests verifying decay behavior.
/// DCS-022: decay must be monotonically decreasing as age increases.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Property", "DecayMonotonicity")]
public class DecayPropertyTests
{
private readonly DecayedConfidenceCalculator _calculator;
public DecayPropertyTests()
{
_calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
}
/// <summary>
/// Property: Decay is monotonically decreasing as age increases.
/// For any a1 less than a2, decay(a1) >= decay(a2).
/// </summary>
[Theory]
[InlineData(0, 1)]
[InlineData(1, 7)]
[InlineData(7, 14)]
[InlineData(14, 28)]
[InlineData(28, 90)]
[InlineData(90, 365)]
public void Decay_AsAgeIncreases_NeverIncreases(int youngerDays, int olderDays)
{
// Arrange
var halfLifeDays = 14.0;
// Act
var youngerDecay = _calculator.CalculateDecayFactor(youngerDays, halfLifeDays);
var olderDecay = _calculator.CalculateDecayFactor(olderDays, halfLifeDays);
// Assert
youngerDecay.Should().BeGreaterThanOrEqualTo(olderDecay,
$"decay at age {youngerDays}d should be >= decay at age {olderDays}d");
}
/// <summary>
/// Property: At age 0, decay is exactly 1.0.
/// </summary>
[Theory]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
[InlineData(90)]
public void Decay_AtAgeZero_IsOne(double halfLifeDays)
{
// Act
var decay = _calculator.CalculateDecayFactor(0, halfLifeDays);
// Assert
decay.Should().Be(1.0, "decay at age 0 should be 1.0");
}
/// <summary>
/// Property: At age = half-life, decay is approximately 0.5.
/// </summary>
[Theory]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
[InlineData(90)]
public void Decay_AtHalfLife_IsApproximatelyHalf(double halfLifeDays)
{
// Act
var decay = _calculator.CalculateDecayFactor(halfLifeDays, halfLifeDays);
// Assert
decay.Should().BeApproximately(0.5, 0.01,
$"decay at half-life ({halfLifeDays}d) should be ~0.5");
}
/// <summary>
/// Property: At age = 2 * half-life, decay is approximately 0.25.
/// </summary>
[Theory]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
public void Decay_AtTwoHalfLives_IsApproximatelyQuarter(double halfLifeDays)
{
// Act
var decay = _calculator.CalculateDecayFactor(halfLifeDays * 2, halfLifeDays);
// Assert
decay.Should().BeApproximately(0.25, 0.01,
$"decay at 2x half-life ({halfLifeDays * 2}d) should be ~0.25");
}
/// <summary>
/// Property: Decay is always in (0, 1] for non-negative age.
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(7)]
[InlineData(14)]
[InlineData(30)]
[InlineData(90)]
[InlineData(365)]
[InlineData(1000)]
public void Decay_ForAnyNonNegativeAge_IsBetweenZeroAndOne(double ageDays)
{
// Arrange
var halfLifeDays = 14.0;
// Act
var decay = _calculator.CalculateDecayFactor(ageDays, halfLifeDays);
// Assert
decay.Should().BeGreaterThan(0.0, "decay should never reach 0");
decay.Should().BeLessThanOrEqualTo(1.0, "decay should never exceed 1");
}
/// <summary>
/// Property: Calculate() with floor ensures result never goes below floor.
/// </summary>
[Theory]
[InlineData(0.01)]
[InlineData(0.05)]
[InlineData(0.1)]
public void Calculate_AtExtremeAge_NeverGoesBelowFloor(double floor)
{
// Arrange - very old observation (10 years)
var ageDays = 3650;
var halfLifeDays = 14.0;
var baseConfidence = 1.0;
// Act - using Calculate which applies floor
var decayed = _calculator.Calculate(baseConfidence, ageDays, halfLifeDays, floor);
// Assert
decayed.Should().BeGreaterThanOrEqualTo(floor,
$"decayed confidence should never go below floor {floor}");
}
/// <summary>
/// Property: Raw decay factor can approach but never go below 0.
/// </summary>
[Fact]
public void DecayFactor_AtExtremeAge_ApproachesZeroButNeverNegative()
{
// Arrange - very old observation (10 years)
var ageDays = 3650;
var halfLifeDays = 14.0;
// Act
var decayFactor = _calculator.CalculateDecayFactor(ageDays, halfLifeDays);
// Assert
decayFactor.Should().BeGreaterThanOrEqualTo(0.0, "decay factor should never be negative");
decayFactor.Should().BeLessThanOrEqualTo(1.0, "decay factor should never exceed 1");
}
/// <summary>
/// Property: Sequence of consecutive days has strictly decreasing decay.
/// </summary>
[Fact]
public void Decay_ConsecutiveDays_StrictlyDecreasing()
{
// Arrange
var halfLifeDays = 14.0;
var previousDecay = double.MaxValue;
// Act & Assert - check 100 consecutive days
for (var day = 0; day < 100; day++)
{
var currentDecay = _calculator.CalculateDecayFactor(day, halfLifeDays);
if (day > 0)
{
currentDecay.Should().BeLessThan(previousDecay,
$"decay at day {day} should be less than day {day - 1}");
}
previousDecay = currentDecay;
}
}
/// <summary>
/// Property: Shorter half-life decays faster than longer half-life.
/// </summary>
[Theory]
[InlineData(7, 14)]
[InlineData(14, 30)]
[InlineData(30, 90)]
public void Decay_ShorterHalfLife_DecaysFaster(double shortHalfLife, double longHalfLife)
{
// Arrange - use an age greater than both half-lives
var ageDays = Math.Max(shortHalfLife, longHalfLife) * 2;
// Act
var shortDecay = _calculator.CalculateDecayFactor(ageDays, shortHalfLife);
var longDecay = _calculator.CalculateDecayFactor(ageDays, longHalfLife);
// Assert
shortDecay.Should().BeLessThan(longDecay,
$"shorter half-life ({shortHalfLife}d) should decay faster than longer ({longHalfLife}d)");
}
/// <summary>
/// Property: Zero or negative half-life should not crash (edge case).
/// </summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(-14)]
public void Decay_WithInvalidHalfLife_DoesNotThrowOrReturnsReasonableValue(double halfLifeDays)
{
// Act
var act = () => _calculator.CalculateDecayFactor(7, halfLifeDays);
// Assert - implementation may throw or return clamped value
// We just verify it doesn't crash with unhandled exception
try
{
var result = act();
// If it returns a value, it should still be bounded
result.Should().BeGreaterThanOrEqualTo(0.0);
result.Should().BeLessThanOrEqualTo(1.0);
}
catch (ArgumentException)
{
// This is acceptable - throwing for invalid input is valid behavior
}
}
}

View File

@@ -0,0 +1,275 @@
// -----------------------------------------------------------------------------
// DeterminismPropertyTests.cs
// Sprint: SPRINT_20260106_001_002_LB_determinization_scoring
// Task: DCS-023 - Write determinism tests: same snapshot same entropy
// Description: Property-based tests ensuring identical inputs produce identical
// outputs across multiple invocations and calculator instances.
// -----------------------------------------------------------------------------
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.PropertyTests;
/// <summary>
/// Property tests verifying determinism.
/// DCS-023: same inputs must yield same outputs, always.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Property", "Determinism")]
public class DeterminismPropertyTests
{
private readonly DateTimeOffset _fixedTime = new(2026, 1, 7, 12, 0, 0, TimeSpan.Zero);
/// <summary>
/// Property: Same snapshot produces same entropy on repeated calls.
/// </summary>
[Fact]
public void Entropy_SameSnapshot_ProducesSameResult()
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
var snapshot = CreateDeterministicSnapshot();
// Act - calculate 10 times
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
results.Add(calculator.CalculateEntropy(snapshot));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "same input should always produce same entropy");
}
/// <summary>
/// Property: Different calculator instances produce same entropy for same snapshot.
/// </summary>
[Fact]
public void Entropy_DifferentInstances_ProduceSameResult()
{
// Arrange
var snapshot = CreateDeterministicSnapshot();
// Act - create multiple instances and calculate
var results = new List<double>();
for (var i = 0; i < 5; i++)
{
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
results.Add(calculator.CalculateEntropy(snapshot));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "different instances should produce same entropy for same input");
}
/// <summary>
/// Property: Parallel execution produces consistent results.
/// </summary>
[Fact]
public void Entropy_ParallelExecution_ProducesConsistentResults()
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
var snapshot = CreateDeterministicSnapshot();
// Act - calculate in parallel
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() => calculator.CalculateEntropy(snapshot)))
.ToArray();
Task.WaitAll(tasks);
var results = tasks.Select(t => t.Result).ToList();
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "parallel execution should produce consistent results");
}
/// <summary>
/// Property: Same decay calculation produces same result.
/// </summary>
[Fact]
public void Decay_SameInputs_ProducesSameResult()
{
// Arrange
var calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
var ageDays = 7.0;
var halfLifeDays = 14.0;
// Act - calculate 10 times
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
results.Add(calculator.CalculateDecayFactor(ageDays, halfLifeDays));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "same input should always produce same decay factor");
}
/// <summary>
/// Property: Same snapshot with same weights produces same entropy.
/// </summary>
[Theory]
[InlineData(0.25, 0.15, 0.25, 0.15, 0.10, 0.10)]
[InlineData(0.30, 0.20, 0.20, 0.10, 0.10, 0.10)]
[InlineData(0.16, 0.16, 0.16, 0.16, 0.18, 0.18)]
public void Entropy_SameSnapshotSameWeights_ProducesSameResult(
double vex, double epss, double reach, double runtime, double backport, double sbom)
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
var snapshot = CreateDeterministicSnapshot();
var weights = new SignalWeights
{
VexWeight = vex,
EpssWeight = epss,
ReachabilityWeight = reach,
RuntimeWeight = runtime,
BackportWeight = backport,
SbomLineageWeight = sbom
};
// Act - calculate 5 times
var results = new List<double>();
for (var i = 0; i < 5; i++)
{
results.Add(calculator.CalculateEntropy(snapshot, weights));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "same snapshot + weights should always produce same entropy");
}
/// <summary>
/// Property: Order of snapshot construction doesn't affect entropy.
/// </summary>
[Fact]
public void Entropy_EquivalentSnapshots_ProduceSameResult()
{
// Arrange
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
// Create two snapshots with same values but constructed differently
var snapshot1 = CreateSnapshotWithVexFirst();
var snapshot2 = CreateSnapshotWithEpssFirst();
// Act
var entropy1 = calculator.CalculateEntropy(snapshot1);
var entropy2 = calculator.CalculateEntropy(snapshot2);
// Assert
entropy1.Should().Be(entropy2, "equivalent snapshots should produce identical entropy");
}
/// <summary>
/// Property: Decay with floor is deterministic.
/// </summary>
[Theory]
[InlineData(1.0, 30, 14.0, 0.1)]
[InlineData(0.8, 7, 7.0, 0.05)]
[InlineData(0.5, 100, 30.0, 0.2)]
public void Decay_WithFloor_IsDeterministic(double baseConfidence, int ageDays, double halfLifeDays, double floor)
{
// Arrange
var calculator = new DecayedConfidenceCalculator(NullLogger<DecayedConfidenceCalculator>.Instance);
// Act - calculate 10 times
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
results.Add(calculator.Calculate(baseConfidence, ageDays, halfLifeDays, floor));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "decay with floor should be deterministic");
}
/// <summary>
/// Property: Entropy calculation is independent of external state.
/// </summary>
[Fact]
public void Entropy_IndependentOfGlobalState_ProducesConsistentResults()
{
// Arrange
var snapshot = CreateDeterministicSnapshot();
// Act - interleave calculations with some "noise"
var results = new List<double>();
for (var i = 0; i < 10; i++)
{
// Create new calculator each time to verify no shared state issues
var calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
// Do some unrelated operations
_ = Guid.NewGuid();
_ = DateTime.UtcNow;
results.Add(calculator.CalculateEntropy(snapshot));
}
// Assert - all results should be identical
results.Distinct().Should().HaveCount(1, "entropy should be independent of external state");
}
#region Helper Methods
private SignalSnapshot CreateDeterministicSnapshot()
{
return new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:test@1.0.0",
Vex = SignalState<VexClaimSummary>.Queried(
new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _fixedTime },
_fixedTime),
Epss = SignalState<EpssEvidence>.Queried(
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _fixedTime },
_fixedTime),
Reachability = SignalState<ReachabilityEvidence>.Queried(
new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = _fixedTime },
_fixedTime),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
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 = _fixedTime },
_fixedTime),
SnapshotAt = _fixedTime
};
}
private SignalSnapshot CreateSnapshotWithVexFirst()
{
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _fixedTime);
return snapshot with
{
Vex = SignalState<VexClaimSummary>.Queried(
new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _fixedTime },
_fixedTime),
Epss = SignalState<EpssEvidence>.Queried(
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _fixedTime },
_fixedTime)
};
}
private SignalSnapshot CreateSnapshotWithEpssFirst()
{
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _fixedTime);
return snapshot with
{
Epss = SignalState<EpssEvidence>.Queried(
new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _fixedTime },
_fixedTime),
Vex = SignalState<VexClaimSummary>.Queried(
new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _fixedTime },
_fixedTime)
};
}
#endregion
}

View File

@@ -0,0 +1,289 @@
// -----------------------------------------------------------------------------
// EntropyPropertyTests.cs
// Sprint: SPRINT_20260106_001_002_LB_determinization_scoring
// Task: DCS-021 - Write property tests: entropy always [0.0, 1.0]
// Description: Property-based tests ensuring entropy is always within bounds
// regardless of signal combinations or weight configurations.
// -----------------------------------------------------------------------------
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.PropertyTests;
/// <summary>
/// Property tests verifying entropy bounds.
/// DCS-021: entropy must always be in [0.0, 1.0] regardless of inputs.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Property", "EntropyBounds")]
public class EntropyPropertyTests
{
private readonly UncertaintyScoreCalculator _calculator;
private readonly DateTimeOffset _now = DateTimeOffset.UtcNow;
public EntropyPropertyTests()
{
_calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
}
/// <summary>
/// Property: For any combination of signal states, entropy is in [0.0, 1.0].
/// </summary>
[Theory]
[MemberData(nameof(AllSignalCombinations))]
public void Entropy_ForAnySignalCombination_IsWithinBounds(
bool hasVex, bool hasEpss, bool hasReach, bool hasRuntime, bool hasBackport, bool hasSbom)
{
// Arrange
var snapshot = CreateSnapshot(hasVex, hasEpss, hasReach, hasRuntime, hasBackport, hasSbom);
// Act
var entropy = _calculator.CalculateEntropy(snapshot);
// Assert
entropy.Should().BeGreaterThanOrEqualTo(0.0, "entropy must be >= 0.0");
entropy.Should().BeLessThanOrEqualTo(1.0, "entropy must be <= 1.0");
}
/// <summary>
/// Property: Entropy with zero weights should not throw and return 0.0.
/// </summary>
[Fact]
public void Entropy_WithZeroWeights_ReturnsZeroWithoutDivisionByZero()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _now);
// Note: Zero weights would cause division by zero; test with very small weights instead
var nearZeroWeights = new SignalWeights
{
VexWeight = 0.000001,
EpssWeight = 0.000001,
ReachabilityWeight = 0.000001,
RuntimeWeight = 0.000001,
BackportWeight = 0.000001,
SbomLineageWeight = 0.000001
};
// Act
var act = () => _calculator.CalculateEntropy(snapshot, nearZeroWeights);
// Assert - should not throw, and result should be bounded
var entropy = act.Should().NotThrow().Subject;
// Note: 0/0 edge case - implementation may return NaN, 0, or 1
// The clamp ensures it's always in bounds if not NaN
if (!double.IsNaN(entropy))
{
entropy.Should().BeGreaterThanOrEqualTo(0.0);
entropy.Should().BeLessThanOrEqualTo(1.0);
}
}
/// <summary>
/// Property: Entropy with extreme weights still produces bounded result.
/// </summary>
[Theory]
[InlineData(0.0001, 0.0001, 0.0001, 0.0001, 0.0001, 0.0001)]
[InlineData(1000.0, 1000.0, 1000.0, 1000.0, 1000.0, 1000.0)]
[InlineData(0.0, 0.0, 0.0, 0.0, 0.0, 1.0)]
[InlineData(1.0, 0.0, 0.0, 0.0, 0.0, 0.0)]
public void Entropy_WithExtremeWeights_IsWithinBounds(
double vex, double epss, double reach, double runtime, double backport, double sbom)
{
// Arrange
var snapshot = CreateFullSnapshot();
var weights = new SignalWeights
{
VexWeight = vex,
EpssWeight = epss,
ReachabilityWeight = reach,
RuntimeWeight = runtime,
BackportWeight = backport,
SbomLineageWeight = sbom
};
// Act
var entropy = _calculator.CalculateEntropy(snapshot, weights);
// Assert
if (!double.IsNaN(entropy))
{
entropy.Should().BeGreaterThanOrEqualTo(0.0);
entropy.Should().BeLessThanOrEqualTo(1.0);
}
}
/// <summary>
/// Property: All signals present yields entropy = 0.0.
/// </summary>
[Fact]
public void Entropy_AllSignalsPresent_IsZero()
{
// Arrange
var snapshot = CreateFullSnapshot();
// Act
var entropy = _calculator.CalculateEntropy(snapshot);
// Assert
entropy.Should().Be(0.0, "all signals present should yield minimal entropy");
}
/// <summary>
/// Property: No signals present yields entropy = 1.0.
/// </summary>
[Fact]
public void Entropy_NoSignalsPresent_IsOne()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _now);
// Act
var entropy = _calculator.CalculateEntropy(snapshot);
// Assert
entropy.Should().Be(1.0, "no signals present should yield maximum entropy");
}
/// <summary>
/// Property: Adding a signal never increases entropy.
/// </summary>
[Theory]
[InlineData("vex")]
[InlineData("epss")]
[InlineData("reachability")]
[InlineData("runtime")]
[InlineData("backport")]
[InlineData("sbom")]
public void Entropy_AddingSignal_NeverIncreasesEntropy(string signalToAdd)
{
// Arrange
var baseSnapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:test@1.0", _now);
var baseEntropy = _calculator.CalculateEntropy(baseSnapshot);
// Act - add one signal
var snapshotWithSignal = AddSignal(baseSnapshot, signalToAdd);
var newEntropy = _calculator.CalculateEntropy(snapshotWithSignal);
// Assert
newEntropy.Should().BeLessThanOrEqualTo(baseEntropy,
$"adding signal '{signalToAdd}' should not increase entropy");
}
/// <summary>
/// Property: Removing a signal never decreases entropy.
/// </summary>
[Theory]
[InlineData("vex")]
[InlineData("epss")]
[InlineData("reachability")]
[InlineData("runtime")]
[InlineData("backport")]
[InlineData("sbom")]
public void Entropy_RemovingSignal_NeverDecreasesEntropy(string signalToRemove)
{
// Arrange
var fullSnapshot = CreateFullSnapshot();
var fullEntropy = _calculator.CalculateEntropy(fullSnapshot);
// Act - remove one signal
var snapshotWithoutSignal = RemoveSignal(fullSnapshot, signalToRemove);
var newEntropy = _calculator.CalculateEntropy(snapshotWithoutSignal);
// Assert
newEntropy.Should().BeGreaterThanOrEqualTo(fullEntropy,
$"removing signal '{signalToRemove}' should not decrease entropy");
}
#region Test Data Generators
public static IEnumerable<object[]> AllSignalCombinations()
{
// Generate all 64 combinations of 6 boolean flags
for (var i = 0; i < 64; i++)
{
yield return new object[]
{
(i & 1) != 0, // hasVex
(i & 2) != 0, // hasEpss
(i & 4) != 0, // hasReach
(i & 8) != 0, // hasRuntime
(i & 16) != 0, // hasBackport
(i & 32) != 0 // hasSbom
};
}
}
#endregion
#region Helper Methods
private SignalSnapshot CreateSnapshot(
bool hasVex, bool hasEpss, bool hasReach, bool hasRuntime, bool hasBackport, bool hasSbom)
{
return new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:test@1.0",
Vex = hasVex
? SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _now }, _now)
: SignalState<VexClaimSummary>.NotQueried(),
Epss = hasEpss
? SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _now }, _now)
: SignalState<EpssEvidence>.NotQueried(),
Reachability = hasReach
? SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = _now }, _now)
: SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = hasRuntime
? SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = true, Source = "test", ObservationStart = _now.AddDays(-7), ObservationEnd = _now, Confidence = 0.9 }, _now)
: SignalState<RuntimeEvidence>.NotQueried(),
Backport = hasBackport
? SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = false, Source = "test", DetectedAt = _now, Confidence = 0.85 }, _now)
: SignalState<BackportEvidence>.NotQueried(),
Sbom = hasSbom
? SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = _now, HasProvenance = true }, _now)
: SignalState<SbomLineageEvidence>.NotQueried(),
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
};
}
private SignalSnapshot CreateFullSnapshot()
{
return CreateSnapshot(true, true, true, true, true, true);
}
private SignalSnapshot AddSignal(SignalSnapshot snapshot, string signal)
{
return signal switch
{
"vex" => snapshot with { Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "affected", Confidence = 0.9, StatementCount = 1, ComputedAt = _now }, _now) },
"epss" => snapshot with { Epss = SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.5, Percentile = 0.8, PublishedAt = _now }, _now) },
"reachability" => snapshot with { Reachability = SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Reachable, AnalyzedAt = _now }, _now) },
"runtime" => snapshot with { Runtime = SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = true, Source = "test", ObservationStart = _now.AddDays(-7), ObservationEnd = _now, Confidence = 0.9 }, _now) },
"backport" => snapshot with { Backport = SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = false, Source = "test", DetectedAt = _now, Confidence = 0.85 }, _now) },
"sbom" => snapshot with { Sbom = SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 150, GeneratedAt = _now, HasProvenance = true }, _now) },
_ => snapshot
};
}
private SignalSnapshot RemoveSignal(SignalSnapshot snapshot, string signal)
{
return signal switch
{
"vex" => snapshot with { Vex = SignalState<VexClaimSummary>.NotQueried() },
"epss" => snapshot with { Epss = SignalState<EpssEvidence>.NotQueried() },
"reachability" => snapshot with { Reachability = SignalState<ReachabilityEvidence>.NotQueried() },
"runtime" => snapshot with { Runtime = SignalState<RuntimeEvidence>.NotQueried() },
"backport" => snapshot with { Backport = SignalState<BackportEvidence>.NotQueried() },
"sbom" => snapshot with { Sbom = SignalState<SbomLineageEvidence>.NotQueried() },
_ => snapshot
};
}
#endregion
}

View File

@@ -0,0 +1,26 @@
<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" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Policy.Determinization\StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,122 @@
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;
public class TrustScoreAggregatorTests
{
private readonly TrustScoreAggregator _aggregator;
public TrustScoreAggregatorTests()
{
_aggregator = new TrustScoreAggregator(NullLogger<TrustScoreAggregator>.Instance);
}
[Fact]
public void Aggregate_AllAffectedSignals_ReturnsHighScore()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = 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.9, Percentile = 0.95, 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>.NotQueried(),
SnapshotAt = now
};
var uncertaintyScore = UncertaintyScore.Create(0.1, new List<SignalGap>(), 0.9, 1.0, now);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert
score.Should().BeGreaterThan(0.7);
}
[Fact]
public void Aggregate_AllNotAffectedSignals_ReturnsLowScore()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = new SignalSnapshot
{
Cve = "CVE-2024-1234",
Purl = "pkg:maven/test@1.0",
Vex = SignalState<VexClaimSummary>.Queried(new VexClaimSummary { Status = "not_affected", Confidence = 0.88, StatementCount = 2, ComputedAt = now }, now),
Epss = SignalState<EpssEvidence>.Queried(new EpssEvidence { Cve = "CVE-2024-1234", Epss = 0.01, Percentile = 0.1, PublishedAt = now }, now),
Reachability = SignalState<ReachabilityEvidence>.Queried(new ReachabilityEvidence { Status = ReachabilityStatus.Unreachable, AnalyzedAt = now }, now),
Runtime = SignalState<RuntimeEvidence>.Queried(new RuntimeEvidence { Detected = false, Source = "test", ObservationStart = now.AddDays(-7), ObservationEnd = now, Confidence = 0.92 }, now),
Backport = SignalState<BackportEvidence>.Queried(new BackportEvidence { Detected = true, Source = "test", DetectedAt = now, Confidence = 0.95 }, now),
Sbom = SignalState<SbomLineageEvidence>.Queried(new SbomLineageEvidence { SbomDigest = "sha256:abc", Format = "SPDX", ComponentCount = 200, GeneratedAt = now, HasProvenance = false }, now),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = now
};
var uncertaintyScore = UncertaintyScore.Create(0.1, new List<SignalGap>(), 0.9, 1.0, now);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert
score.Should().BeLessThan(0.2);
}
[Fact]
public void Aggregate_NoSignals_ReturnsNeutralScorePenalized()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
var gaps = new List<SignalGap>
{
new() { Signal = "VEX", Reason = SignalGapReason.NotQueried, Weight = 0.25 }
};
var uncertaintyScore = UncertaintyScore.Create(0.8, gaps, 0.2, 1.0, DateTimeOffset.UtcNow);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert
score.Should().BeApproximately(0.1, 0.05); // 0.5 * (1 - 0.8) = 0.1
}
[Fact]
public void Aggregate_HighUncertainty_PenalizesScore()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = 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>.NotQueried(),
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = now
};
var gaps = new List<SignalGap>
{
new() { Signal = "EPSS", Reason = SignalGapReason.NotQueried, Weight = 0.15 },
new() { Signal = "Reachability", Reason = SignalGapReason.NotQueried, Weight = 0.25 }
};
var uncertaintyScore = UncertaintyScore.Create(0.75, gaps, 0.25, 1.0, now);
// Act
var score = _aggregator.Aggregate(snapshot, uncertaintyScore);
// Assert - high uncertainty should significantly reduce the score
score.Should().BeLessThan(0.5);
}
}

View File

@@ -0,0 +1,114 @@
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;
public class UncertaintyScoreCalculatorTests
{
private readonly UncertaintyScoreCalculator _calculator;
public UncertaintyScoreCalculatorTests()
{
_calculator = new UncertaintyScoreCalculator(NullLogger<UncertaintyScoreCalculator>.Instance);
}
[Fact]
public void Calculate_AllSignalsPresent_ReturnsMinimalEntropy()
{
// Arrange
var snapshot = CreateFullSnapshot();
// Act
var score = _calculator.Calculate(snapshot);
// Assert
score.Entropy.Should().Be(0.0);
score.Tier.Should().Be(UncertaintyTier.Minimal);
}
[Fact]
public void Calculate_NoSignalsPresent_ReturnsCriticalEntropy()
{
// Arrange
var snapshot = SignalSnapshot.Empty("CVE-2024-1234", "pkg:maven/test@1.0", DateTimeOffset.UtcNow);
// Act
var score = _calculator.Calculate(snapshot);
// Assert
score.Entropy.Should().Be(1.0);
score.Tier.Should().Be(UncertaintyTier.Critical);
score.Gaps.Should().HaveCount(6);
}
[Fact]
public void Calculate_HalfSignalsPresent_ReturnsModerateEntropy()
{
// Arrange
var now = DateTimeOffset.UtcNow;
var snapshot = 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>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = now
};
// Act
var score = _calculator.Calculate(snapshot);
// Assert (VEX=0.25 + EPSS=0.15 + Reach=0.25 = 0.65 present, entropy = 1 - 0.65 = 0.35)
score.Entropy.Should().BeApproximately(0.35, 0.01);
score.Tier.Should().Be(UncertaintyTier.Low);
}
[Fact]
public void CalculateEntropy_CustomWeights_UsesProvidedWeights()
{
// Arrange
var snapshot = CreateFullSnapshot();
var customWeights = new SignalWeights
{
VexWeight = 0.5,
EpssWeight = 0.3,
ReachabilityWeight = 0.1,
RuntimeWeight = 0.05,
BackportWeight = 0.03,
SbomLineageWeight = 0.02
};
// Act
var entropy = _calculator.CalculateEntropy(snapshot, customWeights);
// Assert
entropy.Should().Be(0.0);
}
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, 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
};
}
}

View File

@@ -0,0 +1,222 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Gates;
using StellaOps.Policy.Engine.Gates.Determinization;
using StellaOps.Policy.Engine.Policies;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
namespace StellaOps.Policy.Engine.Tests.Gates.Determinization;
public class DeterminizationGateTests
{
private readonly Mock<ISignalSnapshotBuilder> _snapshotBuilderMock;
private readonly Mock<IUncertaintyScoreCalculator> _uncertaintyCalculatorMock;
private readonly Mock<IDecayedConfidenceCalculator> _decayCalculatorMock;
private readonly Mock<TrustScoreAggregator> _trustAggregatorMock;
private readonly DeterminizationGate _gate;
public DeterminizationGateTests()
{
_snapshotBuilderMock = new Mock<ISignalSnapshotBuilder>();
_uncertaintyCalculatorMock = new Mock<IUncertaintyScoreCalculator>();
_decayCalculatorMock = new Mock<IDecayedConfidenceCalculator>();
_trustAggregatorMock = new Mock<TrustScoreAggregator>();
var options = Options.Create(new DeterminizationOptions());
var policy = new DeterminizationPolicy(options, NullLogger<DeterminizationPolicy>.Instance);
_gate = new DeterminizationGate(
policy,
_uncertaintyCalculatorMock.Object,
_decayCalculatorMock.Object,
_trustAggregatorMock.Object,
_snapshotBuilderMock.Object,
NullLogger<DeterminizationGate>.Instance);
}
[Fact]
public async Task EvaluateAsync_BuildsCorrectMetadata()
{
// Arrange
var snapshot = CreateSnapshot();
var uncertaintyScore = new UncertaintyScore
{
Entropy = 0.45,
Tier = UncertaintyTier.Moderate,
Completeness = 0.55,
MissingSignals = []
};
_snapshotBuilderMock
.Setup(x => x.BuildAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(snapshot);
_uncertaintyCalculatorMock
.Setup(x => x.Calculate(It.IsAny<SignalSnapshot>()))
.Returns(uncertaintyScore);
_decayCalculatorMock
.Setup(x => x.Calculate(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>()))
.Returns(0.85);
_trustAggregatorMock
.Setup(x => x.Aggregate(It.IsAny<SignalSnapshot>(), It.IsAny<UncertaintyScore>()))
.Returns(0.7);
var context = new PolicyGateContext
{
CveId = "CVE-2024-0001",
SubjectKey = "pkg:npm/test@1.0.0",
Environment = "development"
};
var mergeResult = new MergeResult
{
FinalScore = 0.5,
FinalTrustLevel = TrustLevel.Medium,
Claims = []
};
// Act
var result = await _gate.EvaluateAsync(mergeResult, context);
// Assert
result.Details.Should().ContainKey("uncertainty_entropy");
result.Details["uncertainty_entropy"].Should().Be(0.45);
result.Details.Should().ContainKey("uncertainty_tier");
result.Details["uncertainty_tier"].Should().Be("Moderate");
result.Details.Should().ContainKey("uncertainty_completeness");
result.Details["uncertainty_completeness"].Should().Be(0.55);
result.Details.Should().ContainKey("trust_score");
result.Details["trust_score"].Should().Be(0.7);
result.Details.Should().ContainKey("decay_multiplier");
result.Details.Should().ContainKey("decay_is_stale");
result.Details.Should().ContainKey("decay_age_days");
}
[Fact]
public async Task EvaluateAsync_WithGuardRails_IncludesGuardrailsMetadata()
{
// Arrange
var snapshot = CreateSnapshot();
var uncertaintyScore = new UncertaintyScore
{
Entropy = 0.5,
Tier = UncertaintyTier.Moderate,
Completeness = 0.5,
MissingSignals = []
};
_snapshotBuilderMock
.Setup(x => x.BuildAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(snapshot);
_uncertaintyCalculatorMock
.Setup(x => x.Calculate(It.IsAny<SignalSnapshot>()))
.Returns(uncertaintyScore);
_decayCalculatorMock
.Setup(x => x.Calculate(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>()))
.Returns(0.85);
_trustAggregatorMock
.Setup(x => x.Aggregate(It.IsAny<SignalSnapshot>(), It.IsAny<UncertaintyScore>()))
.Returns(0.3);
var context = new PolicyGateContext
{
CveId = "CVE-2024-0001",
SubjectKey = "pkg:npm/test@1.0.0",
Environment = "development"
};
var mergeResult = new MergeResult
{
FinalScore = 0.5,
FinalTrustLevel = TrustLevel.Medium,
Claims = []
};
// Act
var result = await _gate.EvaluateAsync(mergeResult, context);
// Assert
result.Details.Should().ContainKey("guardrails_monitoring");
result.Details.Should().ContainKey("guardrails_reeval_after");
}
[Fact]
public async Task EvaluateAsync_WithMatchedRule_IncludesRuleName()
{
// Arrange
var snapshot = CreateSnapshot();
var uncertaintyScore = new UncertaintyScore
{
Entropy = 0.2,
Tier = UncertaintyTier.Low,
Completeness = 0.8,
MissingSignals = []
};
_snapshotBuilderMock
.Setup(x => x.BuildAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(snapshot);
_uncertaintyCalculatorMock
.Setup(x => x.Calculate(It.IsAny<SignalSnapshot>()))
.Returns(uncertaintyScore);
_decayCalculatorMock
.Setup(x => x.Calculate(It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>(), It.IsAny<double>()))
.Returns(0.9);
_trustAggregatorMock
.Setup(x => x.Aggregate(It.IsAny<SignalSnapshot>(), It.IsAny<UncertaintyScore>()))
.Returns(0.8);
var context = new PolicyGateContext
{
CveId = "CVE-2024-0001",
SubjectKey = "pkg:npm/test@1.0.0",
Environment = "production"
};
var mergeResult = new MergeResult
{
FinalScore = 0.8,
FinalTrustLevel = TrustLevel.High,
Claims = []
};
// Act
var result = await _gate.EvaluateAsync(mergeResult, context);
// Assert
result.Details.Should().ContainKey("matched_rule");
result.Details["matched_rule"].Should().NotBeNull();
}
private static SignalSnapshot CreateSnapshot() => new()
{
Cve = "CVE-2024-0001",
Purl = "pkg:npm/test@1.0.0",
Epss = SignalState<EpssEvidence>.NotQueried(),
Vex = SignalState<VexClaimSummary>.NotQueried(),
Reachability = SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = DateTimeOffset.UtcNow
};
}

View File

@@ -0,0 +1,543 @@
// -----------------------------------------------------------------------------
// FacetQuotaGateIntegrationTests.cs
// Sprint: SPRINT_20260105_002_003_FACET (QTA-015)
// Task: QTA-015 - Integration tests for facet quota gate pipeline
// Description: End-to-end tests for facet drift detection and quota enforcement
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Facet;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Gates;
/// <summary>
/// Integration tests for the facet quota gate pipeline.
/// Tests end-to-end flow from drift reports through gate evaluation.
/// </summary>
[Trait("Category", "Integration")]
public sealed class FacetQuotaGateIntegrationTests
{
private readonly InMemoryFacetSealStore _sealStore;
private readonly Mock<IFacetDriftDetector> _driftDetector;
private readonly FacetSealer _sealer;
public FacetQuotaGateIntegrationTests()
{
_sealStore = new InMemoryFacetSealStore();
_driftDetector = new Mock<IFacetDriftDetector>();
_sealer = new FacetSealer();
}
#region Full Pipeline Tests
[Fact]
public async Task FullPipeline_FirstScan_NoBaseline_PassesWithWarning()
{
// Arrange: No baseline seal exists
var options = new FacetQuotaGateOptions
{
Enabled = true,
NoSealAction = NoSealAction.Warn
};
var gate = CreateGate(options);
var context = new PolicyGateContext { Environment = "production" };
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("no_baseline_seal");
result.Details.Should().ContainKey("action");
result.Details["action"].Should().Be("warn");
}
[Fact]
public async Task FullPipeline_WithBaseline_NoDrift_Passes()
{
// Arrange: Create baseline seal
var imageDigest = "sha256:abc123";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
// Setup drift detector to return no drift
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.Ok);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions { Enabled = true };
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("quota_ok");
}
[Fact]
public async Task FullPipeline_ExceedWarningThreshold_PassesWithWarning()
{
// Arrange
var imageDigest = "sha256:def456";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.Warning);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultMaxChurnPercent = 10.0m
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("quota_warning");
result.Details.Should().ContainKey("breachedFacets");
}
[Fact]
public async Task FullPipeline_ExceedBlockThreshold_Blocks()
{
// Arrange
var imageDigest = "sha256:ghi789";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.Blocked);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultAction = QuotaExceededAction.Block
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeFalse();
result.Reason.Should().Be("quota_exceeded");
}
[Fact]
public async Task FullPipeline_RequiresVex_BlocksUntilVexProvided()
{
// Arrange
var imageDigest = "sha256:jkl012";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReport(imageDigest, baselineSeal.CombinedMerkleRoot, QuotaVerdict.RequiresVex);
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultAction = QuotaExceededAction.RequireVex
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeFalse();
result.Reason.Should().Be("requires_vex_authorization");
result.Details.Should().ContainKey("vexRequired");
((bool)result.Details["vexRequired"]).Should().BeTrue();
}
#endregion
#region Multi-Facet Tests
[Fact]
public async Task MultiFacet_MixedVerdicts_ReportsWorstCase()
{
// Arrange: Multiple facets with different verdicts
var imageDigest = "sha256:multi123";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var facetDrifts = new[]
{
CreateFacetDrift("os-packages", QuotaVerdict.Ok),
CreateFacetDrift("app-dependencies", QuotaVerdict.Warning),
CreateFacetDrift("config-files", QuotaVerdict.Blocked)
};
var driftReport = new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSeal.CombinedMerkleRoot,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [.. facetDrifts],
OverallVerdict = QuotaVerdict.Blocked // Worst case
};
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions { Enabled = true };
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeFalse();
result.Reason.Should().Be("quota_exceeded");
}
[Fact]
public async Task MultiFacet_AllWithinQuota_Passes()
{
// Arrange
var imageDigest = "sha256:allok456";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var facetDrifts = new[]
{
CreateFacetDrift("os-packages", QuotaVerdict.Ok),
CreateFacetDrift("app-dependencies", QuotaVerdict.Ok),
CreateFacetDrift("config-files", QuotaVerdict.Ok)
};
var driftReport = new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSeal.CombinedMerkleRoot,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [.. facetDrifts],
OverallVerdict = QuotaVerdict.Ok
};
SetupDriftDetector(driftReport);
var options = new FacetQuotaGateOptions { Enabled = true };
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
}
#endregion
#region Seal Store Integration
[Fact]
public async Task SealStore_SaveAndRetrieve_WorksCorrectly()
{
// Arrange
var imageDigest = "sha256:store123";
var seal = CreateSeal(imageDigest, 50);
// Act
await _sealStore.SaveAsync(seal);
var retrieved = await _sealStore.GetLatestSealAsync(imageDigest);
// Assert
retrieved.Should().NotBeNull();
retrieved!.ImageDigest.Should().Be(imageDigest);
retrieved.CombinedMerkleRoot.Should().Be(seal.CombinedMerkleRoot);
}
[Fact]
public async Task SealStore_MultipleSeals_ReturnsLatest()
{
// Arrange
var imageDigest = "sha256:multi789";
var seal1 = CreateSealWithTimestamp(imageDigest, 50, DateTimeOffset.UtcNow.AddHours(-2));
var seal2 = CreateSealWithTimestamp(imageDigest, 55, DateTimeOffset.UtcNow.AddHours(-1));
var seal3 = CreateSealWithTimestamp(imageDigest, 60, DateTimeOffset.UtcNow);
await _sealStore.SaveAsync(seal1);
await _sealStore.SaveAsync(seal2);
await _sealStore.SaveAsync(seal3);
// Act
var latest = await _sealStore.GetLatestSealAsync(imageDigest);
// Assert
latest.Should().NotBeNull();
latest!.CreatedAt.Should().Be(seal3.CreatedAt);
}
[Fact]
public async Task SealStore_History_ReturnsInDescendingOrder()
{
// Arrange
var imageDigest = "sha256:history123";
var seal1 = CreateSealWithTimestamp(imageDigest, 50, DateTimeOffset.UtcNow.AddHours(-2));
var seal2 = CreateSealWithTimestamp(imageDigest, 55, DateTimeOffset.UtcNow.AddHours(-1));
var seal3 = CreateSealWithTimestamp(imageDigest, 60, DateTimeOffset.UtcNow);
await _sealStore.SaveAsync(seal1);
await _sealStore.SaveAsync(seal2);
await _sealStore.SaveAsync(seal3);
// Act
var history = await _sealStore.GetHistoryAsync(imageDigest, limit: 10);
// Assert
history.Should().HaveCount(3);
history[0].CreatedAt.Should().Be(seal3.CreatedAt);
history[1].CreatedAt.Should().Be(seal2.CreatedAt);
history[2].CreatedAt.Should().Be(seal1.CreatedAt);
}
#endregion
#region Configuration Tests
[Fact]
public async Task Configuration_PerFacetOverride_AppliesCorrectly()
{
// Arrange: os-packages has higher threshold
var imageDigest = "sha256:override123";
var baselineSeal = CreateSeal(imageDigest, 100);
await _sealStore.SaveAsync(baselineSeal);
var driftReport = CreateDriftReportWithChurn(imageDigest, baselineSeal.CombinedMerkleRoot, "os-packages", 25m);
var options = new FacetQuotaGateOptions
{
Enabled = true,
DefaultMaxChurnPercent = 10.0m,
FacetOverrides = new Dictionary<string, FacetQuotaOverride>
{
["os-packages"] = new FacetQuotaOverride
{
MaxChurnPercent = 30m, // Higher threshold for OS packages
Action = QuotaExceededAction.Warn
}
}
};
var gate = CreateGate(options);
var context = CreateContextWithDriftReport(driftReport);
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert: 25% churn is within the 30% override threshold
result.Passed.Should().BeTrue();
}
[Fact]
public async Task Configuration_DisabledGate_BypassesAllChecks()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = false };
var gate = CreateGate(options);
var context = new PolicyGateContext { Environment = "production" };
var mergeResult = CreateMergeResult(VexStatus.NotAffected);
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
result.Passed.Should().BeTrue();
result.Reason.Should().Be("Gate disabled");
}
#endregion
#region Helper Methods
private FacetQuotaGate CreateGate(FacetQuotaGateOptions options)
{
return new FacetQuotaGate(options, _driftDetector.Object, NullLogger<FacetQuotaGate>.Instance);
}
private void SetupDriftDetector(FacetDriftReport report)
{
_driftDetector
.Setup(d => d.DetectDriftAsync(It.IsAny<FacetSeal>(), It.IsAny<FacetSeal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(report);
}
private static PolicyGateContext CreateContextWithDriftReport(FacetDriftReport report)
{
var json = JsonSerializer.Serialize(report);
return new PolicyGateContext
{
Environment = "production",
Metadata = new Dictionary<string, string>
{
["FacetDriftReport"] = json
}
};
}
private static MergeResult CreateMergeResult(VexStatus status)
{
var claim = new ScoredClaim
{
SourceId = "test",
Status = status,
OriginalScore = 1.0,
AdjustedScore = 1.0,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
};
return new MergeResult
{
Status = status,
Confidence = 0.9,
HasConflicts = false,
AllClaims = [claim],
WinningClaim = claim,
Conflicts = []
};
}
private FacetSeal CreateSeal(string imageDigest, int fileCount)
{
return CreateSealWithTimestamp(imageDigest, fileCount, DateTimeOffset.UtcNow);
}
private FacetSeal CreateSealWithTimestamp(string imageDigest, int fileCount, DateTimeOffset createdAt)
{
var files = Enumerable.Range(0, fileCount)
.Select(i => new FacetFileEntry($"/file{i}.txt", $"sha256:{i:x8}", 100, null))
.ToImmutableArray();
var facetEntry = new FacetEntry(
FacetId: "test-facet",
Files: files,
MerkleRoot: $"sha256:facet{fileCount:x8}");
return new FacetSeal
{
ImageDigest = imageDigest,
SchemaVersion = "1.0.0",
CreatedAt = createdAt,
Facets = [facetEntry],
CombinedMerkleRoot = $"sha256:combined{imageDigest.GetHashCode():x8}{createdAt.Ticks:x8}"
};
}
private static FacetDriftReport CreateDriftReport(string imageDigest, string baselineSealId, QuotaVerdict verdict)
{
return new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSealId,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [CreateFacetDrift("test-facet", verdict)],
OverallVerdict = verdict
};
}
private static FacetDriftReport CreateDriftReportWithChurn(
string imageDigest,
string baselineSealId,
string facetId,
decimal churnPercent)
{
var addedCount = (int)(churnPercent * 100 / 100); // For 100 baseline files
var addedFiles = Enumerable.Range(0, addedCount)
.Select(i => new FacetFileEntry($"/added{i}.txt", $"sha256:added{i}", 100, null))
.ToImmutableArray();
var verdict = churnPercent switch
{
< 10 => QuotaVerdict.Ok,
< 20 => QuotaVerdict.Warning,
_ => QuotaVerdict.Blocked
};
var facetDrift = new FacetDrift
{
FacetId = facetId,
Added = addedFiles,
Removed = [],
Modified = [],
DriftScore = churnPercent,
QuotaVerdict = verdict,
BaselineFileCount = 100
};
return new FacetDriftReport
{
ImageDigest = imageDigest,
BaselineSealId = baselineSealId,
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [facetDrift],
OverallVerdict = verdict
};
}
private static FacetDrift CreateFacetDrift(string facetId, QuotaVerdict verdict)
{
var addedCount = verdict switch
{
QuotaVerdict.Warning => 15,
QuotaVerdict.Blocked => 35,
QuotaVerdict.RequiresVex => 50,
_ => 0
};
var addedFiles = Enumerable.Range(0, addedCount)
.Select(i => new FacetFileEntry($"/added{i}.txt", $"sha256:added{i}", 100, null))
.ToImmutableArray();
return new FacetDrift
{
FacetId = facetId,
Added = addedFiles,
Removed = [],
Modified = [],
DriftScore = addedCount,
QuotaVerdict = verdict,
BaselineFileCount = 100
};
}
#endregion
}

View File

@@ -0,0 +1,276 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Determinization.Models;
using StellaOps.Policy.Engine.Policies;
namespace StellaOps.Policy.Engine.Tests.Policies;
public class DeterminizationPolicyTests
{
private readonly DeterminizationPolicy _policy;
public DeterminizationPolicyTests()
{
var options = Options.Create(new DeterminizationOptions());
_policy = new DeterminizationPolicy(options, NullLogger<DeterminizationPolicy>.Instance);
}
[Fact]
public void Evaluate_RuntimeEvidenceLoaded_ReturnsEscalated()
{
// Arrange
var context = CreateContext(
runtime: new SignalState<RuntimeEvidence>
{
HasValue = true,
Value = new RuntimeEvidence { ObservedLoaded = true }
});
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Escalated);
result.MatchedRule.Should().Be("RuntimeEscalation");
result.Reason.Should().Contain("Runtime evidence shows vulnerable code loaded");
}
[Fact]
public void Evaluate_HighEpss_ReturnsQuarantined()
{
// Arrange
var context = CreateContext(
epss: new SignalState<EpssEvidence>
{
HasValue = true,
Value = new EpssEvidence { Score = 0.8 }
},
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Blocked);
result.MatchedRule.Should().Be("EpssQuarantine");
result.Reason.Should().Contain("EPSS score");
}
[Fact]
public void Evaluate_ReachableCode_ReturnsQuarantined()
{
// Arrange
var context = CreateContext(
reachability: new SignalState<ReachabilityEvidence>
{
HasValue = true,
Value = new ReachabilityEvidence { IsReachable = true, Confidence = 0.9 }
});
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Blocked);
result.MatchedRule.Should().Be("ReachabilityQuarantine");
result.Reason.Should().Contain("reachable");
}
[Fact]
public void Evaluate_HighEntropyInProduction_ReturnsQuarantined()
{
// Arrange
var context = CreateContext(
entropy: 0.5,
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Blocked);
result.MatchedRule.Should().Be("ProductionEntropyBlock");
result.Reason.Should().Contain("High uncertainty");
}
[Fact]
public void Evaluate_StaleEvidence_ReturnsDeferred()
{
// Arrange
var context = CreateContext(
isStale: true);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Deferred);
result.MatchedRule.Should().Be("StaleEvidenceDefer");
result.Reason.Should().Contain("stale");
}
[Fact]
public void Evaluate_ModerateUncertaintyInDev_ReturnsGuardedPass()
{
// Arrange
var context = CreateContext(
entropy: 0.5,
trustScore: 0.3,
environment: DeploymentEnvironment.Development);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.GuardedPass);
result.MatchedRule.Should().Be("GuardedAllowNonProd");
result.GuardRails.Should().NotBeNull();
result.GuardRails!.EnableMonitoring.Should().BeTrue();
}
[Fact]
public void Evaluate_UnreachableWithHighConfidence_ReturnsAllowed()
{
// Arrange
var context = CreateContext(
reachability: new SignalState<ReachabilityEvidence>
{
HasValue = true,
Value = new ReachabilityEvidence { IsReachable = false, Confidence = 0.9 }
},
trustScore: 0.8);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Pass);
result.MatchedRule.Should().Be("UnreachableAllow");
result.Reason.Should().Contain("unreachable");
}
[Fact]
public void Evaluate_VexNotAffected_ReturnsAllowed()
{
// Arrange
var context = CreateContext(
vex: new SignalState<VexClaimSummary>
{
HasValue = true,
Value = new VexClaimSummary { IsNotAffected = true, IssuerTrust = 0.9 }
},
trustScore: 0.8);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Pass);
result.MatchedRule.Should().Be("VexNotAffectedAllow");
result.Reason.Should().Contain("not_affected");
}
[Fact]
public void Evaluate_SufficientEvidenceLowEntropy_ReturnsAllowed()
{
// Arrange
var context = CreateContext(
entropy: 0.2,
trustScore: 0.8,
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Pass);
result.MatchedRule.Should().Be("SufficientEvidenceAllow");
result.Reason.Should().Contain("Sufficient evidence");
}
[Fact]
public void Evaluate_ModerateUncertaintyTier_ReturnsGuardedPass()
{
// Arrange
var context = CreateContext(
tier: UncertaintyTier.Moderate,
trustScore: 0.5,
entropy: 0.5);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.GuardedPass);
result.MatchedRule.Should().Be("GuardedAllowModerateUncertainty");
result.GuardRails.Should().NotBeNull();
}
[Fact]
public void Evaluate_NoMatchingRule_ReturnsDeferred()
{
// Arrange
var context = CreateContext(
entropy: 0.9,
trustScore: 0.1,
environment: DeploymentEnvironment.Production);
// Act
var result = _policy.Evaluate(context);
// Assert
result.Status.Should().Be(PolicyVerdictStatus.Deferred);
result.MatchedRule.Should().Be("DefaultDefer");
result.Reason.Should().Contain("Insufficient evidence");
}
private static DeterminizationContext CreateContext(
SignalState<EpssEvidence>? epss = null,
SignalState<VexClaimSummary>? vex = null,
SignalState<ReachabilityEvidence>? reachability = null,
SignalState<RuntimeEvidence>? runtime = null,
double entropy = 0.0,
double trustScore = 0.0,
UncertaintyTier tier = UncertaintyTier.Minimal,
DeploymentEnvironment environment = DeploymentEnvironment.Development,
bool isStale = false)
{
var snapshot = new SignalSnapshot
{
Cve = "CVE-2024-0001",
Purl = "pkg:npm/test@1.0.0",
Epss = epss ?? SignalState<EpssEvidence>.NotQueried(),
Vex = vex ?? SignalState<VexClaimSummary>.NotQueried(),
Reachability = reachability ?? SignalState<ReachabilityEvidence>.NotQueried(),
Runtime = runtime ?? SignalState<RuntimeEvidence>.NotQueried(),
Backport = SignalState<BackportEvidence>.NotQueried(),
Sbom = SignalState<SbomLineageEvidence>.NotQueried(),
Cvss = SignalState<CvssEvidence>.NotQueried(),
SnapshotAt = DateTimeOffset.UtcNow
};
return new DeterminizationContext
{
SignalSnapshot = snapshot,
UncertaintyScore = new UncertaintyScore
{
Entropy = entropy,
Tier = tier,
Completeness = 1.0 - entropy,
MissingSignals = []
},
Decay = new ObservationDecay
{
LastSignalUpdate = DateTimeOffset.UtcNow.AddDays(-1),
AgeDays = 1,
DecayedMultiplier = isStale ? 0.3 : 0.9,
IsStale = isStale
},
TrustScore = trustScore,
Environment = environment
};
}
}

View File

@@ -0,0 +1,152 @@
using FluentAssertions;
using StellaOps.Policy.Determinization;
using StellaOps.Policy.Engine.Policies;
namespace StellaOps.Policy.Engine.Tests.Policies;
public class DeterminizationRuleSetTests
{
[Fact]
public void Default_RulesAreOrderedByPriority()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
ruleSet.Rules.Should().HaveCountGreaterThan(0);
var priorities = ruleSet.Rules.Select(r => r.Priority).ToList();
priorities.Should().BeInAscendingOrder("rules should be evaluable in priority order");
}
[Fact]
public void Default_RuntimeEscalationHasHighestPriority()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var runtimeRule = ruleSet.Rules.First(r => r.Name == "RuntimeEscalation");
runtimeRule.Priority.Should().Be(10, "runtime escalation should have highest priority");
var allOtherRules = ruleSet.Rules.Where(r => r.Name != "RuntimeEscalation");
allOtherRules.Should().AllSatisfy(r => r.Priority.Should().BeGreaterThan(10));
}
[Fact]
public void Default_DefaultDeferHasLowestPriority()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var defaultRule = ruleSet.Rules.First(r => r.Name == "DefaultDefer");
defaultRule.Priority.Should().Be(100, "default defer should be catch-all with lowest priority");
var allOtherRules = ruleSet.Rules.Where(r => r.Name != "DefaultDefer");
allOtherRules.Should().AllSatisfy(r => r.Priority.Should().BeLessThan(100));
}
[Fact]
public void Default_QuarantineRulesBeforeAllowRules()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var epssQuarantine = ruleSet.Rules.First(r => r.Name == "EpssQuarantine");
var reachabilityQuarantine = ruleSet.Rules.First(r => r.Name == "ReachabilityQuarantine");
var productionBlock = ruleSet.Rules.First(r => r.Name == "ProductionEntropyBlock");
var unreachableAllow = ruleSet.Rules.First(r => r.Name == "UnreachableAllow");
var vexAllow = ruleSet.Rules.First(r => r.Name == "VexNotAffectedAllow");
var sufficientEvidenceAllow = ruleSet.Rules.First(r => r.Name == "SufficientEvidenceAllow");
epssQuarantine.Priority.Should().BeLessThan(unreachableAllow.Priority);
reachabilityQuarantine.Priority.Should().BeLessThan(vexAllow.Priority);
productionBlock.Priority.Should().BeLessThan(sufficientEvidenceAllow.Priority);
}
[Fact]
public void Default_AllRulesHaveUniquePriorities()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var priorities = ruleSet.Rules.Select(r => r.Priority).ToList();
priorities.Should().OnlyHaveUniqueItems("each rule should have unique priority for deterministic ordering");
}
[Fact]
public void Default_AllRulesHaveNames()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
ruleSet.Rules.Should().AllSatisfy(r =>
{
r.Name.Should().NotBeNullOrWhiteSpace("all rules must have names for audit trail");
});
}
[Fact]
public void Default_Contains11Rules()
{
// Arrange
var options = new DeterminizationOptions();
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
ruleSet.Rules.Should().HaveCount(11, "rule set should contain all 11 specified rules");
}
[Fact]
public void Default_ContainsExpectedRules()
{
// Arrange
var options = new DeterminizationOptions();
var expectedRuleNames = new[]
{
"RuntimeEscalation",
"EpssQuarantine",
"ReachabilityQuarantine",
"ProductionEntropyBlock",
"StaleEvidenceDefer",
"GuardedAllowNonProd",
"UnreachableAllow",
"VexNotAffectedAllow",
"SufficientEvidenceAllow",
"GuardedAllowModerateUncertainty",
"DefaultDefer"
};
// Act
var ruleSet = DeterminizationRuleSet.Default(options);
// Assert
var actualNames = ruleSet.Rules.Select(r => r.Name).ToList();
actualNames.Should().BeEquivalentTo(expectedRuleNames);
}
}

View File

@@ -0,0 +1,233 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace StellaOps.Policy.Explainability.Tests;
public class VerdictRationaleRendererTests
{
private readonly VerdictRationaleRenderer _renderer;
public VerdictRationaleRendererTests()
{
_renderer = new VerdictRationaleRenderer(NullLogger<VerdictRationaleRenderer>.Instance);
}
[Fact]
public void Render_Should_CreateCompleteRationale()
{
// Arrange
var input = CreateTestInput();
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Should().NotBeNull();
rationale.RationaleId.Should().StartWith("rat:sha256:");
rationale.Evidence.Cve.Should().Be("CVE-2024-1234");
rationale.PolicyClause.ClauseId.Should().Be("S2.1");
rationale.Decision.Verdict.Should().Be("Affected");
}
[Fact]
public void Render_Should_BeContentAddressed()
{
// Arrange
var timestamp = new DateTimeOffset(2026, 1, 6, 12, 0, 0, TimeSpan.Zero);
var input1 = CreateTestInput(timestamp);
var input2 = CreateTestInput(timestamp); // Identical input with same timestamp
// Act
var rationale1 = _renderer.Render(input1);
var rationale2 = _renderer.Render(input2);
// Assert
rationale1.RationaleId.Should().Be(rationale2.RationaleId);
}
[Fact]
public void RenderPlainText_Should_ProduceFourLineFormat()
{
// Arrange
var input = CreateTestInput();
var rationale = _renderer.Render(input);
// Act
var text = _renderer.RenderPlainText(rationale);
// Assert
var lines = text.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
lines.Should().HaveCount(4);
lines[0].Should().Contain("CVE-2024-1234");
lines[1].Should().Contain("Policy S2.1");
lines[2].Should().Contain("Path witness");
lines[3].Should().Contain("Affected");
}
[Fact]
public void RenderMarkdown_Should_IncludeHeaders()
{
// Arrange
var input = CreateTestInput();
var rationale = _renderer.Render(input);
// Act
var markdown = _renderer.RenderMarkdown(rationale);
// Assert
markdown.Should().Contain("## Verdict Rationale");
markdown.Should().Contain("### Evidence");
markdown.Should().Contain("### Policy Clause");
markdown.Should().Contain("### Attestations");
markdown.Should().Contain("### Decision");
markdown.Should().Contain(rationale.RationaleId);
}
[Fact]
public void RenderJson_Should_ProduceValidJson()
{
// Arrange
var input = CreateTestInput();
var rationale = _renderer.Render(input);
// Act
var json = _renderer.RenderJson(rationale);
// Assert
json.Should().NotBeNullOrEmpty();
// RFC 8785 canonical JSON uses snake_case
json.Should().Contain("\"rationale_id\"");
json.Should().Contain("\"evidence\"");
json.Should().Contain("\"policy_clause\"");
json.Should().Contain("\"attestations\"");
json.Should().Contain("\"decision\"");
}
[Fact]
public void Evidence_Should_IncludeReachabilityDetails()
{
// Arrange
var input = CreateTestInput();
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Evidence.FormattedText.Should().Contain("foo_read");
rationale.Evidence.FormattedText.Should().Contain("/usr/bin/tool");
}
[Fact]
public void Evidence_Should_HandleMissingReachability()
{
// Arrange
var input = CreateTestInput() with { Reachability = null };
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Evidence.FormattedText.Should().NotContain("reachable");
}
[Fact]
public void Attestations_Should_HandleNoAttestations()
{
// Arrange
var input = CreateTestInput() with
{
PathWitness = null,
VexStatements = null,
Provenance = null
};
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Attestations.FormattedText.Should().Be("No attestations available.");
}
[Fact]
public void Decision_Should_IncludeMitigation()
{
// Arrange
var input = CreateTestInput();
// Act
var rationale = _renderer.Render(input);
// Assert
rationale.Decision.FormattedText.Should().Contain("upgrade or backport");
rationale.Decision.FormattedText.Should().Contain("KB-123");
}
private VerdictRationaleInput CreateTestInput(DateTimeOffset? generatedAt = null)
{
return new VerdictRationaleInput
{
VerdictRef = new VerdictReference
{
AttestationId = "att:sha256:abc123",
ArtifactDigest = "sha256:def456",
PolicyId = "policy-1",
Cve = "CVE-2024-1234",
ComponentPurl = "pkg:maven/org.example/lib@1.0.0"
},
Cve = "CVE-2024-1234",
Component = new ComponentIdentity
{
Purl = "pkg:maven/org.example/lib@1.0.0",
Name = "libxyz",
Version = "1.2.3",
Ecosystem = "maven"
},
Reachability = new ReachabilityDetail
{
VulnerableFunction = "foo_read",
EntryPoint = "/usr/bin/tool",
PathSummary = "main->parse->foo_read"
},
PolicyClauseId = "S2.1",
PolicyRuleDescription = "reachable+EPSS>=0.2 => triage=P1",
PolicyConditions = new[] { "reachable", "EPSS>=0.2" },
PathWitness = new AttestationReference
{
Id = "witness:sha256:path123",
Type = "PathWitness",
Digest = "sha256:path123",
Summary = "Build-ID match to vendor advisory"
},
VexStatements = new[]
{
new AttestationReference
{
Id = "vex:sha256:vex123",
Type = "VEX",
Digest = "sha256:vex123",
Summary = "affected"
}
},
Provenance = new AttestationReference
{
Id = "prov:sha256:prov123",
Type = "Provenance",
Digest = "sha256:prov123",
Summary = "SLSA L3"
},
Verdict = "Affected",
Score = 0.72,
Recommendation = "Mitigation recommended",
Mitigation = new MitigationGuidance
{
Action = "upgrade or backport",
Details = "KB-123"
},
GeneratedAt = generatedAt ?? DateTimeOffset.UtcNow,
VerdictDigest = "sha256:verdict123",
PolicyDigest = "sha256:policy123",
EvidenceDigest = "sha256:evidence123"
};
}
}

View File

@@ -0,0 +1,220 @@
// <copyright file="FacetQuotaGateTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-014)
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StellaOps.Facet;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Gates;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
/// <summary>
/// Unit tests for <see cref="FacetQuotaGate"/> evaluation scenarios.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FacetQuotaGateTests
{
private readonly Mock<IFacetDriftDetector> _driftDetectorMock;
private readonly FacetQuotaGateOptions _options;
private FacetQuotaGate _gate;
public FacetQuotaGateTests()
{
_driftDetectorMock = new Mock<IFacetDriftDetector>();
_options = new FacetQuotaGateOptions { Enabled = true };
_gate = CreateGate(_options);
}
private FacetQuotaGate CreateGate(FacetQuotaGateOptions options)
{
return new FacetQuotaGate(options, _driftDetectorMock.Object, NullLogger<FacetQuotaGate>.Instance);
}
[Fact]
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = false };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("Gate disabled", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenNoSealAndNoSealActionIsPass_ReturnsPass()
{
// Arrange - no drift report in context means no seal
var options = new FacetQuotaGateOptions { Enabled = true, NoSealAction = NoSealAction.Pass };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Contains("first scan", result.Reason);
}
[Fact]
public async Task EvaluateAsync_WhenNoSealAndNoSealActionIsWarn_ReturnsPassWithWarning()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = true, NoSealAction = NoSealAction.Warn };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.True(result.Passed);
Assert.Equal("no_baseline_seal", result.Reason);
Assert.True(result.Details.ContainsKey("action"));
}
[Fact]
public async Task EvaluateAsync_WhenNoSealAndNoSealActionIsBlock_ReturnsFail()
{
// Arrange
var options = new FacetQuotaGateOptions { Enabled = true, NoSealAction = NoSealAction.Block };
var gate = CreateGate(options);
var context = CreateContext();
var mergeResult = CreateMergeResult();
// Act
var result = await gate.EvaluateAsync(mergeResult, context);
// Assert
Assert.False(result.Passed);
Assert.Equal("no_baseline_seal", result.Reason);
}
[Fact]
public async Task EvaluateAsync_NullMergeResult_ThrowsArgumentNullException()
{
// Arrange
var context = CreateContext();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() =>
_gate.EvaluateAsync(null!, context));
}
[Fact]
public async Task EvaluateAsync_NullContext_ThrowsArgumentNullException()
{
// Arrange
var mergeResult = CreateMergeResult();
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() =>
_gate.EvaluateAsync(mergeResult, null!));
}
private static PolicyGateContext CreateContext()
{
return new PolicyGateContext
{
Environment = "test"
};
}
private static PolicyGateContext CreateContextWithDriftReportJson(string driftReportJson)
{
return new PolicyGateContext
{
Environment = "test",
Metadata = new Dictionary<string, string>
{
["FacetDriftReport"] = driftReportJson
}
};
}
private static MergeResult CreateMergeResult()
{
var emptyClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.NotAffected,
OriginalScore = 1.0,
AdjustedScore = 1.0,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
};
return new MergeResult
{
Status = VexStatus.NotAffected,
Confidence = 0.9,
HasConflicts = false,
AllClaims = [emptyClaim],
WinningClaim = emptyClaim,
Conflicts = []
};
}
private static FacetDriftReport CreateDriftReport(QuotaVerdict verdict)
{
return new FacetDriftReport
{
ImageDigest = "sha256:abc123",
BaselineSealId = "seal-123",
AnalyzedAt = DateTimeOffset.UtcNow,
FacetDrifts = [CreateFacetDrift("test-facet", verdict)],
OverallVerdict = verdict
};
}
private static FacetDrift CreateFacetDrift(
string facetId,
QuotaVerdict verdict,
int baselineFileCount = 100)
{
// ChurnPercent is computed from TotalChanges / BaselineFileCount
// For different verdicts, we add files appropriately
var addedCount = verdict switch
{
QuotaVerdict.Warning => 10, // 10% churn
QuotaVerdict.Blocked => 30, // 30% churn
QuotaVerdict.RequiresVex => 50, // 50% churn
_ => 0
};
var addedFiles = Enumerable.Range(0, addedCount)
.Select(i => new FacetFileEntry(
$"/added/file{i}.txt",
$"sha256:added{i}",
100,
null))
.ToImmutableArray();
return new FacetDrift
{
FacetId = facetId,
Added = addedFiles,
Removed = [],
Modified = [],
DriftScore = addedCount,
QuotaVerdict = verdict,
BaselineFileCount = baselineFileCount
};
}
}

View File

@@ -17,9 +17,11 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Determinization/StellaOps.Policy.Determinization.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Policy.Exceptions/StellaOps.Policy.Exceptions.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Facet/StellaOps.Facet.csproj" />
</ItemGroup>
</Project>