Add property-based tests for SBOM/VEX document ordering and Unicode normalization determinism

- Implement `SbomVexOrderingDeterminismProperties` for testing component list and vulnerability metadata hash consistency.
- Create `UnicodeNormalizationDeterminismProperties` to validate NFC normalization and Unicode string handling.
- Add project file for `StellaOps.Testing.Determinism.Properties` with necessary dependencies.
- Introduce CI/CD template validation tests including YAML syntax checks and documentation content verification.
- Create validation script for CI/CD templates ensuring all required files and structures are present.
This commit is contained in:
StellaOps Bot
2025-12-26 15:17:15 +02:00
parent 7792749bb4
commit 907783f625
354 changed files with 79727 additions and 1346 deletions

View File

@@ -65,6 +65,21 @@ public static class ErrorCodes
/// <summary>Bundle not found in catalog.</summary>
public const string BundleNotFound = "BUNDLE_NOT_FOUND";
/// <summary>Feed snapshot not found.</summary>
public const string SnapshotNotFound = "SNAPSHOT_NOT_FOUND";
/// <summary>Invalid feed sources specified.</summary>
public const string InvalidSources = "INVALID_SOURCES";
/// <summary>Uploaded file is empty.</summary>
public const string EmptyFile = "EMPTY_FILE";
/// <summary>Uploaded file exceeds size limit.</summary>
public const string FileTooLarge = "FILE_TOO_LARGE";
/// <summary>Feature is disabled.</summary>
public const string FeatureDisabled = "FEATURE_DISABLED";
// ─────────────────────────────────────────────────────────────────────────
// AOC (Aggregation-Only Contract) Errors
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -4,6 +4,7 @@ internal static class ProblemTypes
{
public const string NotFound = "https://stellaops.org/problems/not-found";
public const string Validation = "https://stellaops.org/problems/validation";
public const string Forbidden = "https://stellaops.org/problems/forbidden";
public const string Conflict = "https://stellaops.org/problems/conflict";
public const string Locked = "https://stellaops.org/problems/locked";
public const string LeaseRejected = "https://stellaops.org/problems/lease-rejected";

View File

@@ -0,0 +1,442 @@
// -----------------------------------------------------------------------------
// FeedSnapshotEndpointExtensions.cs
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
// Task: DET-GAP-03
// Description: Feed snapshot endpoint for atomic multi-source snapshots
// -----------------------------------------------------------------------------
using System.Net.Mime;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Results;
using StellaOps.Replay.Core.FeedSnapshot;
using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace StellaOps.Concelier.WebService.Extensions;
/// <summary>
/// Endpoint extensions for feed snapshot functionality.
/// Provides atomic multi-source snapshots with composite digest.
/// Per DET-GAP-03 in SPRINT_20251226_007_BE_determinism_gaps.
/// </summary>
internal static class FeedSnapshotEndpointExtensions
{
/// <summary>
/// Maps feed snapshot endpoints to the application.
/// </summary>
public static void MapFeedSnapshotEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/feeds/snapshot")
.WithTags("FeedSnapshot")
.WithOpenApi();
// POST /api/v1/feeds/snapshot - Create atomic snapshot
group.MapPost("/", CreateSnapshotAsync)
.WithName("CreateFeedSnapshot")
.WithSummary("Create an atomic feed snapshot")
.WithDescription("Creates an atomic snapshot of all registered feed sources with a composite digest.");
// GET /api/v1/feeds/snapshot - List available snapshots
group.MapGet("/", ListSnapshotsAsync)
.WithName("ListFeedSnapshots")
.WithSummary("List available feed snapshots")
.WithDescription("Returns a list of available feed snapshots with metadata.");
// GET /api/v1/feeds/snapshot/{snapshotId} - Get snapshot details
group.MapGet("/{snapshotId}", GetSnapshotAsync)
.WithName("GetFeedSnapshot")
.WithSummary("Get feed snapshot details")
.WithDescription("Returns detailed information about a specific feed snapshot.");
// GET /api/v1/feeds/snapshot/{snapshotId}/export - Export snapshot bundle
group.MapGet("/{snapshotId}/export", ExportSnapshotAsync)
.WithName("ExportFeedSnapshot")
.WithSummary("Export feed snapshot bundle")
.WithDescription("Downloads the snapshot bundle as a compressed archive for offline use.");
// POST /api/v1/feeds/snapshot/import - Import snapshot bundle
group.MapPost("/import", ImportSnapshotAsync)
.WithName("ImportFeedSnapshot")
.WithSummary("Import feed snapshot bundle")
.WithDescription("Imports a snapshot bundle from a compressed archive.");
// GET /api/v1/feeds/snapshot/{snapshotId}/validate - Validate snapshot
group.MapGet("/{snapshotId}/validate", ValidateSnapshotAsync)
.WithName("ValidateFeedSnapshot")
.WithSummary("Validate feed snapshot integrity")
.WithDescription("Validates the integrity of a feed snapshot against current feed state.");
// GET /api/v1/feeds/sources - List registered feed sources
group.MapGet("/sources", ListSourcesAsync)
.WithName("ListFeedSources")
.WithSummary("List registered feed sources")
.WithDescription("Returns a list of registered feed sources available for snapshots.");
}
private static async Task<IResult> CreateSnapshotAsync(
HttpContext context,
IFeedSnapshotCoordinator coordinator,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
[FromBody] CreateSnapshotRequest? request,
CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
// Check if feed snapshot feature is enabled
if (!options.FeedSnapshot.Enabled)
{
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
}
// Validate requested sources if provided
if (request?.Sources is { Length: > 0 })
{
var registeredSources = coordinator.RegisteredSources;
var invalidSources = request.Sources
.Where(s => !registeredSources.Contains(s, StringComparer.OrdinalIgnoreCase))
.ToArray();
if (invalidSources.Length > 0)
{
return ConcelierProblemResultFactory.InvalidSources(
context,
invalidSources,
registeredSources);
}
}
var bundle = await coordinator.CreateSnapshotAsync(
request?.Label,
cancellationToken).ConfigureAwait(false);
var response = new CreateSnapshotResponse(
bundle.SnapshotId,
bundle.CompositeDigest,
bundle.CreatedAt,
bundle.Sources.Select(s => new SourceSnapshotSummary(
s.SourceId,
s.Digest,
s.ItemCount)).ToArray());
return HttpResults.Created(
$"/api/v1/feeds/snapshot/{bundle.SnapshotId}",
response);
}
private static async Task<IResult> ListSnapshotsAsync(
HttpContext context,
IFeedSnapshotCoordinator coordinator,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
[FromQuery] int? limit,
[FromQuery] string? cursor,
CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
if (!options.FeedSnapshot.Enabled)
{
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
}
var effectiveLimit = Math.Min(
Math.Max(limit ?? 50, 1),
options.FeedSnapshot.MaxListPageSize);
var summaries = await coordinator.ListSnapshotsAsync(
cursor,
effectiveLimit,
cancellationToken).ConfigureAwait(false);
var response = new ListSnapshotsResponse(
summaries.Select(s => new SnapshotListItem(
s.SnapshotId,
s.CompositeDigest,
s.CreatedAt,
s.Label,
s.SourceCount,
s.TotalItemCount)).ToArray());
return HttpResults.Ok(response);
}
private static async Task<IResult> GetSnapshotAsync(
HttpContext context,
IFeedSnapshotCoordinator coordinator,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
string snapshotId,
CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
if (!options.FeedSnapshot.Enabled)
{
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
}
var bundle = await coordinator.GetSnapshotAsync(snapshotId, cancellationToken)
.ConfigureAwait(false);
if (bundle is null)
{
return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId);
}
var response = new GetSnapshotResponse(
bundle.SnapshotId,
bundle.CompositeDigest,
bundle.CreatedAt,
bundle.Label,
bundle.Sources.Select(s => new SourceSnapshotDetail(
s.SourceId,
s.Digest,
s.ItemCount,
s.CreatedAt)).ToArray());
return HttpResults.Ok(response);
}
private static async Task<IResult> ExportSnapshotAsync(
HttpContext context,
IFeedSnapshotCoordinator coordinator,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
string snapshotId,
[FromQuery] string? format,
CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
if (!options.FeedSnapshot.Enabled)
{
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
}
var exportOptions = new ExportBundleOptions
{
Compression = ParseCompression(format),
IncludeManifest = true,
IncludeChecksums = true
};
var metadata = await coordinator.ExportBundleAsync(
snapshotId,
exportOptions,
cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId);
}
context.Response.Headers.ContentDisposition =
$"attachment; filename=\"snapshot-{snapshotId}.tar.{GetExtension(exportOptions.Compression)}\"";
return HttpResults.File(
metadata.ExportPath,
MediaTypeNames.Application.Octet,
$"snapshot-{snapshotId}.tar.{GetExtension(exportOptions.Compression)}");
}
private static async Task<IResult> ImportSnapshotAsync(
HttpContext context,
IFeedSnapshotCoordinator coordinator,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
IFormFile file,
[FromQuery] bool? validate,
CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
if (!options.FeedSnapshot.Enabled)
{
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
}
if (file.Length == 0)
{
return ConcelierProblemResultFactory.EmptyFile(context);
}
if (file.Length > options.FeedSnapshot.MaxBundleSizeBytes)
{
return ConcelierProblemResultFactory.FileTooLarge(
context,
file.Length,
options.FeedSnapshot.MaxBundleSizeBytes);
}
await using var stream = file.OpenReadStream();
var importOptions = new ImportBundleOptions
{
ValidateDigests = validate ?? true
};
var bundle = await coordinator.ImportBundleAsync(
stream,
importOptions,
cancellationToken).ConfigureAwait(false);
var response = new ImportSnapshotResponse(
bundle.SnapshotId,
bundle.CompositeDigest,
bundle.CreatedAt,
bundle.Sources.Count);
return HttpResults.Created(
$"/api/v1/feeds/snapshot/{bundle.SnapshotId}",
response);
}
private static async Task<IResult> ValidateSnapshotAsync(
HttpContext context,
IFeedSnapshotCoordinator coordinator,
IOptionsMonitor<ConcelierOptions> optionsMonitor,
string snapshotId,
CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
if (!options.FeedSnapshot.Enabled)
{
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
}
var result = await coordinator.ValidateSnapshotAsync(snapshotId, cancellationToken)
.ConfigureAwait(false);
if (result is null)
{
return ConcelierProblemResultFactory.SnapshotNotFound(context, snapshotId);
}
var response = new ValidateSnapshotResponse(
result.IsValid,
result.SnapshotDigest,
result.CurrentDigest,
result.DriftedSources.Select(d => new DriftedSourceInfo(
d.SourceId,
d.SnapshotDigest,
d.CurrentDigest,
d.AddedItems,
d.RemovedItems,
d.ModifiedItems)).ToArray());
return HttpResults.Ok(response);
}
private static IResult ListSourcesAsync(
HttpContext context,
IFeedSnapshotCoordinator coordinator,
IOptionsMonitor<ConcelierOptions> optionsMonitor)
{
var options = optionsMonitor.CurrentValue;
if (!options.FeedSnapshot.Enabled)
{
return ConcelierProblemResultFactory.FeatureDisabled(context, "FeedSnapshot");
}
var sources = coordinator.RegisteredSources;
return HttpResults.Ok(new ListSourcesResponse(sources.ToArray()));
}
private static CompressionAlgorithm ParseCompression(string? format)
{
return format?.ToUpperInvariant() switch
{
"ZSTD" => CompressionAlgorithm.Zstd,
"GZIP" or "GZ" => CompressionAlgorithm.Gzip,
"NONE" => CompressionAlgorithm.None,
_ => CompressionAlgorithm.Zstd // Default to Zstd
};
}
private static string GetExtension(CompressionAlgorithm compression)
{
return compression switch
{
CompressionAlgorithm.Zstd => "zst",
CompressionAlgorithm.Gzip => "gz",
_ => "tar"
};
}
}
// ---- Request/Response DTOs ----
/// <summary>Request to create a feed snapshot.</summary>
/// <param name="Label">Optional human-readable label for the snapshot.</param>
/// <param name="Sources">Optional list of source IDs to include. If null, all registered sources are included.</param>
public sealed record CreateSnapshotRequest(
string? Label,
string[]? Sources);
/// <summary>Response after creating a feed snapshot.</summary>
public sealed record CreateSnapshotResponse(
string SnapshotId,
string CompositeDigest,
DateTimeOffset CreatedAt,
SourceSnapshotSummary[] Sources);
/// <summary>Summary of a source in a snapshot.</summary>
public sealed record SourceSnapshotSummary(
string SourceId,
string Digest,
int ItemCount);
/// <summary>Response listing available snapshots.</summary>
public sealed record ListSnapshotsResponse(
SnapshotListItem[] Snapshots);
/// <summary>Item in the snapshot list.</summary>
public sealed record SnapshotListItem(
string SnapshotId,
string CompositeDigest,
DateTimeOffset CreatedAt,
string? Label,
int SourceCount,
int TotalItemCount);
/// <summary>Response with snapshot details.</summary>
public sealed record GetSnapshotResponse(
string SnapshotId,
string CompositeDigest,
DateTimeOffset CreatedAt,
string? Label,
SourceSnapshotDetail[] Sources);
/// <summary>Detailed info about a source in a snapshot.</summary>
public sealed record SourceSnapshotDetail(
string SourceId,
string Digest,
int ItemCount,
DateTimeOffset CreatedAt);
/// <summary>Response after importing a snapshot.</summary>
public sealed record ImportSnapshotResponse(
string SnapshotId,
string CompositeDigest,
DateTimeOffset CreatedAt,
int SourceCount);
/// <summary>Response from snapshot validation.</summary>
public sealed record ValidateSnapshotResponse(
bool IsValid,
string SnapshotDigest,
string CurrentDigest,
DriftedSourceInfo[] DriftedSources);
/// <summary>Info about a drifted source.</summary>
public sealed record DriftedSourceInfo(
string SourceId,
string SnapshotDigest,
string CurrentDigest,
int AddedItems,
int RemovedItems,
int ModifiedItems);
/// <summary>Response listing registered sources.</summary>
public sealed record ListSourcesResponse(
string[] Sources);

