Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism
- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency. - Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling. - Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies. - Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification. - Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BudgetEnforcementIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251226_002_BE_budget_enforcement
|
||||
// Task: BUDGET-11 - Integration tests for budget enforcement
|
||||
// Description: Integration tests for window reset, consumption, threshold transitions, notifications
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Gates;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
public sealed class BudgetEnforcementIntegrationTests
|
||||
{
|
||||
private readonly InMemoryBudgetStore _store = new();
|
||||
private readonly BudgetLedger _ledger;
|
||||
|
||||
public BudgetEnforcementIntegrationTests()
|
||||
{
|
||||
_ledger = new BudgetLedger(_store, NullLogger<BudgetLedger>.Instance);
|
||||
}
|
||||
|
||||
#region Window Management Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_DifferentWindows_AreIndependent()
|
||||
{
|
||||
// Arrange: Create budgets for two different windows
|
||||
var serviceId = "window-test-service";
|
||||
var window1 = "2025-01";
|
||||
var window2 = "2025-02";
|
||||
|
||||
// Act: Create and consume in window 1
|
||||
var budget1 = await _ledger.GetBudgetAsync(serviceId, window1);
|
||||
await _ledger.ConsumeAsync(serviceId, 50, "release-jan");
|
||||
|
||||
// Create new budget in window 2 (simulating monthly reset)
|
||||
var budget2 = await _ledger.GetBudgetAsync(serviceId, window2);
|
||||
|
||||
// Assert: Window 2 should start fresh
|
||||
budget2.Consumed.Should().Be(0);
|
||||
budget2.Allocated.Should().Be(200); // Default tier 1 allocation
|
||||
budget2.Status.Should().Be(BudgetStatus.Green);
|
||||
|
||||
// Window 1 should still have consumption
|
||||
var budget1Again = await _ledger.GetBudgetAsync(serviceId, window1);
|
||||
budget1Again.Consumed.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Budget_WindowReset_DoesNotCarryOver()
|
||||
{
|
||||
// Arrange: Heavily consume in current window
|
||||
var serviceId = "reset-test-service";
|
||||
var currentWindow = DateTimeOffset.UtcNow.ToString("yyyy-MM");
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId, currentWindow);
|
||||
await _ledger.ConsumeAsync(serviceId, 150, "heavy-release");
|
||||
|
||||
// Simulate next month
|
||||
var nextWindow = DateTimeOffset.UtcNow.AddMonths(1).ToString("yyyy-MM");
|
||||
|
||||
// Act: Get budget for next window
|
||||
var nextBudget = await _ledger.GetBudgetAsync(serviceId, nextWindow);
|
||||
|
||||
// Assert: No carry-over
|
||||
nextBudget.Consumed.Should().Be(0);
|
||||
nextBudget.Remaining.Should().Be(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Consumption Tests
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_MultipleReleases_AccumulatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "multi-release-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Multiple consumption operations
|
||||
await _ledger.ConsumeAsync(serviceId, 20, "release-1");
|
||||
await _ledger.ConsumeAsync(serviceId, 15, "release-2");
|
||||
await _ledger.ConsumeAsync(serviceId, 30, "release-3");
|
||||
|
||||
// Assert
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().Be(65);
|
||||
budget.Remaining.Should().Be(135);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_UpToExactLimit_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "exact-limit-service";
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Consume exactly to the limit
|
||||
var result = await _ledger.ConsumeAsync(serviceId, 200, "max-release");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Budget.Consumed.Should().Be(200);
|
||||
result.Budget.Remaining.Should().Be(0);
|
||||
result.Budget.Status.Should().Be(BudgetStatus.Exhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_AttemptOverBudget_Fails()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "over-budget-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 180, "heavy-release");
|
||||
|
||||
// Act: Try to consume more than remaining
|
||||
var result = await _ledger.ConsumeAsync(serviceId, 25, "overflow-release");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("Insufficient");
|
||||
|
||||
// Budget should remain unchanged
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().Be(180);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consume_ZeroPoints_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "zero-point-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act
|
||||
var result = await _ledger.ConsumeAsync(serviceId, 0, "no-risk-release");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Budget.Consumed.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threshold Transition Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ThresholdTransition_GreenToYellow()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "threshold-gy-service";
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Green);
|
||||
|
||||
// Act: Consume 40% (threshold boundary)
|
||||
await _ledger.ConsumeAsync(serviceId, 80, "transition-release");
|
||||
|
||||
// Assert
|
||||
var updatedBudget = await _ledger.GetBudgetAsync(serviceId);
|
||||
updatedBudget.Status.Should().Be(BudgetStatus.Yellow);
|
||||
updatedBudget.PercentageUsed.Should().Be(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThresholdTransition_YellowToRed()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "threshold-yr-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 80, "initial-release");
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Yellow);
|
||||
|
||||
// Act: Consume to 70% (threshold boundary)
|
||||
await _ledger.ConsumeAsync(serviceId, 60, "transition-release");
|
||||
|
||||
// Assert
|
||||
var updatedBudget = await _ledger.GetBudgetAsync(serviceId);
|
||||
updatedBudget.Status.Should().Be(BudgetStatus.Red);
|
||||
updatedBudget.PercentageUsed.Should().Be(70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThresholdTransition_RedToExhausted()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "threshold-re-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 140, "heavy-release");
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Red);
|
||||
|
||||
// Act: Consume to 100%
|
||||
await _ledger.ConsumeAsync(serviceId, 60, "final-release");
|
||||
|
||||
// Assert
|
||||
var updatedBudget = await _ledger.GetBudgetAsync(serviceId);
|
||||
updatedBudget.Status.Should().Be(BudgetStatus.Exhausted);
|
||||
updatedBudget.PercentageUsed.Should().Be(100);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, BudgetStatus.Green)]
|
||||
[InlineData(39, BudgetStatus.Green)]
|
||||
[InlineData(40, BudgetStatus.Yellow)]
|
||||
[InlineData(69, BudgetStatus.Yellow)]
|
||||
[InlineData(70, BudgetStatus.Red)]
|
||||
[InlineData(99, BudgetStatus.Red)]
|
||||
[InlineData(100, BudgetStatus.Exhausted)]
|
||||
public async Task ThresholdBoundaries_AreCorrect(int percentageConsumed, BudgetStatus expectedStatus)
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = $"boundary-{percentageConsumed}-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Consume to specific percentage (200 * percentage / 100)
|
||||
var pointsToConsume = 200 * percentageConsumed / 100;
|
||||
if (pointsToConsume > 0)
|
||||
{
|
||||
await _ledger.ConsumeAsync(serviceId, pointsToConsume, "boundary-release");
|
||||
}
|
||||
|
||||
// Assert
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(expectedStatus);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Earned Capacity Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AdjustAllocation_IncreasesCapacity_ChangesThreshold()
|
||||
{
|
||||
// Arrange: Start in Red status
|
||||
var serviceId = "capacity-increase-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 150, "heavy-release"); // 75% = Red
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Status.Should().Be(BudgetStatus.Red);
|
||||
|
||||
// Act: Add earned capacity
|
||||
var adjusted = await _ledger.AdjustAllocationAsync(serviceId, 50, "earned capacity");
|
||||
|
||||
// Assert: Status should improve
|
||||
adjusted.Allocated.Should().Be(250);
|
||||
adjusted.PercentageUsed.Should().Be(60); // 150/250 = 60%
|
||||
adjusted.Status.Should().Be(BudgetStatus.Yellow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdjustAllocation_DecreaseCapacity_ChangesThreshold()
|
||||
{
|
||||
// Arrange: Start in Yellow status
|
||||
var serviceId = "capacity-decrease-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 80, "initial-release"); // 40% = Yellow
|
||||
|
||||
// Act: Reduce capacity (penalty)
|
||||
var adjusted = await _ledger.AdjustAllocationAsync(serviceId, -50, "incident penalty");
|
||||
|
||||
// Assert: Status should worsen
|
||||
adjusted.Allocated.Should().Be(150);
|
||||
adjusted.PercentageUsed.Should().BeApproximately(53.33m, 0.1m); // 80/150
|
||||
adjusted.Status.Should().Be(BudgetStatus.Yellow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region History and Audit Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_ReturnsAllEntriesForWindow()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "history-service";
|
||||
var window = DateTimeOffset.UtcNow.ToString("yyyy-MM");
|
||||
await _ledger.GetBudgetAsync(serviceId, window);
|
||||
|
||||
// Act: Create multiple entries
|
||||
await _ledger.ConsumeAsync(serviceId, 10, "release-1");
|
||||
await _ledger.ConsumeAsync(serviceId, 20, "release-2");
|
||||
await _ledger.ConsumeAsync(serviceId, 30, "release-3");
|
||||
|
||||
var history = await _ledger.GetHistoryAsync(serviceId, window);
|
||||
|
||||
// Assert
|
||||
history.Should().HaveCount(3);
|
||||
history.Should().Contain(e => e.ReleaseId == "release-1" && e.RiskPoints == 10);
|
||||
history.Should().Contain(e => e.ReleaseId == "release-2" && e.RiskPoints == 20);
|
||||
history.Should().Contain(e => e.ReleaseId == "release-3" && e.RiskPoints == 30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_EmptyForNewService()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "new-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act
|
||||
var history = await _ledger.GetHistoryAsync(serviceId);
|
||||
|
||||
// Assert
|
||||
history.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetHistory_DifferentWindows_AreIsolated()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "multi-window-history";
|
||||
var window1 = "2025-01";
|
||||
var window2 = "2025-02";
|
||||
|
||||
await _ledger.GetBudgetAsync(serviceId, window1);
|
||||
await _store.AddEntryAsync(new BudgetEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString(),
|
||||
ServiceId = serviceId,
|
||||
Window = window1,
|
||||
ReleaseId = "jan-release",
|
||||
RiskPoints = 50,
|
||||
ConsumedAt = DateTimeOffset.UtcNow
|
||||
}, CancellationToken.None);
|
||||
|
||||
await _ledger.GetBudgetAsync(serviceId, window2);
|
||||
await _store.AddEntryAsync(new BudgetEntry
|
||||
{
|
||||
EntryId = Guid.NewGuid().ToString(),
|
||||
ServiceId = serviceId,
|
||||
Window = window2,
|
||||
ReleaseId = "feb-release",
|
||||
RiskPoints = 30,
|
||||
ConsumedAt = DateTimeOffset.UtcNow
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var historyW1 = await _ledger.GetHistoryAsync(serviceId, window1);
|
||||
var historyW2 = await _ledger.GetHistoryAsync(serviceId, window2);
|
||||
|
||||
// Assert
|
||||
historyW1.Should().HaveCount(1);
|
||||
historyW1[0].ReleaseId.Should().Be("jan-release");
|
||||
|
||||
historyW2.Should().HaveCount(1);
|
||||
historyW2[0].ReleaseId.Should().Be("feb-release");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tier-Based Allocation Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(ServiceTier.Internal, 300)]
|
||||
[InlineData(ServiceTier.CustomerFacingNonCritical, 200)]
|
||||
[InlineData(ServiceTier.CustomerFacingCritical, 120)]
|
||||
[InlineData(ServiceTier.SafetyCritical, 80)]
|
||||
public void DefaultAllocations_MatchTierRiskProfile(ServiceTier tier, int expectedAllocation)
|
||||
{
|
||||
// Assert
|
||||
DefaultBudgetAllocations.GetMonthlyAllocation(tier).Should().Be(expectedAllocation);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrent Access Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentConsumption_IsThreadSafe()
|
||||
{
|
||||
// Arrange
|
||||
var serviceId = "concurrent-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
|
||||
// Act: Concurrent consumption attempts
|
||||
var tasks = Enumerable.Range(0, 10)
|
||||
.Select(i => _ledger.ConsumeAsync(serviceId, 5, $"release-{i}"))
|
||||
.ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: All should succeed, total consumed should be 50
|
||||
results.Should().OnlyContain(r => r.IsSuccess);
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().Be(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentConsumption_RespectsLimit()
|
||||
{
|
||||
// Arrange: Set up a budget with limited capacity
|
||||
var serviceId = "limited-concurrent-service";
|
||||
await _ledger.GetBudgetAsync(serviceId);
|
||||
await _ledger.ConsumeAsync(serviceId, 180, "initial-large-release");
|
||||
|
||||
// Act: Concurrent attempts that would exceed limit
|
||||
var tasks = Enumerable.Range(0, 5)
|
||||
.Select(i => _ledger.ConsumeAsync(serviceId, 10, $"concurrent-{i}"))
|
||||
.ToList();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: At least some should fail (only 20 remaining)
|
||||
results.Count(r => r.IsSuccess).Should().BeLessThanOrEqualTo(2);
|
||||
|
||||
var budget = await _ledger.GetBudgetAsync(serviceId);
|
||||
budget.Consumed.Should().BeLessThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CicdGateIntegrationTests.cs
|
||||
// Sprint: SPRINT_20251226_001_BE_cicd_gate_integration
|
||||
// Task: CICD-GATE-09 - Integration tests: Zastava webhook -> Scheduler -> Policy Engine -> verdict
|
||||
// Description: End-to-end integration tests for CI/CD release gate workflow
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Gates;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the CI/CD gate evaluation workflow.
|
||||
/// Tests the complete flow from webhook trigger through verdict.
|
||||
/// </summary>
|
||||
public class CicdGateIntegrationTests
|
||||
{
|
||||
private readonly PolicyGateOptions _options;
|
||||
private readonly IOptionsMonitor<PolicyGateOptions> _optionsMonitor;
|
||||
|
||||
public CicdGateIntegrationTests()
|
||||
{
|
||||
_options = new PolicyGateOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DefaultTimeout = TimeSpan.FromSeconds(60),
|
||||
AllowBypassWithJustification = true,
|
||||
MinimumJustificationLength = 20
|
||||
};
|
||||
|
||||
var monitor = Substitute.For<IOptionsMonitor<PolicyGateOptions>>();
|
||||
monitor.CurrentValue.Returns(_options);
|
||||
monitor.Get(Arg.Any<string?>()).Returns(_options);
|
||||
_optionsMonitor = monitor;
|
||||
}
|
||||
|
||||
#region Gate Evaluation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_NewImageWithNoDelta_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0001",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CU",
|
||||
UncertaintyTier = "T4",
|
||||
GraphHash = "blake3:abc",
|
||||
PathLength = -1,
|
||||
Confidence = 0.95
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
decision.BlockedBy.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_NewCriticalVulnerability_ReturnsBlock()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0002",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR", // Supplier reachable - should block
|
||||
UncertaintyTier = "T1",
|
||||
GraphHash = "blake3:def",
|
||||
PathLength = 3,
|
||||
RiskScore = 9.8
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
decision.BlockedBy.Should().Be("LatticeState");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_HighUncertainty_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0003",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CU",
|
||||
UncertaintyTier = "T2", // High uncertainty - should warn
|
||||
GraphHash = "blake3:ghi",
|
||||
PathLength = -1,
|
||||
Confidence = 0.6
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Warn);
|
||||
decision.Advisory.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bypass and Override Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_BlockWithValidBypass_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0004",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR",
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "Emergency hotfix approved by security team - JIRA-12345"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Warn);
|
||||
decision.Advisory.Should().Contain("Override accepted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_BlockWithInvalidBypass_RemainsBlocked()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0005",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR",
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "yolo" // Too short
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Exit Code Tests
|
||||
|
||||
[Fact]
|
||||
public void GateExitCode_Pass_ReturnsZero()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exitCode = MapDecisionToExitCode(PolicyGateDecisionType.Allow);
|
||||
|
||||
// Assert
|
||||
exitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateExitCode_Warn_ReturnsOne()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exitCode = MapDecisionToExitCode(PolicyGateDecisionType.Warn);
|
||||
|
||||
// Assert
|
||||
exitCode.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateExitCode_Block_ReturnsTwo()
|
||||
{
|
||||
// Arrange & Act
|
||||
var exitCode = MapDecisionToExitCode(PolicyGateDecisionType.Block);
|
||||
|
||||
// Assert
|
||||
exitCode.Should().Be(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Batch Evaluation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_MultipleVulnerabilities_ReturnsWorstCase()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var requests = new[]
|
||||
{
|
||||
CreateRequest("not_affected", "CU", "T4"), // Pass
|
||||
CreateRequest("not_affected", "CU", "T2"), // Warn
|
||||
CreateRequest("not_affected", "SR", "T1") // Block
|
||||
};
|
||||
|
||||
// Act
|
||||
var decisions = new List<PolicyGateDecision>();
|
||||
foreach (var request in requests)
|
||||
{
|
||||
decisions.Add(await evaluator.EvaluateAsync(request));
|
||||
}
|
||||
|
||||
var worstDecision = decisions
|
||||
.OrderByDescending(d => (int)d.Decision)
|
||||
.First();
|
||||
|
||||
// Assert
|
||||
worstDecision.Decision.Should().Be(PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateBatch_AllPass_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var requests = new[]
|
||||
{
|
||||
CreateRequest("not_affected", "CU", "T4"),
|
||||
CreateRequest("not_affected", "CU", "T4"),
|
||||
CreateRequest("affected", "CR", "T4")
|
||||
};
|
||||
|
||||
// Act
|
||||
var decisions = new List<PolicyGateDecision>();
|
||||
foreach (var request in requests)
|
||||
{
|
||||
decisions.Add(await evaluator.EvaluateAsync(request));
|
||||
}
|
||||
|
||||
// Assert
|
||||
decisions.All(d => d.Decision == PolicyGateDecisionType.Allow).Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Audit Trail Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_CreatesAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0006",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CU",
|
||||
UncertaintyTier = "T4",
|
||||
GraphHash = "blake3:xyz"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.GateId.Should().NotBeNullOrEmpty();
|
||||
decision.Subject.Should().NotBeNull();
|
||||
decision.Subject.VulnId.Should().Be("CVE-2025-0006");
|
||||
decision.Evidence.Should().NotBeNull();
|
||||
decision.Gates.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_BypassAttempt_LogsAuditEntry()
|
||||
{
|
||||
// Arrange
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0007",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "SR",
|
||||
AllowOverride = true,
|
||||
OverrideJustification = "Production hotfix required - incident INC-9999 - approved by CISO"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert - bypass should be recorded
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Warn);
|
||||
decision.Advisory.Should().Contain("Override");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Disabled Gate Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_WhenDisabled_ReturnsAllow()
|
||||
{
|
||||
// Arrange
|
||||
var options = new PolicyGateOptions { Enabled = false };
|
||||
var monitor = Substitute.For<IOptionsMonitor<PolicyGateOptions>>();
|
||||
monitor.CurrentValue.Returns(options);
|
||||
monitor.Get(Arg.Any<string?>()).Returns(options);
|
||||
|
||||
var evaluator = new PolicyGateEvaluator(
|
||||
monitor,
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGateEvaluator>.Instance);
|
||||
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-0008",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = "not_affected",
|
||||
LatticeState = "CR", // Would normally block
|
||||
UncertaintyTier = "T1"
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
decision.Advisory.Should().Contain("disabled");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Baseline Comparison Tests
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_NewVulnNotInBaseline_ReturnsBlock()
|
||||
{
|
||||
// Arrange - new critical vuln not present in baseline
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-NEW",
|
||||
Purl = "pkg:oci/myapp@sha256:newimage",
|
||||
RequestedStatus = "affected",
|
||||
LatticeState = "CR",
|
||||
UncertaintyTier = "T1",
|
||||
RiskScore = 9.5,
|
||||
// No baseline reference = new finding
|
||||
IsNewFinding = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert - new critical findings should warn at minimum
|
||||
decision.Decision.Should().BeOneOf(PolicyGateDecisionType.Warn, PolicyGateDecisionType.Block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateGate_VulnExistsInBaseline_ReturnsAllow()
|
||||
{
|
||||
// Arrange - existing vuln already in baseline
|
||||
var evaluator = CreateEvaluator();
|
||||
var request = new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = "CVE-2025-EXISTING",
|
||||
Purl = "pkg:oci/myapp@sha256:newimage",
|
||||
RequestedStatus = "affected",
|
||||
LatticeState = "CR",
|
||||
UncertaintyTier = "T4",
|
||||
RiskScore = 7.0,
|
||||
IsNewFinding = false // Already in baseline
|
||||
};
|
||||
|
||||
// Act
|
||||
var decision = await evaluator.EvaluateAsync(request);
|
||||
|
||||
// Assert - existing findings should pass
|
||||
decision.Decision.Should().Be(PolicyGateDecisionType.Allow);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Helper methods
|
||||
|
||||
private PolicyGateEvaluator CreateEvaluator()
|
||||
{
|
||||
return new PolicyGateEvaluator(
|
||||
_optionsMonitor,
|
||||
TimeProvider.System,
|
||||
NullLogger<PolicyGateEvaluator>.Instance);
|
||||
}
|
||||
|
||||
private static PolicyGateRequest CreateRequest(
|
||||
string status,
|
||||
string latticeState,
|
||||
string uncertaintyTier)
|
||||
{
|
||||
return new PolicyGateRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
VulnId = $"CVE-2025-{Guid.NewGuid():N}",
|
||||
Purl = "pkg:oci/myapp@sha256:abc123",
|
||||
RequestedStatus = status,
|
||||
LatticeState = latticeState,
|
||||
UncertaintyTier = uncertaintyTier,
|
||||
GraphHash = "blake3:test",
|
||||
PathLength = -1,
|
||||
Confidence = 0.9
|
||||
};
|
||||
}
|
||||
|
||||
private static int MapDecisionToExitCode(PolicyGateDecisionType decision)
|
||||
{
|
||||
return decision switch
|
||||
{
|
||||
PolicyGateDecisionType.Allow => 0,
|
||||
PolicyGateDecisionType.Warn => 1,
|
||||
PolicyGateDecisionType.Block => 2,
|
||||
_ => 10
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional tests for webhook-triggered gate evaluations.
|
||||
/// </summary>
|
||||
public class WebhookGateIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void DockerRegistryWebhook_ParsesDigest_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var webhookPayload = """
|
||||
{
|
||||
"events": [{
|
||||
"action": "push",
|
||||
"target": {
|
||||
"repository": "myapp",
|
||||
"digest": "sha256:abc123def456"
|
||||
}
|
||||
}]
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var parsed = System.Text.Json.JsonDocument.Parse(webhookPayload);
|
||||
var events = parsed.RootElement.GetProperty("events");
|
||||
var firstEvent = events[0];
|
||||
var digest = firstEvent.GetProperty("target").GetProperty("digest").GetString();
|
||||
|
||||
// Assert
|
||||
digest.Should().Be("sha256:abc123def456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HarborWebhook_ParsesDigest_Correctly()
|
||||
{
|
||||
// Arrange
|
||||
var webhookPayload = """
|
||||
{
|
||||
"type": "PUSH_ARTIFACT",
|
||||
"event_data": {
|
||||
"resources": [{
|
||||
"digest": "sha256:xyz789abc123",
|
||||
"resource_url": "harbor.example.com/myproject/myapp@sha256:xyz789abc123"
|
||||
}]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var parsed = System.Text.Json.JsonDocument.Parse(webhookPayload);
|
||||
var resources = parsed.RootElement
|
||||
.GetProperty("event_data")
|
||||
.GetProperty("resources");
|
||||
var digest = resources[0].GetProperty("digest").GetString();
|
||||
|
||||
// Assert
|
||||
digest.Should().Be("sha256:xyz789abc123");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user