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