Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
This commit is contained in:
master
2025-11-04 07:49:39 +02:00
parent f72c5c513a
commit 2eb6852d34
491 changed files with 39445 additions and 3917 deletions

View File

@@ -1,15 +1,33 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Storage;
using StellaOps.EvidenceLocker.Infrastructure.DependencyInjection;
using StellaOps.EvidenceLocker.Infrastructure.Services;
using StellaOps.EvidenceLocker.WebService.Audit;
using StellaOps.EvidenceLocker.WebService.Contracts;
using StellaOps.EvidenceLocker.WebService.Security;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEvidenceLockerInfrastructure(builder.Configuration);
builder.Services.AddScoped<EvidenceSnapshotService>();
builder.Services.AddHealthChecks();
builder.Services.AddStellaOpsResourceServerAuthentication(
builder.Configuration,
configure: options =>
{
options.RequiredScopes.Clear();
});
configure: options => options.RequiredScopes.Clear());
builder.Services.AddAuthorization(options =>
{
@@ -34,13 +52,282 @@ app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/evidence/{id}", (string id) => Results.Ok(new { id, status = "available" }))
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead);
app.MapHealthChecks("/health/ready");
app.MapPost("/evidence", () => Results.Accepted("/evidence", new { status = "queued" }))
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate);
app.MapPost("/evidence/snapshot",
async (HttpContext context, ClaimsPrincipal user, EvidenceSnapshotRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
app.MapPost("/evidence/{id}/hold", (string id) => Results.Accepted($"/evidence/{id}/hold", new { id, status = "on-hold" }))
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceHold);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/snapshot");
return ForbidTenant();
}
try
{
var result = await service.CreateSnapshotAsync(tenantId, request.ToDomain(), cancellationToken);
var materialCount = request.Materials.Count;
var totalSize = request.Materials.Sum(material => material.SizeBytes);
EvidenceAuditLogger.LogSnapshotCreated(logger, user, tenantId, request.Kind, result.BundleId, materialCount, totalSize);
var dto = new EvidenceSnapshotResponseDto(
result.BundleId,
result.RootHash,
result.Signature.ToDto());
return Results.Created($"/evidence/{result.BundleId}", dto);
}
catch (InvalidOperationException ex)
{
EvidenceAuditLogger.LogSnapshotRejected(logger, user, tenantId, request.Kind, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceHold)
.Produces<EvidenceSnapshotResponseDto>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.WithName("CreateEvidenceSnapshot")
.WithTags("Evidence")
.WithSummary("Create a new evidence snapshot for the tenant.");
app.MapGet("/evidence/{bundleId:guid}",
async (HttpContext context, ClaimsPrincipal user, Guid bundleId, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}");
return ForbidTenant();
}
var details = await service.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
if (details is null)
{
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
}
EvidenceAuditLogger.LogBundleRetrieved(logger, user, tenantId, details.Bundle);
var dto = new EvidenceBundleResponseDto(
details.Bundle.Id.Value,
details.Bundle.Kind,
details.Bundle.Status,
details.Bundle.RootHash,
details.Bundle.StorageKey,
details.Bundle.CreatedAt,
details.Bundle.UpdatedAt,
details.Bundle.Description,
details.Bundle.SealedAt,
details.Bundle.ExpiresAt,
details.Signature.ToDto());
return Results.Ok(dto);
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces<EvidenceBundleResponseDto>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
.WithName("GetEvidenceBundle")
.WithTags("Evidence");
app.MapGet("/evidence/{bundleId:guid}/download",
async (HttpContext context,
ClaimsPrincipal user,
Guid bundleId,
EvidenceSnapshotService snapshotService,
EvidenceBundlePackagingService packagingService,
IEvidenceObjectStore objectStore,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/download");
return ForbidTenant();
}
var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
if (bundle is null)
{
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
}
try
{
var package = await packagingService.EnsurePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
EvidenceAuditLogger.LogBundleDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created);
var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false);
return Results.File(
packageStream,
contentType: "application/gzip",
fileDownloadName: $"evidence-bundle-{bundleId:D}.tgz");
}
catch (InvalidOperationException ex)
{
EvidenceAuditLogger.LogBundleDownloadFailure(logger, user, tenantId, bundleId, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
.WithName("DownloadEvidenceBundle")
.WithTags("Evidence");
app.MapGet("/evidence/{bundleId:guid}/portable",
async (HttpContext context,
ClaimsPrincipal user,
Guid bundleId,
EvidenceSnapshotService snapshotService,
EvidencePortableBundleService portableService,
IEvidenceObjectStore objectStore,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/{bundleId}/portable");
return ForbidTenant();
}
var bundle = await snapshotService.GetBundleAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
if (bundle is null)
{
EvidenceAuditLogger.LogBundleNotFound(logger, user, tenantId, bundleId);
return Results.NotFound(new ErrorResponse("not_found", "Evidence bundle not found."));
}
try
{
var package = await portableService.EnsurePortablePackageAsync(tenantId, EvidenceBundleId.FromGuid(bundleId), cancellationToken);
EvidenceAuditLogger.LogPortableDownload(logger, user, tenantId, bundleId, package.StorageKey, package.Created);
var packageStream = await objectStore.OpenReadAsync(package.StorageKey, cancellationToken).ConfigureAwait(false);
return Results.File(
packageStream,
contentType: "application/gzip",
fileDownloadName: $"portable-evidence-bundle-{bundleId:D}.tgz");
}
catch (InvalidOperationException ex)
{
EvidenceAuditLogger.LogPortableDownloadFailure(logger, user, tenantId, bundleId, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.Produces<ErrorResponse>(StatusCodes.Status404NotFound)
.WithName("DownloadPortableEvidenceBundle")
.WithTags("Evidence")
.WithSummary("Download a sealed, portable evidence bundle for sealed or air-gapped distribution.");
app.MapPost("/evidence/verify",
async (HttpContext context, ClaimsPrincipal user, EvidenceVerifyRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/verify");
return ForbidTenant();
}
var trusted = await service.VerifyAsync(tenantId, EvidenceBundleId.FromGuid(request.BundleId), request.RootHash, cancellationToken);
EvidenceAuditLogger.LogVerificationResult(logger, user, tenantId, request.BundleId, request.RootHash, trusted);
return Results.Ok(new EvidenceVerifyResponseDto(trusted));
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceRead)
.Produces<EvidenceVerifyResponseDto>(StatusCodes.Status200OK)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.WithName("VerifyEvidenceBundle")
.WithTags("Evidence");
app.MapPost("/evidence/hold/{caseId}",
async (HttpContext context, ClaimsPrincipal user, string caseId, EvidenceHoldRequestDto request, EvidenceSnapshotService service, ILoggerFactory loggerFactory, CancellationToken cancellationToken) =>
{
var logger = loggerFactory.CreateLogger(EvidenceAuditLogger.LoggerName);
if (string.IsNullOrWhiteSpace(caseId))
{
return ValidationProblem("Case identifier is required.");
}
if (!TenantResolution.TryResolveTenant(user, out var tenantId))
{
EvidenceAuditLogger.LogTenantMissing(logger, user, context.Request.Path.Value ?? "/evidence/hold/{caseId}");
return ForbidTenant();
}
try
{
var hold = await service.CreateHoldAsync(tenantId, caseId, request.ToDomain(), cancellationToken);
EvidenceAuditLogger.LogHoldCreated(logger, user, tenantId, hold);
var dto = new EvidenceHoldResponseDto(
hold.Id.Value,
hold.BundleId?.Value,
hold.CaseId,
hold.Reason,
hold.CreatedAt,
hold.ExpiresAt,
hold.ReleasedAt,
hold.Notes);
return Results.Created($"/evidence/hold/{hold.Id.Value}", dto);
}
catch (InvalidOperationException ex)
{
if (ex.Message.Contains("does not exist", StringComparison.OrdinalIgnoreCase) && request.BundleId is Guid referencedBundle)
{
EvidenceAuditLogger.LogHoldBundleMissing(logger, user, tenantId, caseId, referencedBundle);
}
else if (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
EvidenceAuditLogger.LogHoldConflict(logger, user, tenantId, caseId);
}
else
{
EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message);
}
return ValidationProblem(ex.Message);
}
catch (ArgumentException ex)
{
EvidenceAuditLogger.LogHoldValidationFailure(logger, user, tenantId, caseId, ex.Message);
return ValidationProblem(ex.Message);
}
})
.RequireAuthorization(StellaOpsResourceServerPolicies.EvidenceCreate)
.Produces<EvidenceHoldResponseDto>(StatusCodes.Status201Created)
.Produces<ErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ErrorResponse>(StatusCodes.Status403Forbidden)
.WithName("CreateEvidenceHold")
.WithTags("Evidence")
.WithSummary("Create a legal hold for the specified case identifier.");
app.Run();
static IResult ForbidTenant() => Results.Forbid();
static IResult ValidationProblem(string message)
=> Results.ValidationProblem(new Dictionary<string, string[]>
{
["message"] = new[] { message }
});