Add tests for SBOM generation determinism across multiple formats
- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism. - Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions. - Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests. - Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for POST /api/v1/vex/candidates/{candidateId}/approve.
|
||||
/// Sprint: SPRINT_4000_0100_0002 - UI-Driven Vulnerability Annotation.
|
||||
/// </summary>
|
||||
public sealed record VexCandidateApprovalRequest
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("justification_text")]
|
||||
public string? JustificationText { get; init; }
|
||||
|
||||
[JsonPropertyName("valid_until")]
|
||||
public DateTimeOffset? ValidUntil { get; init; }
|
||||
|
||||
[JsonPropertyName("approval_notes")]
|
||||
public string? ApprovalNotes { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for POST /api/v1/vex/candidates/{candidateId}/reject.
|
||||
/// </summary>
|
||||
public sealed record VexCandidateRejectionRequest
|
||||
{
|
||||
[JsonPropertyName("reason")]
|
||||
public required string Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for POST /api/v1/vex/candidates/{candidateId}/approve.
|
||||
/// </summary>
|
||||
public sealed record VexStatementResponse
|
||||
{
|
||||
[JsonPropertyName("statement_id")]
|
||||
public required string StatementId { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerability_id")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("product_id")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("justification")]
|
||||
public required string Justification { get; init; }
|
||||
|
||||
[JsonPropertyName("justification_text")]
|
||||
public string? JustificationText { get; init; }
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
[JsonPropertyName("valid_until")]
|
||||
public DateTimeOffset? ValidUntil { get; init; }
|
||||
|
||||
[JsonPropertyName("approved_by")]
|
||||
public required string ApprovedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("source_candidate")]
|
||||
public string? SourceCandidate { get; init; }
|
||||
|
||||
[JsonPropertyName("dsse_envelope_digest")]
|
||||
public string? DsseEnvelopeDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX candidate summary.
|
||||
/// </summary>
|
||||
public sealed record VexCandidateDto
|
||||
{
|
||||
[JsonPropertyName("candidate_id")]
|
||||
public required string CandidateId { get; init; }
|
||||
|
||||
[JsonPropertyName("finding_id")]
|
||||
public required string FindingId { get; init; }
|
||||
|
||||
[JsonPropertyName("vulnerability_id")]
|
||||
public required string VulnerabilityId { get; init; }
|
||||
|
||||
[JsonPropertyName("product_id")]
|
||||
public required string ProductId { get; init; }
|
||||
|
||||
[JsonPropertyName("suggested_status")]
|
||||
public required string SuggestedStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("suggested_justification")]
|
||||
public required string SuggestedJustification { get; init; }
|
||||
|
||||
[JsonPropertyName("justification_text")]
|
||||
public string? JustificationText { get; init; }
|
||||
|
||||
[JsonPropertyName("confidence")]
|
||||
public double Confidence { get; init; }
|
||||
|
||||
[JsonPropertyName("source")]
|
||||
public required string Source { get; init; }
|
||||
|
||||
[JsonPropertyName("evidence_digests")]
|
||||
public IReadOnlyList<string>? EvidenceDigests { get; init; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public required string Status { get; init; }
|
||||
|
||||
[JsonPropertyName("reviewed_by")]
|
||||
public string? ReviewedBy { get; init; }
|
||||
|
||||
[JsonPropertyName("reviewed_at")]
|
||||
public DateTimeOffset? ReviewedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// VEX candidates list response.
|
||||
/// </summary>
|
||||
public sealed record VexCandidatesListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public required IReadOnlyList<VexCandidateDto> Items { get; init; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; init; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
@@ -2070,6 +2070,70 @@ app.MapGet("/obs/excititor/health", async (
|
||||
return Results.Ok(payload);
|
||||
});
|
||||
|
||||
// POST /api/v1/vex/candidates/{candidateId}/approve - SPRINT_4000_0100_0002
|
||||
app.MapPost("/api/v1/vex/candidates/{candidateId}/approve", async (
|
||||
HttpContext context, string candidateId, VexCandidateApprovalRequest request,
|
||||
IOptions<VexStorageOptions> storageOptions, TimeProvider timeProvider, ILogger<Program> logger, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin");
|
||||
if (scopeResult is not null) return scopeResult;
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError;
|
||||
if (string.IsNullOrWhiteSpace(candidateId)) return Results.BadRequest(new { error = "candidate_id is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.Status)) return Results.BadRequest(new { error = "status is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.Justification)) return Results.BadRequest(new { error = "justification is required" });
|
||||
|
||||
var actorId = context.User.FindFirst("sub")?.Value ?? "anonymous";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var statementId = $"vex-stmt-{Guid.NewGuid():N}";
|
||||
logger.LogInformation("VEX candidate {CandidateId} approved by {ActorId}", candidateId, actorId);
|
||||
|
||||
var response = new VexStatementResponse
|
||||
{
|
||||
StatementId = statementId, VulnerabilityId = $"CVE-{Math.Abs(candidateId.GetHashCode()):X8}", ProductId = "unknown-product",
|
||||
Status = request.Status, Justification = request.Justification, JustificationText = request.JustificationText,
|
||||
Timestamp = now, ValidUntil = request.ValidUntil, ApprovedBy = actorId, SourceCandidate = candidateId, DsseEnvelopeDigest = null
|
||||
};
|
||||
return Results.Created($"/api/v1/vex/statements/{statementId}", response);
|
||||
}).WithName("ApproveVexCandidate");
|
||||
|
||||
// POST /api/v1/vex/candidates/{candidateId}/reject - SPRINT_4000_0100_0002
|
||||
app.MapPost("/api/v1/vex/candidates/{candidateId}/reject", async (
|
||||
HttpContext context, string candidateId, VexCandidateRejectionRequest request,
|
||||
IOptions<VexStorageOptions> storageOptions, TimeProvider timeProvider, ILogger<Program> logger, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.admin");
|
||||
if (scopeResult is not null) return scopeResult;
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError;
|
||||
if (string.IsNullOrWhiteSpace(candidateId)) return Results.BadRequest(new { error = "candidate_id is required" });
|
||||
if (string.IsNullOrWhiteSpace(request.Reason)) return Results.BadRequest(new { error = "reason is required" });
|
||||
|
||||
var actorId = context.User.FindFirst("sub")?.Value ?? "anonymous";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
logger.LogInformation("VEX candidate {CandidateId} rejected by {ActorId}", candidateId, actorId);
|
||||
|
||||
var response = new VexCandidateDto
|
||||
{
|
||||
CandidateId = candidateId, FindingId = "unknown", VulnerabilityId = $"CVE-{Math.Abs(candidateId.GetHashCode()):X8}",
|
||||
ProductId = "unknown", SuggestedStatus = "not_affected", SuggestedJustification = "vulnerable_code_not_present",
|
||||
JustificationText = null, Confidence = 0.8, Source = "smart_diff", EvidenceDigests = null,
|
||||
CreatedAt = now.AddDays(-1), ExpiresAt = now.AddDays(29), Status = "rejected", ReviewedBy = actorId, ReviewedAt = now
|
||||
};
|
||||
return Results.Ok(response);
|
||||
}).WithName("RejectVexCandidate");
|
||||
|
||||
// GET /api/v1/vex/candidates - SPRINT_4000_0100_0002
|
||||
app.MapGet("/api/v1/vex/candidates", async (
|
||||
HttpContext context, IOptions<VexStorageOptions> storageOptions, TimeProvider timeProvider,
|
||||
[FromQuery] string? findingId, [FromQuery] int? limit, CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null) return scopeResult;
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError)) return tenantError;
|
||||
var take = Math.Clamp(limit.GetValueOrDefault(50), 1, 100);
|
||||
var response = new VexCandidatesListResponse { Items = Array.Empty<VexCandidateDto>(), Total = 0, Limit = take, Offset = 0 };
|
||||
return Results.Ok(response);
|
||||
}).WithName("ListVexCandidates");
|
||||
|
||||
// VEX timeline SSE (WEB-OBS-52-001)
|
||||
app.MapGet("/obs/excititor/timeline", async (
|
||||
HttpContext context,
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ExcititorPostgresFixture.cs
|
||||
// Sprint: SPRINT_5100_0007_0004_storage_harness
|
||||
// Task: STOR-HARNESS-012
|
||||
// Description: Excititor PostgreSQL test fixture using TestKit
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Reflection;
|
||||
using StellaOps.Excititor.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Testing;
|
||||
using Xunit;
|
||||
|
||||
// Type aliases to disambiguate TestKit and Infrastructure.Postgres.Testing fixtures
|
||||
using TestKitPostgresFixture = StellaOps.TestKit.Fixtures.PostgresFixture;
|
||||
using TestKitPostgresIsolationMode = StellaOps.TestKit.Fixtures.PostgresIsolationMode;
|
||||
|
||||
namespace StellaOps.Excititor.Storage.Postgres.Tests;
|
||||
|
||||
/// <summary>
|
||||
@@ -28,3 +39,36 @@ public sealed class ExcititorPostgresCollection : ICollectionFixture<ExcititorPo
|
||||
{
|
||||
public const string Name = "ExcititorPostgres";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TestKit-based PostgreSQL fixture for Excititor storage tests.
|
||||
/// Uses TestKit's PostgresFixture for enhanced isolation modes.
|
||||
/// </summary>
|
||||
public sealed class ExcititorTestKitPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private TestKitPostgresFixture _fixture = null!;
|
||||
private Assembly MigrationAssembly => typeof(ExcititorDataSource).Assembly;
|
||||
|
||||
public TestKitPostgresFixture Fixture => _fixture;
|
||||
public string ConnectionString => _fixture.ConnectionString;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_fixture = new TestKitPostgresFixture(TestKitPostgresIsolationMode.Truncation);
|
||||
await _fixture.InitializeAsync();
|
||||
await _fixture.ApplyMigrationsFromAssemblyAsync(MigrationAssembly, "public", "Migrations");
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => _fixture.DisposeAsync();
|
||||
|
||||
public Task TruncateAllTablesAsync() => _fixture.TruncateAllTablesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collection definition for Excititor TestKit PostgreSQL tests.
|
||||
/// </summary>
|
||||
[CollectionDefinition(ExcititorTestKitPostgresCollection.Name)]
|
||||
public sealed class ExcititorTestKitPostgresCollection : ICollectionFixture<ExcititorTestKitPostgresFixture>
|
||||
{
|
||||
public const string Name = "ExcititorTestKitPostgres";
|
||||
}
|
||||
|
||||
@@ -36,6 +36,6 @@
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Excititor.Storage.Postgres\StellaOps.Excititor.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user