View File

@@ -44,6 +44,12 @@ public sealed class ConcelierOptions
/// </summary>
public FederationOptions Federation { get; set; } = new();
/// <summary>
/// Feed snapshot configuration for atomic multi-source snapshots.
/// Per DET-GAP-03 in SPRINT_20251226_007_BE_determinism_gaps.
/// </summary>
public FeedSnapshotOptions FeedSnapshot { get; set; } = new();
/// <summary>
/// Stella Router integration configuration (disabled by default).
/// When enabled, ASP.NET endpoints are automatically registered with the Router.
@@ -303,4 +309,41 @@ public sealed class ConcelierOptions
/// </summary>
public bool RequireSignature { get; set; } = true;
}
/// <summary>
/// Feed snapshot options for atomic multi-source snapshots.
/// Per DET-GAP-03 in SPRINT_20251226_007_BE_determinism_gaps.
/// </summary>
public sealed class FeedSnapshotOptions
{
/// <summary>
/// Enable feed snapshot endpoints.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Maximum list page size for snapshot listing.
/// </summary>
public int MaxListPageSize { get; set; } = 100;
/// <summary>
/// Maximum bundle size in bytes for import (default 1GB).
/// </summary>
public long MaxBundleSizeBytes { get; set; } = 1024L * 1024 * 1024;
/// <summary>
/// Snapshot retention days. Snapshots older than this are automatically cleaned up.
/// </summary>
public int RetentionDays { get; set; } = 90;
/// <summary>
/// Path to store snapshot bundles.
/// </summary>
public string StoragePath { get; set; } = System.IO.Path.Combine("out", "snapshots");
/// <summary>
/// Default compression algorithm for exports.
/// </summary>
public string DefaultCompression { get; set; } = "zstd";
}
}

