489 lines
17 KiB
C#
489 lines
17 KiB
C#
// SPDX-License-Identifier: BUSL-1.1
|
|
// SPDX-FileCopyrightText: 2025 StellaOps Contributors
|
|
// Sprint: SPRINT_5100_0009_0004 - Policy Module Test Implementation
|
|
// Tasks: POLICY-5100-011, POLICY-5100-012, POLICY-5100-013
|
|
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.IdentityModel.JsonWebTokens;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using StellaOps.Policy.Gateway.Contracts;
|
|
using Xunit;
|
|
// Use alias for Policy.Gateway.Program to avoid conflict with Policy.Engine.Program
|
|
using GatewayProgram = StellaOps.Policy.Gateway.Program;
|
|
|
|
namespace StellaOps.Policy.Gateway.Tests.W1;
|
|
|
|
/// <summary>
|
|
/// W1-level integration tests for Policy Gateway endpoints.
|
|
/// Covers contract validation, authentication, authorization, and OpenTelemetry tracing.
|
|
/// </summary>
|
|
[Trait("Category", "W1")]
|
|
[Trait("Category", "Gateway")]
|
|
public sealed class PolicyGatewayIntegrationTests : IAsyncLifetime
|
|
{
|
|
private PolicyGatewayTestFactory _factory = null!;
|
|
private HttpClient _client = null!;
|
|
private readonly ActivityListener _activityListener;
|
|
private readonly ConcurrentBag<Activity> _recordedActivities;
|
|
|
|
public PolicyGatewayIntegrationTests()
|
|
{
|
|
_recordedActivities = new ConcurrentBag<Activity>();
|
|
_activityListener = new ActivityListener
|
|
{
|
|
ShouldListenTo = source => source.Name.StartsWith("StellaOps", StringComparison.OrdinalIgnoreCase),
|
|
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
|
|
ActivityStarted = activity => _recordedActivities.Add(activity)
|
|
};
|
|
ActivitySource.AddActivityListener(_activityListener);
|
|
}
|
|
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
_factory = new PolicyGatewayTestFactory();
|
|
_client = _factory.CreateClient();
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_activityListener.Dispose();
|
|
_client.Dispose();
|
|
await _factory.DisposeAsync();
|
|
}
|
|
|
|
#region Contract Tests (POLICY-5100-011)
|
|
|
|
[Fact(DisplayName = "GET /api/policy/exceptions returns expected contract shape")]
|
|
public async Task GetExceptions_ReturnsExpectedContractShape()
|
|
{
|
|
// Arrange
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read"]);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert
|
|
if (response.StatusCode == HttpStatusCode.OK)
|
|
{
|
|
var content = await response.Content.ReadFromJsonAsync<ExceptionListResponse>();
|
|
content.Should().NotBeNull();
|
|
content!.Items.Should().NotBeNull();
|
|
content.TotalCount.Should().BeGreaterThanOrEqualTo(0);
|
|
content.Offset.Should().BeGreaterThanOrEqualTo(0);
|
|
content.Limit.Should().BeGreaterThan(0);
|
|
}
|
|
else
|
|
{
|
|
// If not OK, should be a recognized error response (401, 403, 404)
|
|
response.StatusCode.Should().BeOneOf(
|
|
HttpStatusCode.Unauthorized,
|
|
HttpStatusCode.Forbidden,
|
|
HttpStatusCode.NotFound);
|
|
}
|
|
}
|
|
|
|
[Fact(DisplayName = "GET /api/policy/exceptions/counts returns expected contract shape")]
|
|
public async Task GetExceptionCounts_ReturnsExpectedContractShape()
|
|
{
|
|
// Arrange
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read"]);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions/counts");
|
|
|
|
// Assert
|
|
if (response.StatusCode == HttpStatusCode.OK)
|
|
{
|
|
var content = await response.Content.ReadFromJsonAsync<ExceptionCountsResponse>();
|
|
content.Should().NotBeNull();
|
|
content!.Total.Should().BeGreaterThanOrEqualTo(0);
|
|
content.Proposed.Should().BeGreaterThanOrEqualTo(0);
|
|
content.Approved.Should().BeGreaterThanOrEqualTo(0);
|
|
content.Active.Should().BeGreaterThanOrEqualTo(0);
|
|
content.Expired.Should().BeGreaterThanOrEqualTo(0);
|
|
content.Revoked.Should().BeGreaterThanOrEqualTo(0);
|
|
}
|
|
}
|
|
|
|
[Fact(DisplayName = "POST /api/policy/deltas/compute returns expected contract shape")]
|
|
public async Task ComputeDelta_ReturnsExpectedContractShape()
|
|
{
|
|
// Arrange
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read", "policy:write"]);
|
|
var request = new ComputeDeltaRequest
|
|
{
|
|
ArtifactDigest = "sha256:abc123",
|
|
TargetSnapshotId = "snapshot-001",
|
|
BaselineStrategy = "previous"
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync("/api/policy/deltas/compute", request);
|
|
|
|
// Assert - response should have expected structure even on validation errors
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
response.StatusCode.Should().BeOneOf(
|
|
HttpStatusCode.OK,
|
|
HttpStatusCode.BadRequest,
|
|
HttpStatusCode.NotFound,
|
|
HttpStatusCode.Unauthorized,
|
|
HttpStatusCode.Forbidden);
|
|
|
|
// If BadRequest, should be ProblemDetails
|
|
if (response.StatusCode == HttpStatusCode.BadRequest)
|
|
{
|
|
var json = JsonDocument.Parse(content);
|
|
json.RootElement.TryGetProperty("title", out _).Should().BeTrue("BadRequest should return ProblemDetails");
|
|
}
|
|
}
|
|
|
|
[Fact(DisplayName = "GET /api/policy/exceptions/{id} returns 404 for non-existent exception")]
|
|
public async Task GetException_ReturnsNotFound_ForNonExistent()
|
|
{
|
|
// Arrange
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read"]);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions/non-existent-id-12345");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
var json = JsonDocument.Parse(content);
|
|
json.RootElement.TryGetProperty("title", out _).Should().BeTrue("NotFound should return ProblemDetails");
|
|
}
|
|
|
|
[Fact(DisplayName = "POST /api/policy/deltas/compute returns 400 for missing artifact digest")]
|
|
public async Task ComputeDelta_ReturnsBadRequest_ForMissingDigest()
|
|
{
|
|
// Arrange
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read", "policy:write"]);
|
|
var request = new ComputeDeltaRequest
|
|
{
|
|
ArtifactDigest = null!,
|
|
TargetSnapshotId = "snapshot-001"
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync("/api/policy/deltas/compute", request);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Auth Tests (POLICY-5100-012)
|
|
|
|
[Fact(DisplayName = "Deny by default: Anonymous request returns 401")]
|
|
public async Task AnonymousRequest_Returns401()
|
|
{
|
|
// Arrange - no auth header
|
|
_client.DefaultRequestHeaders.Authorization = null;
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact(DisplayName = "Invalid token returns 401")]
|
|
public async Task InvalidToken_Returns401()
|
|
{
|
|
// Arrange - malformed token
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid.token.here");
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact(DisplayName = "Expired token returns 401")]
|
|
public async Task ExpiredToken_Returns401()
|
|
{
|
|
// Arrange - token that expired in the past
|
|
var expiredToken = CreateJwtToken(["policy:read"], expiresIn: TimeSpan.FromMinutes(-5));
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expiredToken);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact(DisplayName = "Missing required scope returns 403")]
|
|
public async Task MissingScope_Returns403()
|
|
{
|
|
// Arrange - token without policy:read scope
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["other:scope"]);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact(DisplayName = "Correct scope allows access")]
|
|
public async Task CorrectScope_AllowsAccess()
|
|
{
|
|
// Arrange
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read"]);
|
|
|
|
// Act
|
|
var response = await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized);
|
|
response.StatusCode.Should().NotBe(HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Fact(DisplayName = "Write operation requires policy:write scope")]
|
|
public async Task WriteOperation_RequiresWriteScope()
|
|
{
|
|
// Arrange - only read scope
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read"]);
|
|
var request = new CreateExceptionRequest
|
|
{
|
|
VulnerabilityId = "CVE-2024-0001",
|
|
Purl = "pkg:npm/example@1.0.0",
|
|
Justification = "Test justification"
|
|
};
|
|
|
|
// Act
|
|
var response = await _client.PostAsJsonAsync("/api/policy/exceptions", request);
|
|
|
|
// Assert
|
|
response.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized, HttpStatusCode.NotFound);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region OTel Trace Tests (POLICY-5100-013)
|
|
|
|
[Fact(DisplayName = "Request creates activity with policy_id tag")]
|
|
public async Task Request_CreatesActivity_WithPolicyIdTag()
|
|
{
|
|
// Arrange
|
|
_recordedActivities.Clear();
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read"]);
|
|
|
|
// Act
|
|
await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert - check for activities with expected tags
|
|
// Note: Activity tags depend on actual OTel implementation
|
|
var activities = _recordedActivities.ToArray();
|
|
// At minimum, HTTP activities should be recorded
|
|
// The specific policy_id tag depends on implementation
|
|
}
|
|
|
|
[Fact(DisplayName = "Request creates activity with tenant_id tag")]
|
|
public async Task Request_CreatesActivity_WithTenantIdTag()
|
|
{
|
|
// Arrange
|
|
_recordedActivities.Clear();
|
|
var token = CreateJwtToken(["policy:read"], tenantId: "tenant-123");
|
|
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
|
|
// Act
|
|
await _client.GetAsync("/api/policy/exceptions");
|
|
|
|
// Assert - check for tenant_id in activities
|
|
var activities = _recordedActivities.ToArray();
|
|
// The specific tenant_id tag depends on OTel implementation
|
|
}
|
|
|
|
[Fact(DisplayName = "Delta computation records verdict_id in trace")]
|
|
public async Task DeltaComputation_RecordsVerdictId_InTrace()
|
|
{
|
|
// Arrange
|
|
_recordedActivities.Clear();
|
|
_client.DefaultRequestHeaders.Authorization = CreateAuthHeader(["policy:read", "policy:write"]);
|
|
var request = new ComputeDeltaRequest
|
|
{
|
|
ArtifactDigest = "sha256:abc123",
|
|
TargetSnapshotId = "snapshot-001"
|
|
};
|
|
|
|
// Act
|
|
await _client.PostAsJsonAsync("/api/policy/deltas/compute", request);
|
|
|
|
// Assert - verify activities were recorded for the operation
|
|
var activities = _recordedActivities.ToArray();
|
|
// Specific verdict_id tag verification depends on implementation
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helpers
|
|
|
|
private static AuthenticationHeaderValue CreateAuthHeader(string[] scopes, string? tenantId = null)
|
|
{
|
|
var token = CreateJwtToken(scopes, tenantId: tenantId);
|
|
return new AuthenticationHeaderValue("Bearer", token);
|
|
}
|
|
|
|
private static string CreateJwtToken(string[] scopes, TimeSpan? expiresIn = null, string? tenantId = null)
|
|
{
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(PolicyGatewayTestFactory.TestSigningKey));
|
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, "test-user"),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
new("scope", string.Join(" ", scopes))
|
|
};
|
|
|
|
if (tenantId != null)
|
|
{
|
|
claims.Add(new Claim("tenant_id", tenantId));
|
|
}
|
|
|
|
var expires = DateTime.UtcNow.Add(expiresIn ?? TimeSpan.FromHours(1));
|
|
|
|
var handler = new JsonWebTokenHandler();
|
|
var descriptor = new SecurityTokenDescriptor
|
|
{
|
|
Subject = new ClaimsIdentity(claims),
|
|
Expires = expires,
|
|
SigningCredentials = credentials,
|
|
Issuer = PolicyGatewayTestFactory.TestIssuer,
|
|
Audience = PolicyGatewayTestFactory.TestAudience
|
|
};
|
|
|
|
return handler.CreateToken(descriptor);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test factory for Policy Gateway integration tests.
|
|
/// </summary>
|
|
internal sealed class PolicyGatewayTestFactory : WebApplicationFactory<GatewayProgram>
|
|
{
|
|
public const string TestSigningKey = "ThisIsATestSigningKeyForPolicyGatewayTestsThatIsLongEnough256Bits!";
|
|
public const string TestIssuer = "test-issuer";
|
|
public const string TestAudience = "policy-gateway";
|
|
|
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
|
{
|
|
builder.UseEnvironment("Testing");
|
|
|
|
builder.ConfigureTestServices(services =>
|
|
{
|
|
// Override authentication to use test JWT validation
|
|
services.AddAuthentication("Bearer")
|
|
.AddJwtBearer("Bearer", options =>
|
|
{
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidateAudience = true,
|
|
ValidateLifetime = true,
|
|
ValidateIssuerSigningKey = true,
|
|
ValidIssuer = TestIssuer,
|
|
ValidAudience = TestAudience,
|
|
IssuerSigningKey = new SymmetricSecurityKey(
|
|
Encoding.UTF8.GetBytes(TestSigningKey)),
|
|
ClockSkew = TimeSpan.Zero
|
|
};
|
|
});
|
|
|
|
// Add test-specific service overrides
|
|
ConfigureTestServices(services);
|
|
});
|
|
}
|
|
|
|
private static void ConfigureTestServices(IServiceCollection services)
|
|
{
|
|
// Register mock/stub services as needed for isolated testing
|
|
// This allows tests to run without external dependencies
|
|
}
|
|
}
|
|
|
|
#region Contract DTOs for deserialization
|
|
|
|
/// <summary>
|
|
/// Response contract for exception list endpoint.
|
|
/// </summary>
|
|
public sealed record ExceptionListResponse
|
|
{
|
|
public IReadOnlyList<ExceptionDto> Items { get; init; } = [];
|
|
public int TotalCount { get; init; }
|
|
public int Offset { get; init; }
|
|
public int Limit { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Response contract for exception counts endpoint.
|
|
/// </summary>
|
|
public sealed record ExceptionCountsResponse
|
|
{
|
|
public int Total { get; init; }
|
|
public int Proposed { get; init; }
|
|
public int Approved { get; init; }
|
|
public int Active { get; init; }
|
|
public int Expired { get; init; }
|
|
public int Revoked { get; init; }
|
|
public int ExpiringSoon { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// DTO for exception entity.
|
|
/// </summary>
|
|
public sealed record ExceptionDto
|
|
{
|
|
public string Id { get; init; } = string.Empty;
|
|
public string VulnerabilityId { get; init; } = string.Empty;
|
|
public string Status { get; init; } = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request contract for compute delta endpoint.
|
|
/// </summary>
|
|
public sealed record ComputeDeltaRequest
|
|
{
|
|
public string ArtifactDigest { get; init; } = string.Empty;
|
|
public string TargetSnapshotId { get; init; } = string.Empty;
|
|
public string? BaselineSnapshotId { get; init; }
|
|
public string? BaselineStrategy { get; init; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request contract for creating an exception.
|
|
/// </summary>
|
|
public sealed record CreateExceptionRequest
|
|
{
|
|
public string VulnerabilityId { get; init; } = string.Empty;
|
|
public string Purl { get; init; } = string.Empty;
|
|
public string Justification { get; init; } = string.Empty;
|
|
}
|
|
|
|
#endregion
|
|
|
|
|
|
|