Files
git.stella-ops.org/src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Gates/StabilityDampingGateTests.cs
StellaOps Bot 3098e84de4 save progress
2026-01-04 14:54:52 +02:00

348 lines
10 KiB
C#

// 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;
/// <summary>
/// Unit tests for <see cref="StabilityDampingGate"/>.
/// </summary>
[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<StabilityDampingOptions>(opts);
return new StabilityDampingGate(optionsMonitor, _timeProvider, NullLogger<StabilityDampingGate>.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<T> : IOptionsMonitor<T>
where T : class
{
public TestOptionsMonitor(T value) => CurrentValue = value;
public T CurrentValue { get; }
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
}