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:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -0,0 +1,61 @@
using System.Text.Json.Serialization;
namespace StellaOps.Findings.Ledger.WebService.Contracts;
/// <summary>
/// Request for PATCH /api/v1/findings/{findingId}/state.
/// Sprint: SPRINT_4000_0100_0002 - UI-Driven Vulnerability Annotation.
/// </summary>
public sealed record StateTransitionRequest
{
[JsonPropertyName("target_state")]
public required string TargetState { get; init; }
[JsonPropertyName("justification")]
public string? Justification { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("due_date")]
public DateTimeOffset? DueDate { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
}
/// <summary>
/// Response for PATCH /api/v1/findings/{findingId}/state.
/// </summary>
public sealed record StateTransitionResponse
{
[JsonPropertyName("finding_id")]
public required string FindingId { get; init; }
[JsonPropertyName("previous_state")]
public string? PreviousState { get; init; }
[JsonPropertyName("current_state")]
public required string CurrentState { get; init; }
[JsonPropertyName("transition_recorded_at")]
public required DateTimeOffset TransitionRecordedAt { get; init; }
[JsonPropertyName("actor_id")]
public required string ActorId { get; init; }
[JsonPropertyName("justification")]
public string? Justification { get; init; }
[JsonPropertyName("notes")]
public string? Notes { get; init; }
[JsonPropertyName("due_date")]
public DateTimeOffset? DueDate { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
[JsonPropertyName("event_id")]
public Guid? EventId { get; init; }
}

View File

@@ -1761,6 +1761,96 @@ app.MapPost("/v1/vex-consensus/issuers", async Task<Results<Created<VexIssuerDet
.Produces(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
// PATCH /api/v1/findings/{findingId}/state - SPRINT_4000_0100_0002
app.MapPatch("/api/v1/findings/{findingId}/state", async Task<Results<Ok<StateTransitionResponse>, NotFound, ProblemHttpResult>> (
HttpContext httpContext,
string findingId,
StateTransitionRequest request,
ILedgerEventWriteService writeService,
ILedgerEventRepository eventRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (!TryGetTenant(httpContext, out var tenantProblem, out var tenantId))
{
return tenantProblem!;
}
if (string.IsNullOrWhiteSpace(findingId))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "invalid_finding_id", detail: "Finding ID is required.");
}
if (string.IsNullOrWhiteSpace(request.TargetState))
{
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "invalid_target_state", detail: "Target state is required.");
}
var actorId = httpContext.User.FindFirst("sub")?.Value ?? "anonymous";
var actorType = httpContext.User.FindFirst("actor_type")?.Value ?? "user";
var evidenceRefs = await eventRepository.GetEvidenceReferencesAsync(tenantId, findingId, cancellationToken).ConfigureAwait(false);
var artifactId = "unknown";
var chainId = Guid.NewGuid();
var previousStatus = "affected";
long sequenceNumber = 1;
var latestEvidenceRef = evidenceRefs.FirstOrDefault();
if (latestEvidenceRef != null)
{
var latestEvent = await eventRepository.GetByEventIdAsync(tenantId, latestEvidenceRef.EventId, cancellationToken).ConfigureAwait(false);
if (latestEvent != null)
{
artifactId = latestEvent.ArtifactId;
chainId = latestEvent.ChainId;
sequenceNumber = latestEvent.SequenceNumber + 1;
}
}
var targetState = request.TargetState.ToLowerInvariant().Trim();
var now = timeProvider.GetUtcNow();
var payload = new JsonObject { ["status"] = targetState, ["previous_status"] = previousStatus };
if (!string.IsNullOrWhiteSpace(request.Justification)) payload["justification"] = request.Justification;
if (!string.IsNullOrWhiteSpace(request.Notes)) payload["notes"] = request.Notes;
if (request.DueDate.HasValue) payload["due_date"] = request.DueDate.Value.ToString("O");
if (request.Tags is { Count: > 0 })
{
var tagsArray = new JsonArray();
foreach (var tag in request.Tags) tagsArray.Add(tag);
payload["tags"] = tagsArray;
}
var eventEnvelope = new JsonObject { ["event"] = new JsonObject { ["eventType"] = LedgerEventConstants.EventFindingStatusChanged, ["payload"] = payload } };
var draft = new LedgerEventDraft(
TenantId: tenantId, ChainId: chainId, SequenceNumber: sequenceNumber, EventId: Guid.NewGuid(),
EventType: LedgerEventConstants.EventFindingStatusChanged, PolicyVersion: "1", FindingId: findingId,
ArtifactId: artifactId, SourceRunId: null, ActorId: actorId, ActorType: actorType,
OccurredAt: now, RecordedAt: now, Payload: payload, CanonicalEnvelope: eventEnvelope, ProvidedPreviousHash: null);
var result = await writeService.AppendAsync(draft, cancellationToken).ConfigureAwait(false);
if (result.Status == LedgerWriteStatus.ValidationFailed)
return TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "validation_failed", detail: string.Join("; ", result.Errors));
if (result.Status == LedgerWriteStatus.Conflict)
return TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: result.ConflictCode ?? "conflict", detail: string.Join("; ", result.Errors));
var response = new StateTransitionResponse
{
FindingId = findingId, PreviousState = previousStatus, CurrentState = targetState, TransitionRecordedAt = now,
ActorId = actorId, Justification = request.Justification, Notes = request.Notes, DueDate = request.DueDate,
Tags = request.Tags, EventId = result.Record?.EventId
};
return TypedResults.Ok(response);
})
.WithName("TransitionFindingState")
.RequireAuthorization(LedgerWritePolicy)
.Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status409Conflict);
app.Run();
static Created<LedgerEventResponse> CreateCreatedResponse(LedgerEventRecord record)