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:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user