Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user