// Licensed to StellaOps under the AGPL-3.0-or-later license. using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using StellaOps.Policy.Engine.Gates; using Xunit; namespace StellaOps.Policy.Engine.Tests.Gates; /// /// Unit tests for . /// [Trait("Category", "Unit")] public class StabilityDampingGateTests { private readonly FakeTimeProvider _timeProvider; private readonly StabilityDampingOptions _defaultOptions; public StabilityDampingGateTests() { _timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 4, 12, 0, 0, TimeSpan.Zero)); _defaultOptions = new StabilityDampingOptions { Enabled = true, MinDurationBeforeChange = TimeSpan.FromHours(4), MinConfidenceDeltaPercent = 0.15, OnlyDampDowngrades = true, DampedStatuses = ["affected", "not_affected", "fixed", "under_investigation"] }; } private StabilityDampingGate CreateGate(StabilityDampingOptions? options = null) { var opts = options ?? _defaultOptions; var optionsMonitor = new TestOptionsMonitor(opts); return new StabilityDampingGate(optionsMonitor, _timeProvider, NullLogger.Instance); } [Fact] public async Task EvaluateAsync_NewVerdict_ShouldSurface() { // Arrange var gate = CreateGate(); var request = new StabilityDampingRequest { Key = "artifact:CVE-2024-1234", ProposedState = new VerdictState { Status = "affected", Confidence = 0.85, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeTrue(); decision.Reason.Should().Contain("new verdict"); } [Fact] public async Task EvaluateAsync_WhenDisabled_ShouldAlwaysSurface() { // Arrange var options = new StabilityDampingOptions { Enabled = false }; var gate = CreateGate(options); var request = new StabilityDampingRequest { Key = "artifact:CVE-2024-1234", ProposedState = new VerdictState { Status = "affected", Confidence = 0.85, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeTrue(); decision.Reason.Should().Contain("disabled"); } [Fact] public async Task EvaluateAsync_StatusUpgrade_ShouldSurfaceImmediately() { // Arrange var gate = CreateGate(); var key = "artifact:CVE-2024-1234"; // Record initial state as not_affected await gate.RecordStateAsync(key, new VerdictState { Status = "not_affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() }); // Request upgrade to affected (more severe) var request = new StabilityDampingRequest { Key = key, ProposedState = new VerdictState { Status = "affected", Confidence = 0.85, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeTrue(); decision.IsUpgrade.Should().BeTrue(); decision.Reason.Should().Contain("upgrade"); } [Fact] public async Task EvaluateAsync_StatusDowngrade_WithoutMinDuration_ShouldDamp() { // Arrange var gate = CreateGate(); var key = "artifact:CVE-2024-1234"; // Record initial state as affected await gate.RecordStateAsync(key, new VerdictState { Status = "affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() }); // Advance time but not enough to meet threshold _timeProvider.Advance(TimeSpan.FromHours(2)); // Request downgrade to not_affected (less severe) var request = new StabilityDampingRequest { Key = key, ProposedState = new VerdictState { Status = "not_affected", Confidence = 0.75, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeFalse(); decision.Reason.Should().Contain("Damped"); } [Fact] public async Task EvaluateAsync_StatusDowngrade_AfterMinDuration_ShouldSurface() { // Arrange var gate = CreateGate(); var key = "artifact:CVE-2024-1234"; // Record initial state as affected await gate.RecordStateAsync(key, new VerdictState { Status = "affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() }); // Advance time past threshold _timeProvider.Advance(TimeSpan.FromHours(5)); // Request downgrade to not_affected var request = new StabilityDampingRequest { Key = key, ProposedState = new VerdictState { Status = "not_affected", Confidence = 0.75, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeTrue(); decision.StateDuration.Should().BeGreaterThan(TimeSpan.FromHours(4)); } [Fact] public async Task EvaluateAsync_LargeConfidenceDelta_ShouldSurfaceImmediately() { // Arrange var gate = CreateGate(); var key = "artifact:CVE-2024-1234"; // Record initial state await gate.RecordStateAsync(key, new VerdictState { Status = "affected", Confidence = 0.50, Timestamp = _timeProvider.GetUtcNow() }); // Request with large confidence change (>15%) var request = new StabilityDampingRequest { Key = key, ProposedState = new VerdictState { Status = "affected", Confidence = 0.90, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeTrue(); decision.ConfidenceDelta.Should().BeGreaterThan(0.15); } [Fact] public async Task EvaluateAsync_SmallConfidenceDelta_SameStatus_ShouldDamp() { // Arrange var gate = CreateGate(); var key = "artifact:CVE-2024-1234"; // Record initial state await gate.RecordStateAsync(key, new VerdictState { Status = "affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() }); // Request with small confidence change (<15%) var request = new StabilityDampingRequest { Key = key, ProposedState = new VerdictState { Status = "affected", Confidence = 0.85, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeFalse(); decision.ConfidenceDelta.Should().BeLessThan(0.15); } [Fact] public async Task PruneHistoryAsync_ShouldRemoveStaleRecords() { // Arrange var gate = CreateGate(); // Record old state await gate.RecordStateAsync("old-key", new VerdictState { Status = "affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() }); // Advance time past retention period _timeProvider.Advance(TimeSpan.FromDays(8)); // Default retention is 7 days // Record new state (to ensure we have something current) await gate.RecordStateAsync("new-key", new VerdictState { Status = "affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() }); // Act var pruned = await gate.PruneHistoryAsync(); // Assert pruned.Should().BeGreaterThanOrEqualTo(1); } [Fact] public async Task EvaluateAsync_WithTenantId_ShouldIsolateTenants() { // Arrange var gate = CreateGate(); // Record state for tenant-a await gate.RecordStateAsync("tenant-a:artifact:CVE-2024-1234", new VerdictState { Status = "affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() }); // Request for tenant-b (different tenant, no history) var request = new StabilityDampingRequest { Key = "artifact:CVE-2024-1234", TenantId = "tenant-b", ProposedState = new VerdictState { Status = "affected", Confidence = 0.80, Timestamp = _timeProvider.GetUtcNow() } }; // Act var decision = await gate.EvaluateAsync(request); // Assert decision.ShouldSurface.Should().BeTrue(); decision.Reason.Should().Contain("new verdict"); } private sealed class TestOptionsMonitor : IOptionsMonitor where T : class { public TestOptionsMonitor(T value) => CurrentValue = value; public T CurrentValue { get; } public T Get(string? name) => CurrentValue; public IDisposable? OnChange(Action listener) => null; } }