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:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -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
}

View File

@@ -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");
}
}