348 lines
10 KiB
C#
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;
|
|
}
|
|
}
|