doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -0,0 +1,288 @@
// -----------------------------------------------------------------------------
// GatesEndpointsIntegrationTests.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-006 - Integration tests for gates endpoint
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Policy.Gateway.Endpoints;
using Xunit;
namespace StellaOps.Policy.Gateway.Tests.Endpoints;
public sealed class GatesEndpointsIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public GatesEndpointsIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Add test doubles
services.AddMemoryCache();
});
});
_client = _factory.CreateClient();
}
#region GET /gates/{bom_ref} Tests
[Fact]
public async Task GetGateStatus_ValidBomRef_ReturnsOk()
{
// Arrange
var bomRef = Uri.EscapeDataString("pkg:npm/lodash@4.17.21");
// Act
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
Assert.NotNull(content);
Assert.Equal("pkg:npm/lodash@4.17.21", content.BomRef);
Assert.Contains(content.GateDecision, new[] { "pass", "warn", "block" });
Assert.NotEqual(default, content.CheckedAt);
}
[Fact]
public async Task GetGateStatus_EncodedBomRef_DecodesCorrectly()
{
// Arrange - Docker image with SHA
var bomRef = Uri.EscapeDataString("pkg:docker/acme/api@sha256:abc123def456");
// Act
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
Assert.Equal("pkg:docker/acme/api@sha256:abc123def456", content?.BomRef);
}
[Fact]
public async Task GetGateStatus_ResolvedComponent_IncludesVerdictHash()
{
// Arrange - Use a known-clean component
var bomRef = Uri.EscapeDataString("pkg:npm/clean-package@1.0.0");
// Act
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
// Assert
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
if (content?.State == "resolved" && content.GateDecision == "pass")
{
Assert.NotNull(content.VerdictHash);
Assert.StartsWith("sha256:", content.VerdictHash);
}
}
[Fact]
public async Task GetGateStatus_UnknownsExist_ReturnsUnknownsList()
{
// Arrange - Use component that may have unknowns
var bomRef = Uri.EscapeDataString("pkg:npm/test-unknowns@1.0.0");
// Act
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
// Assert
var content = await response.Content.ReadFromJsonAsync<GateStatusResponse>();
Assert.NotNull(content);
Assert.NotNull(content.Unknowns);
if (content.Unknowns.Count > 0)
{
var unknown = content.Unknowns[0];
Assert.NotEqual(Guid.Empty, unknown.UnknownId);
Assert.Contains(unknown.Band, new[] { "hot", "warm", "cold" });
Assert.Contains(unknown.State, new[] { "pending", "under_review", "escalated", "resolved", "rejected" });
}
}
[Fact]
public async Task GetGateStatus_CachesResponse()
{
// Arrange
var bomRef = Uri.EscapeDataString("pkg:npm/cached@1.0.0");
// Act - Make two requests
var response1 = await _client.GetAsync($"/api/v1/gates/{bomRef}");
var response2 = await _client.GetAsync($"/api/v1/gates/{bomRef}");
// Assert
var content1 = await response1.Content.ReadFromJsonAsync<GateStatusResponse>();
var content2 = await response2.Content.ReadFromJsonAsync<GateStatusResponse>();
// Same result should be returned (from cache)
Assert.Equal(content1?.GateDecision, content2?.GateDecision);
Assert.Equal(content1?.State, content2?.State);
}
#endregion
#region POST /gates/{bom_ref}/check Tests
[Fact]
public async Task CheckGate_WithoutVerdict_ReturnsDecision()
{
// Arrange
var bomRef = Uri.EscapeDataString("pkg:npm/test@1.0.0");
var request = new GateCheckRequest { ProposedVerdict = null };
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/check", request);
// Assert
Assert.True(response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.Forbidden);
var content = await response.Content.ReadFromJsonAsync<GateCheckResponse>();
Assert.NotNull(content);
Assert.Contains(content.Decision, new[] { "pass", "warn", "block" });
}
[Fact]
public async Task CheckGate_NotAffectedVerdict_WithUnknowns_Blocks()
{
// Arrange - Component with unknowns
var bomRef = Uri.EscapeDataString("pkg:npm/has-unknowns@1.0.0");
var request = new GateCheckRequest { ProposedVerdict = "not_affected" };
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/check", request);
// Assert - May be blocked if unknowns exist
var content = await response.Content.ReadFromJsonAsync<GateCheckResponse>();
Assert.NotNull(content);
if (content.BlockingUnknownIds.Count > 0)
{
Assert.Equal("block", content.Decision);
Assert.Contains("not_affected", content.Reason, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public async Task CheckGate_Blocked_Returns403()
{
// Arrange - Force a blocking scenario
var bomRef = Uri.EscapeDataString("pkg:npm/blocked-test@1.0.0");
var request = new GateCheckRequest { ProposedVerdict = "not_affected" };
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/check", request);
// Assert
var content = await response.Content.ReadFromJsonAsync<GateCheckResponse>();
if (content?.Decision == "block")
{
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
#endregion
#region POST /gates/{bom_ref}/exception Tests
[Fact]
public async Task RequestException_ReturnsResponse()
{
// Arrange
var bomRef = Uri.EscapeDataString("pkg:npm/exception-test@1.0.0");
var request = new ExceptionRequest
{
UnknownIds = [Guid.NewGuid()],
Justification = "Critical business deadline - risk accepted by security team"
};
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/exception", request);
// Assert
var content = await response.Content.ReadFromJsonAsync<ExceptionResponse>();
Assert.NotNull(content);
Assert.NotEqual(default, content.RequestedAt);
// By default, exceptions are not auto-granted
if (!content.Granted)
{
Assert.NotNull(content.DenialReason);
}
}
[Fact]
public async Task RequestException_WithoutJustification_ReturnsBadRequest()
{
// Arrange
var bomRef = Uri.EscapeDataString("pkg:npm/no-justification@1.0.0");
var request = new { UnknownIds = new[] { Guid.NewGuid() } }; // Missing justification
// Act
var response = await _client.PostAsJsonAsync($"/api/v1/gates/{bomRef}/exception", request);
// Assert - Should reject missing required field
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
#endregion
#region Response Format Tests
[Fact]
public async Task Response_UsesSnakeCaseJson()
{
// Arrange
var bomRef = Uri.EscapeDataString("pkg:npm/json-test@1.0.0");
// Act
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
var json = await response.Content.ReadAsStringAsync();
// Assert - Check for snake_case property names
Assert.Contains("\"bom_ref\"", json);
Assert.Contains("\"gate_decision\"", json);
Assert.Contains("\"checked_at\"", json);
// Should NOT contain camelCase
Assert.DoesNotContain("\"bomRef\"", json);
Assert.DoesNotContain("\"gateDecision\"", json);
Assert.DoesNotContain("\"checkedAt\"", json);
}
[Fact]
public async Task Response_IncludesUnknownDetails()
{
// Arrange
var bomRef = Uri.EscapeDataString("pkg:npm/detailed@1.0.0");
// Act
var response = await _client.GetAsync($"/api/v1/gates/{bomRef}");
var json = await response.Content.ReadAsStringAsync();
// Assert - Check for expected fields in unknowns array
if (json.Contains("\"unknowns\":"))
{
Assert.Contains("\"unknown_id\"", json);
Assert.Contains("\"band\"", json);
Assert.Contains("\"state\"", json);
}
}
#endregion
}
// Placeholder for test Program class if not available
#if !INTEGRATION_TEST_HOST
public class Program { }
#endif

View File

@@ -0,0 +1,545 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-006 - Integration tests for Score Gate API Endpoint
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using StellaOps.Policy.Gateway.Contracts;
using StellaOps.TestKit;
using Xunit;
using GatewayProgram = StellaOps.Policy.Gateway.Program;
namespace StellaOps.Policy.Gateway.Tests;
/// <summary>
/// Integration tests for score-based gate evaluation endpoints.
/// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
/// </summary>
public sealed class ScoreGateEndpointsTests : IClassFixture<WebApplicationFactory<GatewayProgram>>
{
private readonly HttpClient _client;
private readonly WebApplicationFactory<GatewayProgram> _factory;
public ScoreGateEndpointsTests(WebApplicationFactory<GatewayProgram> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.UseSetting("Environment", "Testing");
});
_client = _factory.CreateClient();
_client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "test-tenant");
}
#region Health Check Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Health_ReturnsHealthy()
{
// Act
var response = await _client.GetAsync("/api/v1/gate/health", CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: CancellationToken.None);
result.GetProperty("status").GetString().Should().Be("healthy");
}
#endregion
#region Gate Evaluation Tests
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_WithHighScore_ReturnsBlock()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
CvssBase = 9.0,
Epss = 0.85,
Reachability = "function_level",
ExploitMaturity = "high",
PatchProofConfidence = 0.0
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Action.Should().Be(ScoreGateActions.Block);
result.Score.Should().BeGreaterOrEqualTo(0.65);
result.ExitCode.Should().Be(ScoreGateExitCodes.Block);
result.VerdictBundleId.Should().StartWith("sha256:");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_WithMediumScore_ReturnsWarn()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-5678@pkg:npm/express@4.0.0",
CvssBase = 5.5,
Epss = 0.25,
Reachability = "package_level",
ExploitMaturity = "poc",
PatchProofConfidence = 0.3
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Action.Should().BeOneOf(ScoreGateActions.Warn, ScoreGateActions.Pass);
result.VerdictBundleId.Should().StartWith("sha256:");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_WithLowScore_ReturnsPass()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-9999@pkg:npm/safe-package@1.0.0",
CvssBase = 2.0,
Epss = 0.01,
Reachability = "none",
ExploitMaturity = "none",
PatchProofConfidence = 0.9
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Action.Should().Be(ScoreGateActions.Pass);
result.Score.Should().BeLessThan(0.40);
result.ExitCode.Should().Be(ScoreGateExitCodes.Pass);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_WithNotAffectedVex_ReturnsPass()
{
// Arrange - authoritative VEX should auto-pass regardless of other scores
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-7777@pkg:npm/risky-lib@2.0.0",
CvssBase = 9.8,
Epss = 0.95,
Reachability = "caller",
ExploitMaturity = "high",
PatchProofConfidence = 0.0,
VexStatus = "not_affected",
VexSource = ".vex/internal-assessment"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Action.Should().Be(ScoreGateActions.Pass);
result.MatchedRules.Should().Contain("auto_pass_trusted_vex");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_WithIncludeVerdict_ReturnsFullBundle()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-1111@pkg:npm/test-lib@1.0.0",
CvssBase = 5.0,
Epss = 0.30,
IncludeVerdict = true
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.VerdictBundle.Should().NotBeNull();
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_IncludesBreakdown()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-2222@pkg:npm/breakdown-test@1.0.0",
CvssBase = 7.5,
Epss = 0.42,
Reachability = "function_level",
ExploitMaturity = "poc",
PatchProofConfidence = 0.3
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Breakdown.Should().NotBeNull();
result.Breakdown!.Count.Should().BeGreaterThan(0);
result.Breakdown.Should().Contain(b => b.Dimension == "CVSS Base" || b.Dimension == "Reachability");
}
#endregion
#region Validation Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Evaluate_WithoutFindingId_ReturnsBadRequest()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "",
CvssBase = 7.5,
Epss = 0.42
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Evaluate_WithNullBody_ReturnsBadRequest()
{
// Act
var response = await _client.PostAsJsonAsync<ScoreGateEvaluateRequest?>("/api/v1/gate/evaluate", null, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
#endregion
#region Policy Profile Tests
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_WithAdvisoryProfile_UsesAdvisoryFormula()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-3333@pkg:npm/advisory-test@1.0.0",
CvssBase = 7.0,
Epss = 0.50,
Reachability = "function_level",
ExploitMaturity = "poc",
PatchProofConfidence = 0.2,
PolicyProfile = "advisory"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Breakdown.Should().NotBeNull();
// Advisory formula should have CVSS, EPSS, Reachability, Exploit Maturity, Patch Proof dimensions
result.Breakdown!.Should().Contain(b => b.Symbol == "CVS" || b.Symbol == "EPS" || b.Symbol == "PPF");
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task Evaluate_WithLegacyProfile_UsesLegacyFormula()
{
// Arrange
var request = new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-4444@pkg:npm/legacy-test@1.0.0",
CvssBase = 7.0,
Epss = 0.50,
PolicyProfile = "legacy"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Breakdown.Should().NotBeNull();
// Legacy formula should have RCH, RTS, BKP, XPL, SRC, MIT dimensions
result.Breakdown!.Should().Contain(b => b.Symbol == "RCH" || b.Symbol == "RTS");
}
#endregion
#region Batch Evaluation Tests
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvaluateBatch_WithMultipleFindings_ReturnsAggregatedResults()
{
// Arrange
var request = new ScoreGateBatchEvaluateRequest
{
Findings =
[
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-0001@pkg:npm/safe@1.0.0",
CvssBase = 2.0,
Epss = 0.01
},
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-0002@pkg:npm/medium@1.0.0",
CvssBase = 5.5,
Epss = 0.35
},
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-0003@pkg:npm/risky@1.0.0",
CvssBase = 9.8,
Epss = 0.90,
ExploitMaturity = "high"
}
]
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Summary.Total.Should().Be(3);
result.Decisions.Should().HaveCount(3);
result.DurationMs.Should().BeGreaterOrEqualTo(0);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvaluateBatch_WithBlockedFinding_ReturnsBlockOverallAction()
{
// Arrange
var request = new ScoreGateBatchEvaluateRequest
{
Findings =
[
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-SAFE@pkg:npm/safe@1.0.0",
CvssBase = 2.0,
Epss = 0.01
},
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-BLOCKED@pkg:npm/risky@1.0.0",
CvssBase = 9.8,
Epss = 0.95,
Reachability = "caller",
ExploitMaturity = "high"
}
]
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.OverallAction.Should().Be(ScoreGateActions.Block);
result.ExitCode.Should().Be(ScoreGateExitCodes.Block);
result.Summary.Blocked.Should().BeGreaterOrEqualTo(1);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvaluateBatch_WithAllPassing_ReturnsPassOverallAction()
{
// Arrange
var request = new ScoreGateBatchEvaluateRequest
{
Findings =
[
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-LOW1@pkg:npm/safe1@1.0.0",
CvssBase = 2.0,
Epss = 0.01,
PatchProofConfidence = 0.9
},
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-LOW2@pkg:npm/safe2@1.0.0",
CvssBase = 3.0,
Epss = 0.05,
PatchProofConfidence = 0.8
}
]
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.OverallAction.Should().Be(ScoreGateActions.Pass);
result.ExitCode.Should().Be(ScoreGateExitCodes.Pass);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvaluateBatch_WithFailFast_StopsOnFirstBlock()
{
// Arrange
var request = new ScoreGateBatchEvaluateRequest
{
Findings =
[
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-FIRST@pkg:npm/first@1.0.0",
CvssBase = 9.8,
Epss = 0.95,
ExploitMaturity = "high"
},
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-SECOND@pkg:npm/second@1.0.0",
CvssBase = 9.8,
Epss = 0.95,
ExploitMaturity = "high"
}
],
Options = new ScoreGateBatchOptions { FailFast = true }
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.OverallAction.Should().Be(ScoreGateActions.Block);
// With fail-fast, it may stop before processing all
result.Summary.Blocked.Should().BeGreaterOrEqualTo(1);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvaluateBatch_WithIncludeVerdicts_ReturnsVerdictBundles()
{
// Arrange
var request = new ScoreGateBatchEvaluateRequest
{
Findings =
[
new ScoreGateEvaluateRequest
{
FindingId = "CVE-2024-VERBOSE@pkg:npm/verbose@1.0.0",
CvssBase = 5.0,
Epss = 0.30
}
],
Options = new ScoreGateBatchOptions { IncludeVerdicts = true }
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Decisions.Should().HaveCount(1);
result.Decisions[0].VerdictBundle.Should().NotBeNull();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EvaluateBatch_WithEmptyFindings_ReturnsBadRequest()
{
// Arrange
var request = new ScoreGateBatchEvaluateRequest
{
Findings = []
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EvaluateBatch_WithManyFindings_HandlesParallelEvaluation()
{
// Arrange - 20 findings for parallel test
var findings = Enumerable.Range(1, 20).Select(i => new ScoreGateEvaluateRequest
{
FindingId = $"CVE-2024-{i:D4}@pkg:npm/test-{i}@1.0.0",
CvssBase = (i % 10) + 1,
Epss = (i % 100) / 100.0
}).ToList();
var request = new ScoreGateBatchEvaluateRequest
{
Findings = findings,
Options = new ScoreGateBatchOptions { MaxParallelism = 5 }
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/gate/evaluate-batch", request, cancellationToken: CancellationToken.None);
// Assert
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.Forbidden);
var result = await response.Content.ReadFromJsonAsync<ScoreGateBatchEvaluateResponse>(cancellationToken: CancellationToken.None);
result.Should().NotBeNull();
result!.Summary.Total.Should().Be(20);
result.Decisions.Should().HaveCount(20);
}
#endregion
}

View File

@@ -0,0 +1,216 @@
// -----------------------------------------------------------------------------
// HttpOpaClientTests.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: Unit tests for HTTP OPA client
// -----------------------------------------------------------------------------
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gates.Opa;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
[Trait("Category", "Unit")]
public sealed class HttpOpaClientTests
{
[Fact]
public async Task EvaluateAsync_SuccessfulResponse_ReturnsResult()
{
var responseJson = """
{
"decision_id": "test-123",
"result": {
"allow": true,
"reason": "All checks passed"
}
}
""";
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(mockHandler)
{
BaseAddress = new Uri("http://localhost:8181")
};
var options = Options.Create(new OpaClientOptions
{
BaseUrl = "http://localhost:8181",
IncludeMetrics = true
});
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
var result = await client.EvaluateAsync("stella/test/allow", new { input = "test" });
Assert.True(result.Success);
Assert.Equal("test-123", result.DecisionId);
Assert.NotNull(result.Result);
}
[Fact]
public async Task EvaluateAsync_ServerError_ReturnsFailure()
{
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("Internal Server Error")
});
var httpClient = new HttpClient(mockHandler)
{
BaseAddress = new Uri("http://localhost:8181")
};
var options = Options.Create(new OpaClientOptions());
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
var result = await client.EvaluateAsync("stella/test/allow", new { input = "test" });
Assert.False(result.Success);
Assert.Contains("500", result.Error);
}
[Fact]
public async Task EvaluateAsync_TypedResult_DeserializesCorrectly()
{
var responseJson = """
{
"decision_id": "typed-123",
"result": {
"allow": false,
"reason": "Key not trusted",
"violations": ["Untrusted signing key"]
}
}
""";
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(mockHandler)
{
BaseAddress = new Uri("http://localhost:8181")
};
var options = Options.Create(new OpaClientOptions());
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
var result = await client.EvaluateAsync<TestPolicyResult>("stella/test/allow", new { input = "test" });
Assert.True(result.Success);
Assert.NotNull(result.Result);
Assert.False(result.Result.Allow);
Assert.Equal("Key not trusted", result.Result.Reason);
}
[Fact]
public async Task HealthCheckAsync_HealthyServer_ReturnsTrue()
{
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK));
var httpClient = new HttpClient(mockHandler)
{
BaseAddress = new Uri("http://localhost:8181")
};
var options = Options.Create(new OpaClientOptions());
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
var result = await client.HealthCheckAsync();
Assert.True(result);
}
[Fact]
public async Task HealthCheckAsync_UnhealthyServer_ReturnsFalse()
{
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
var httpClient = new HttpClient(mockHandler)
{
BaseAddress = new Uri("http://localhost:8181")
};
var options = Options.Create(new OpaClientOptions());
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
var result = await client.HealthCheckAsync();
Assert.False(result);
}
[Fact]
public async Task EvaluateAsync_WithMetrics_IncludesMetricsParameter()
{
var responseJson = """
{
"decision_id": "metrics-test",
"result": { "allow": true },
"metrics": {
"timer_rego_query_compile_ns": 12345,
"timer_rego_query_eval_ns": 67890,
"timer_server_handler_ns": 100000
}
}
""";
var mockHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(mockHandler)
{
BaseAddress = new Uri("http://localhost:8181")
};
var options = Options.Create(new OpaClientOptions { IncludeMetrics = true });
using var client = new HttpOpaClient(options, NullLogger<HttpOpaClient>.Instance, httpClient);
var result = await client.EvaluateAsync("stella/test/allow", new { });
Assert.True(result.Success);
Assert.NotNull(result.Metrics);
Assert.Equal(12345, result.Metrics.TimerRegoQueryCompileNs);
Assert.Equal(67890, result.Metrics.TimerRegoQueryEvalNs);
Assert.Equal(100000, result.Metrics.TimerServerHandlerNs);
}
private sealed record TestPolicyResult
{
public bool Allow { get; init; }
public string? Reason { get; init; }
public IReadOnlyList<string>? Violations { get; init; }
}
private sealed class MockHttpMessageHandler : HttpMessageHandler
{
private readonly HttpResponseMessage _response;
public MockHttpMessageHandler(HttpResponseMessage response)
{
_response = response;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_response);
}
}
}

View File

@@ -0,0 +1,201 @@
// -----------------------------------------------------------------------------
// OpaGateAdapterTests.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-007 - OPA Client Integration
// Description: Unit tests for OPA gate adapter
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Gates;
using StellaOps.Policy.Gates.Opa;
using StellaOps.Policy.TrustLattice;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
[Trait("Category", "Unit")]
public sealed class OpaGateAdapterTests
{
private static MergeResult CreateMergeResult() => new()
{
Status = VexStatus.Affected,
Confidence = 0.8,
HasConflicts = false,
AllClaims = ImmutableArray<ScoredClaim>.Empty,
WinningClaim = new ScoredClaim
{
SourceId = "test",
Status = VexStatus.Affected,
OriginalScore = 0.8,
AdjustedScore = 0.8,
ScopeSpecificity = 1,
Accepted = true,
Reason = "test"
},
Conflicts = ImmutableArray<ConflictRecord>.Empty
};
private static PolicyGateContext CreateContext(string environment = "production") => new()
{
Environment = environment,
HasReachabilityProof = true
};
[Fact]
public async Task EvaluateAsync_OpaReturnsAllow_ReturnsPass()
{
var mockClient = new MockOpaClient(new OpaTypedResult<object>
{
Success = true,
DecisionId = "test-decision-123",
Result = new { allow = true, reason = "All checks passed" }
});
var options = Options.Create(new OpaGateOptions
{
GateName = "TestOpaGate",
PolicyPath = "stella/test/allow"
});
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
Assert.Equal("TestOpaGate", result.GateName);
}
[Fact]
public async Task EvaluateAsync_OpaReturnsDeny_ReturnsFail()
{
var mockClient = new MockOpaClient(new OpaTypedResult<object>
{
Success = true,
DecisionId = "test-decision-456",
Result = new { allow = false, reason = "Untrusted key", violations = new[] { "Key not in trusted set" } }
});
var options = Options.Create(new OpaGateOptions
{
GateName = "TestOpaGate",
PolicyPath = "stella/test/allow"
});
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Contains("Untrusted key", result.Reason);
}
[Fact]
public async Task EvaluateAsync_OpaError_FailOnErrorTrue_ReturnsFail()
{
var mockClient = new MockOpaClient(new OpaTypedResult<object>
{
Success = false,
Error = "Connection refused"
});
var options = Options.Create(new OpaGateOptions
{
GateName = "TestOpaGate",
PolicyPath = "stella/test/allow",
FailOnError = true
});
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.False(result.Passed);
Assert.Contains("OPA evaluation error", result.Reason);
}
[Fact]
public async Task EvaluateAsync_OpaError_FailOnErrorFalse_ReturnsPass()
{
var mockClient = new MockOpaClient(new OpaTypedResult<object>
{
Success = false,
Error = "Connection refused"
});
var options = Options.Create(new OpaGateOptions
{
GateName = "TestOpaGate",
PolicyPath = "stella/test/allow",
FailOnError = false
});
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Passed);
}
[Fact]
public async Task EvaluateAsync_IncludesDecisionIdInDetails()
{
var mockClient = new MockOpaClient(new OpaTypedResult<object>
{
Success = true,
DecisionId = "decision-abc-123",
Result = new { allow = true }
});
var options = Options.Create(new OpaGateOptions
{
GateName = "TestOpaGate",
PolicyPath = "stella/test/allow"
});
var gate = new OpaGateAdapter(mockClient, options, NullLogger<OpaGateAdapter>.Instance);
var result = await gate.EvaluateAsync(CreateMergeResult(), CreateContext());
Assert.True(result.Details.ContainsKey("opaDecisionId"));
Assert.Equal("decision-abc-123", result.Details["opaDecisionId"]);
}
private sealed class MockOpaClient : IOpaClient
{
private readonly OpaTypedResult<object> _result;
public MockOpaClient(OpaTypedResult<object> result)
{
_result = result;
}
public Task<OpaEvaluationResult> EvaluateAsync(string policyPath, object input, CancellationToken cancellationToken = default)
{
return Task.FromResult(new OpaEvaluationResult
{
Success = _result.Success,
DecisionId = _result.DecisionId,
Result = _result.Result,
Error = _result.Error
});
}
public Task<OpaTypedResult<TResult>> EvaluateAsync<TResult>(string policyPath, object input, CancellationToken cancellationToken = default)
{
// For the mock, we just return what we have
return Task.FromResult(new OpaTypedResult<TResult>
{
Success = _result.Success,
DecisionId = _result.DecisionId,
Result = _result.Result is TResult typed ? typed : default,
Error = _result.Error
});
}
public Task<bool> HealthCheckAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task UploadPolicyAsync(string policyId, string regoContent, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
public Task DeletePolicyAsync(string policyId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
}

View File

@@ -0,0 +1,208 @@
// -----------------------------------------------------------------------------
// TrustedKeyRegistryTests.cs
// Sprint: SPRINT_20260118_017_Policy_gate_attestation_verification
// Task: TASK-017-005 - Trusted Key Registry
// Description: Unit tests for trusted key registry implementations
// -----------------------------------------------------------------------------
using StellaOps.Policy.Gates.Attestation;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
[Trait("Category", "Unit")]
public sealed class TrustedKeyRegistryTests
{
[Fact]
public async Task InMemoryRegistry_IsTrustedAsync_UnknownKey_ReturnsFalse()
{
var registry = new InMemoryTrustedKeyRegistry();
var result = await registry.IsTrustedAsync("unknown-key-id");
Assert.False(result);
}
[Fact]
public async Task InMemoryRegistry_AddAsync_ThenIsTrusted_ReturnsTrue()
{
var registry = new InMemoryTrustedKeyRegistry();
var key = new TrustedKey
{
KeyId = "key-001",
Fingerprint = "sha256:abc123def456",
Algorithm = "ECDSA_P256",
TrustedAt = DateTimeOffset.UtcNow,
IsActive = true
};
await registry.AddAsync(key);
var result = await registry.IsTrustedAsync("key-001");
Assert.True(result);
}
[Fact]
public async Task InMemoryRegistry_GetKeyAsync_ReturnsKey()
{
var registry = new InMemoryTrustedKeyRegistry();
var key = new TrustedKey
{
KeyId = "key-002",
Fingerprint = "sha256:xyz789",
Algorithm = "Ed25519",
Owner = "test@example.com",
TrustedAt = DateTimeOffset.UtcNow,
IsActive = true
};
await registry.AddAsync(key);
var retrieved = await registry.GetKeyAsync("key-002");
Assert.NotNull(retrieved);
Assert.Equal("key-002", retrieved.KeyId);
Assert.Equal("Ed25519", retrieved.Algorithm);
Assert.Equal("test@example.com", retrieved.Owner);
}
[Fact]
public async Task InMemoryRegistry_GetByFingerprintAsync_ReturnsKey()
{
var registry = new InMemoryTrustedKeyRegistry();
var key = new TrustedKey
{
KeyId = "key-003",
Fingerprint = "sha256:fingerprint123",
Algorithm = "RSA_2048",
TrustedAt = DateTimeOffset.UtcNow,
IsActive = true
};
await registry.AddAsync(key);
var retrieved = await registry.GetByFingerprintAsync("sha256:fingerprint123");
Assert.NotNull(retrieved);
Assert.Equal("key-003", retrieved.KeyId);
}
[Fact]
public async Task InMemoryRegistry_RevokeAsync_KeyNoLongerTrusted()
{
var registry = new InMemoryTrustedKeyRegistry();
var key = new TrustedKey
{
KeyId = "key-004",
Fingerprint = "sha256:revokeme",
Algorithm = "ECDSA_P384",
TrustedAt = DateTimeOffset.UtcNow,
IsActive = true
};
await registry.AddAsync(key);
Assert.True(await registry.IsTrustedAsync("key-004"));
await registry.RevokeAsync("key-004", "Compromised key");
Assert.False(await registry.IsTrustedAsync("key-004"));
var revokedKey = await registry.GetKeyAsync("key-004");
Assert.NotNull(revokedKey);
Assert.False(revokedKey.IsActive);
Assert.Equal("Compromised key", revokedKey.RevokedReason);
Assert.NotNull(revokedKey.RevokedAt);
}
[Fact]
public async Task InMemoryRegistry_ExpiredKey_NotTrusted()
{
var registry = new InMemoryTrustedKeyRegistry();
var key = new TrustedKey
{
KeyId = "key-005",
Fingerprint = "sha256:expired",
Algorithm = "ECDSA_P256",
TrustedAt = DateTimeOffset.UtcNow.AddDays(-30),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(-1), // Expired yesterday
IsActive = true
};
await registry.AddAsync(key);
Assert.False(await registry.IsTrustedAsync("key-005"));
}
[Fact]
public async Task InMemoryRegistry_ListAsync_ReturnsAllKeys()
{
var registry = new InMemoryTrustedKeyRegistry();
await registry.AddAsync(new TrustedKey
{
KeyId = "list-key-1",
Fingerprint = "sha256:list1",
Algorithm = "ECDSA_P256",
TrustedAt = DateTimeOffset.UtcNow,
IsActive = true
});
await registry.AddAsync(new TrustedKey
{
KeyId = "list-key-2",
Fingerprint = "sha256:list2",
Algorithm = "Ed25519",
TrustedAt = DateTimeOffset.UtcNow,
IsActive = true
});
var keys = new List<TrustedKey>();
await foreach (var key in registry.ListAsync())
{
keys.Add(key);
}
Assert.Equal(2, keys.Count);
Assert.Contains(keys, k => k.KeyId == "list-key-1");
Assert.Contains(keys, k => k.KeyId == "list-key-2");
}
[Fact]
public async Task InMemoryRegistry_InactiveKey_NotTrusted()
{
var registry = new InMemoryTrustedKeyRegistry();
var key = new TrustedKey
{
KeyId = "key-006",
Fingerprint = "sha256:inactive",
Algorithm = "ECDSA_P256",
TrustedAt = DateTimeOffset.UtcNow,
IsActive = false // Explicitly inactive
};
await registry.AddAsync(key);
Assert.False(await registry.IsTrustedAsync("key-006"));
}
[Fact]
public async Task InMemoryRegistry_KeyWithPurposes_StoresPurposes()
{
var registry = new InMemoryTrustedKeyRegistry();
var key = new TrustedKey
{
KeyId = "key-007",
Fingerprint = "sha256:purposes",
Algorithm = "ECDSA_P256",
Purposes = new[] { "sbom-signing", "vex-signing" },
TrustedAt = DateTimeOffset.UtcNow,
IsActive = true
};
await registry.AddAsync(key);
var retrieved = await registry.GetKeyAsync("key-007");
Assert.NotNull(retrieved);
Assert.Equal(2, retrieved.Purposes.Count);
Assert.Contains("sbom-signing", retrieved.Purposes);
Assert.Contains("vex-signing", retrieved.Purposes);
}
}

View File

@@ -0,0 +1,357 @@
// -----------------------------------------------------------------------------
// UnknownsGateCheckerIntegrationTests.cs
// Sprint: SPRINT_20260118_018_Unknowns_queue_enhancement
// Task: UQ-003 - Integration tests with mocked unknowns
// -----------------------------------------------------------------------------
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using StellaOps.Policy.Gates;
using Xunit;
namespace StellaOps.Policy.Tests.Gates;
public sealed class UnknownsGateCheckerIntegrationTests
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly UnknownsGateOptions _options;
private readonly ILogger<UnknownsGateChecker> _logger;
public UnknownsGateCheckerIntegrationTests()
{
_httpClient = new HttpClient();
_cache = new MemoryCache(new MemoryCacheOptions());
_options = new UnknownsGateOptions
{
FailClosed = true,
BlockNotAffectedWithUnknowns = true,
RequireKevException = true,
ForceReviewOnSlaBreach = true,
CacheTtlSeconds = 30
};
_logger = Substitute.For<ILogger<UnknownsGateChecker>>();
}
#region Gate Decision Tests
[Fact]
public async Task Check_NoUnknowns_ReturnsPass()
{
// Arrange
var checker = CreateCheckerWithMockedUnknowns([]);
// Act
var result = await checker.CheckAsync("pkg:npm/clean@1.0.0");
// Assert
Assert.Equal(GateDecision.Pass, result.Decision);
Assert.Equal("resolved", result.State);
}
[Fact]
public async Task Check_HotUnknowns_FailClosed_ReturnsBlock()
{
// Arrange
var unknowns = new List<UnknownState>
{
new()
{
UnknownId = Guid.NewGuid(),
CveId = "CVE-2026-1234",
Band = "hot",
State = "pending",
SlaRemainingHours = 12
}
};
var checker = CreateCheckerWithMockedUnknowns(unknowns);
// Act
var result = await checker.CheckAsync("pkg:npm/vulnerable@1.0.0");
// Assert
Assert.Equal(GateDecision.Block, result.Decision);
Assert.Equal("blocked_by_unknowns", result.State);
Assert.Single(result.BlockingUnknownIds);
}
[Fact]
public async Task Check_WarmUnknowns_ReturnsWarn()
{
// Arrange
var unknowns = new List<UnknownState>
{
new()
{
UnknownId = Guid.NewGuid(),
CveId = "CVE-2026-5678",
Band = "warm",
State = "pending",
SlaRemainingHours = 120
}
};
var checker = CreateCheckerWithMockedUnknowns(unknowns);
// Act
var result = await checker.CheckAsync("pkg:npm/warning@1.0.0");
// Assert
Assert.Equal(GateDecision.Warn, result.Decision);
Assert.Equal("pending", result.State);
}
[Fact]
public async Task Check_ColdUnknowns_ReturnsWarn()
{
// Arrange
var unknowns = new List<UnknownState>
{
new()
{
UnknownId = Guid.NewGuid(),
CveId = "CVE-2026-9999",
Band = "cold",
State = "pending",
SlaRemainingHours = 500
}
};
var checker = CreateCheckerWithMockedUnknowns(unknowns);
// Act
var result = await checker.CheckAsync("pkg:npm/cold@1.0.0");
// Assert
Assert.Equal(GateDecision.Warn, result.Decision);
}
#endregion
#region Not Affected Verdict Tests
[Fact]
public async Task Check_NotAffectedVerdict_WithUnknowns_Blocks()
{
// Arrange
var unknowns = new List<UnknownState>
{
new()
{
UnknownId = Guid.NewGuid(),
Band = "warm",
State = "pending",
SlaRemainingHours = 100
}
};
var checker = CreateCheckerWithMockedUnknowns(unknowns);
// Act
var result = await checker.CheckAsync("pkg:npm/test@1.0.0", "not_affected");
// Assert
Assert.Equal(GateDecision.Block, result.Decision);
Assert.Contains("not_affected", result.Reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Check_NotAffectedVerdict_NoUnknowns_Passes()
{
// Arrange
var checker = CreateCheckerWithMockedUnknowns([]);
// Act
var result = await checker.CheckAsync("pkg:npm/clean@1.0.0", "not_affected");
// Assert
Assert.Equal(GateDecision.Pass, result.Decision);
}
#endregion
#region KEV Tests
[Fact]
public async Task Check_KevUnknown_RequiresException()
{
// Arrange
var unknowns = new List<UnknownState>
{
new()
{
UnknownId = Guid.NewGuid(),
CveId = "CVE-2026-KEV1",
Band = "warm",
State = "pending",
InKev = true
}
};
var checker = CreateCheckerWithMockedUnknowns(unknowns);
// Act
var result = await checker.CheckAsync("pkg:npm/kev@1.0.0");
// Assert
Assert.Equal(GateDecision.Block, result.Decision);
Assert.Contains("KEV", result.Reason, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region SLA Breach Tests
[Fact]
public async Task Check_SlaBreached_ForcesReview()
{
// Arrange
var unknowns = new List<UnknownState>
{
new()
{
UnknownId = Guid.NewGuid(),
Band = "cold",
State = "pending",
SlaBreach = true
}
};
var checker = CreateCheckerWithMockedUnknowns(unknowns);
// Act
var result = await checker.CheckAsync("pkg:npm/overdue@1.0.0");
// Assert
Assert.Equal(GateDecision.Block, result.Decision);
Assert.Contains("SLA", result.Reason, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Caching Tests
[Fact]
public async Task Check_CachesResult()
{
// Arrange
var callCount = 0;
var unknowns = new List<UnknownState>();
var checker = CreateCheckerWithMockedUnknowns(unknowns, () => callCount++);
// Act
await checker.CheckAsync("pkg:npm/cached@1.0.0");
await checker.CheckAsync("pkg:npm/cached@1.0.0");
await checker.CheckAsync("pkg:npm/cached@1.0.0");
// Assert - Should only call underlying once due to caching
Assert.Equal(1, callCount);
}
[Fact]
public async Task Check_DifferentBomRefs_NotCached()
{
// Arrange
var callCount = 0;
var unknowns = new List<UnknownState>();
var checker = CreateCheckerWithMockedUnknowns(unknowns, () => callCount++);
// Act
await checker.CheckAsync("pkg:npm/one@1.0.0");
await checker.CheckAsync("pkg:npm/two@1.0.0");
// Assert - Should call twice (different keys)
Assert.Equal(2, callCount);
}
#endregion
#region Aggregate State Tests
[Fact]
public async Task Check_MultipleUnknowns_ReturnsWorstState()
{
// Arrange
var unknowns = new List<UnknownState>
{
new() { UnknownId = Guid.NewGuid(), Band = "cold", State = "pending" },
new() { UnknownId = Guid.NewGuid(), Band = "warm", State = "under_review" },
new() { UnknownId = Guid.NewGuid(), Band = "warm", State = "escalated" }
};
var checker = CreateCheckerWithMockedUnknowns(unknowns);
// Act
var result = await checker.CheckAsync("pkg:npm/multi@1.0.0");
// Assert - Escalated is worst
Assert.Equal("escalated", result.State);
}
#endregion
#region Exception Workflow Tests
[Fact]
public async Task RequestException_ReturnsNotGrantedByDefault()
{
// Arrange
var checker = CreateCheckerWithMockedUnknowns([]);
// Act
var result = await checker.RequestExceptionAsync(
"pkg:npm/test@1.0.0",
[Guid.NewGuid()],
"Critical business need",
"user@example.com");
// Assert
Assert.False(result.Granted);
Assert.NotNull(result.DenialReason);
}
#endregion
#region Helpers
private UnknownsGateChecker CreateCheckerWithMockedUnknowns(
List<UnknownState> unknowns,
Action? onGetUnknowns = null)
{
return new MockedUnknownsGateChecker(
_httpClient,
_cache,
Options.Create(_options),
_logger,
unknowns,
onGetUnknowns);
}
#endregion
}
/// <summary>
/// Mocked gate checker for testing.
/// </summary>
public sealed class MockedUnknownsGateChecker : UnknownsGateChecker
{
private readonly List<UnknownState> _unknowns;
private readonly Action? _onGetUnknowns;
public MockedUnknownsGateChecker(
HttpClient httpClient,
IMemoryCache cache,
IOptions<UnknownsGateOptions> options,
ILogger<UnknownsGateChecker> logger,
List<UnknownState> unknowns,
Action? onGetUnknowns = null)
: base(httpClient, cache, options, logger)
{
_unknowns = unknowns;
_onGetUnknowns = onGetUnknowns;
}
public override Task<IReadOnlyList<UnknownState>> GetUnknownsAsync(
string bomRef,
CancellationToken ct = default)
{
_onGetUnknowns?.Invoke();
return Task.FromResult<IReadOnlyList<UnknownState>>(_unknowns);
}
}