Gaps fill up, fixes, ui restructuring
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for BeaconRateGate.
|
||||
/// Sprint: SPRINT_20260219_014 (BEA-03)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "20260219.014")]
|
||||
public sealed class BeaconRateGateTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 19, 14, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Gate disabled
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(enabled: false);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("disabled", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environment filtering
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("development");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("not required", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing beacon data
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingBeaconData_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingBeaconData_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production");
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate threshold enforcement
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateAboveThreshold_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8);
|
||||
var context = CreateContext("production", beaconRate: 0.95, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateBelowThreshold_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("below threshold", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RateBelowThreshold_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", beaconRate: 0.5, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Minimum beacon count
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_BelowMinBeaconCount_SkipsRateEnforcement()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, minBeaconCount: 50);
|
||||
// Rate is bad but count is too low to enforce.
|
||||
var context = CreateContext("production", beaconRate: 0.3, beaconCount: 5);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("deferred", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boundary conditions
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ExactlyAtThreshold_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8);
|
||||
var context = CreateContext("production", beaconRate: 0.8, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_JustBelowThreshold_TriggersAction()
|
||||
{
|
||||
var gate = CreateGate(minRate: 0.8, belowAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", beaconRate: 0.7999, beaconCount: 100);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private BeaconRateGate CreateGate(
|
||||
bool enabled = true,
|
||||
double minRate = 0.8,
|
||||
int minBeaconCount = 10,
|
||||
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
|
||||
PolicyGateDecisionType belowAction = PolicyGateDecisionType.Warn)
|
||||
{
|
||||
var opts = new PolicyGateOptions
|
||||
{
|
||||
BeaconRate = new BeaconRateGateOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MinVerificationRate = minRate,
|
||||
MinBeaconCount = minBeaconCount,
|
||||
MissingBeaconAction = missingAction,
|
||||
BelowThresholdAction = belowAction,
|
||||
RequiredEnvironments = new List<string> { "production" },
|
||||
},
|
||||
};
|
||||
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
|
||||
return new BeaconRateGate(monitor, NullLogger<BeaconRateGate>.Instance, _fixedTimeProvider);
|
||||
}
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment,
|
||||
double? beaconRate = null,
|
||||
int? beaconCount = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (beaconRate.HasValue)
|
||||
{
|
||||
metadata["beacon_verification_rate"] = beaconRate.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
if (beaconCount.HasValue)
|
||||
{
|
||||
metadata["beacon_verified_count"] = beaconCount.Value.ToString();
|
||||
}
|
||||
|
||||
return new PolicyGateContext
|
||||
{
|
||||
Environment = environment,
|
||||
SubjectKey = "test-subject",
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Engine.Gates;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for ExecutionEvidenceGate.
|
||||
/// Sprint: SPRINT_20260219_013 (SEE-03)
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Sprint", "20260219.013")]
|
||||
public sealed class ExecutionEvidenceGateTests
|
||||
{
|
||||
private readonly TimeProvider _fixedTimeProvider = new FixedTimeProvider(
|
||||
new DateTimeOffset(2026, 2, 19, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
#region Gate disabled
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WhenDisabled_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate(enabled: false);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("disabled", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Environment filtering
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_NonRequiredEnvironment_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("development", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("not required", result.Reason!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiredEnvironment_EnforcesEvidence()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
Assert.Contains("required", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evidence present
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_EvidencePresent_ReturnsPass()
|
||||
{
|
||||
var gate = CreateGate();
|
||||
var context = CreateContext("production", hasEvidence: true);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("present", result.Reason!);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Missing evidence actions
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingEvidence_WarnMode_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Warn);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("warn", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_MissingEvidence_BlockMode_ReturnsFail()
|
||||
{
|
||||
var gate = CreateGate(missingAction: PolicyGateDecisionType.Block);
|
||||
var context = CreateContext("production", hasEvidence: false);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.False(result.Passed);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Quality checks
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_InsufficientHotSymbols_ReturnsPassWithWarning()
|
||||
{
|
||||
var gate = CreateGate(minHotSymbols: 10);
|
||||
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 2);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("insufficient", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SufficientHotSymbols_ReturnsCleanPass()
|
||||
{
|
||||
var gate = CreateGate(minHotSymbols: 3);
|
||||
var context = CreateContext("production", hasEvidence: true, hotSymbolCount: 15);
|
||||
|
||||
var result = await gate.EvaluateAsync(context);
|
||||
|
||||
Assert.True(result.Passed);
|
||||
Assert.Contains("meets", result.Reason!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private ExecutionEvidenceGate CreateGate(
|
||||
bool enabled = true,
|
||||
PolicyGateDecisionType missingAction = PolicyGateDecisionType.Warn,
|
||||
int minHotSymbols = 3,
|
||||
int minCallPaths = 1)
|
||||
{
|
||||
var opts = new PolicyGateOptions
|
||||
{
|
||||
ExecutionEvidence = new ExecutionEvidenceGateOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
MissingEvidenceAction = missingAction,
|
||||
MinHotSymbolCount = minHotSymbols,
|
||||
MinUniqueCallPaths = minCallPaths,
|
||||
RequiredEnvironments = new List<string> { "production" },
|
||||
},
|
||||
};
|
||||
var monitor = new StaticOptionsMonitor<PolicyGateOptions>(opts);
|
||||
return new ExecutionEvidenceGate(monitor, NullLogger<ExecutionEvidenceGate>.Instance, _fixedTimeProvider);
|
||||
}
|
||||
|
||||
private static PolicyGateContext CreateContext(
|
||||
string environment,
|
||||
bool hasEvidence,
|
||||
int? hotSymbolCount = null,
|
||||
int? uniqueCallPaths = null)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>();
|
||||
if (hasEvidence)
|
||||
{
|
||||
metadata["has_execution_evidence"] = "true";
|
||||
}
|
||||
if (hotSymbolCount.HasValue)
|
||||
{
|
||||
metadata["execution_evidence_hot_symbol_count"] = hotSymbolCount.Value.ToString();
|
||||
}
|
||||
if (uniqueCallPaths.HasValue)
|
||||
{
|
||||
metadata["execution_evidence_unique_call_paths"] = uniqueCallPaths.Value.ToString();
|
||||
}
|
||||
|
||||
return new PolicyGateContext
|
||||
{
|
||||
Environment = environment,
|
||||
SubjectKey = "test-subject",
|
||||
Metadata = metadata,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _fixedTime;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _fixedTime;
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public StaticOptionsMonitor(T value) => _value = value;
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user