View File

@@ -199,6 +199,92 @@ public static class ConcelierProblemResultFactory
return NotFound(context, ErrorCodes.BundleSourceNotFound, "Bundle source", sourceId);
}
/// <summary>
/// Creates a 404 Not Found response for snapshot not found.
/// Per DET-GAP-03.
/// </summary>
public static IResult SnapshotNotFound(HttpContext context, string? snapshotId = null)
{
return NotFound(context, ErrorCodes.SnapshotNotFound, "Feed snapshot", snapshotId);
}
/// <summary>
/// Creates a 400 Bad Request response for invalid feed sources.
/// Per DET-GAP-03.
/// </summary>
public static IResult InvalidSources(
HttpContext context,
IReadOnlyList<string> invalidSources,
IReadOnlyList<string> validSources)
{
return Problem(
context,
ProblemTypes.Validation,
"Invalid feed sources",
StatusCodes.Status400BadRequest,
ErrorCodes.InvalidSources,
$"Invalid sources: [{string.Join(", ", invalidSources)}]. Valid sources: [{string.Join(", ", validSources)}].",
"sources",
new Dictionary<string, object?>
{
["invalidSources"] = invalidSources,
["validSources"] = validSources
});
}
/// <summary>
/// Creates a 400 Bad Request response for empty file.
/// Per DET-GAP-03.
/// </summary>
public static IResult EmptyFile(HttpContext context)
{
return Problem(
context,
ProblemTypes.Validation,
"Empty file",
StatusCodes.Status400BadRequest,
ErrorCodes.EmptyFile,
"The uploaded file is empty.",
"file");
}
/// <summary>
/// Creates a 400 Bad Request response for file too large.
/// Per DET-GAP-03.
/// </summary>
public static IResult FileTooLarge(HttpContext context, long actualSize, long maxSize)
{
return Problem(
context,
ProblemTypes.Validation,
"File too large",
StatusCodes.Status400BadRequest,
ErrorCodes.FileTooLarge,
$"File size ({actualSize} bytes) exceeds maximum allowed ({maxSize} bytes).",
"file",
new Dictionary<string, object?>
{
["actualSize"] = actualSize,
["maxSize"] = maxSize
});
}
/// <summary>
/// Creates a 403 Forbidden response for feature disabled.
/// Per DET-GAP-03.
/// </summary>
public static IResult FeatureDisabled(HttpContext context, string featureName)
{
return Problem(
context,
ProblemTypes.Forbidden,
"Feature disabled",
StatusCodes.Status403Forbidden,
ErrorCodes.FeatureDisabled,
$"The {featureName} feature is not enabled. Enable it in configuration to use this endpoint.",
featureName);
}
/// <summary>
/// Creates a 404 Not Found response for bundle not found.
/// </summary>

View File

@@ -45,5 +45,6 @@
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="../../__Libraries/StellaOps.Router.AspNet/StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
</ItemGroup>
</Project>