audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories

This commit is contained in:
master
2026-01-07 18:49:59 +02:00
parent 04ec098046
commit 608a7f85c0
866 changed files with 56323 additions and 6231 deletions

View File

@@ -0,0 +1,28 @@
# EvidenceLocker Module Charter
## Mission
- Store, package, and export evidence bundles with deterministic outputs.
## Responsibilities
- Maintain evidence bundle schemas and export formats.
- Provide API and worker workflows for evidence packaging and retrieval.
- Enforce deterministic ordering, hashing, and offline-friendly behavior.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/evidence-locker/architecture.md
- docs/modules/evidence-locker/export-format.md
- docs/modules/evidence-locker/evidence-bundle-v1.md
- docs/modules/evidence-locker/attestation-contract.md
## Working Agreement
- Deterministic ordering and invariant formatting for export artifacts.
- Use TimeProvider and IGuidGenerator where timestamps or IDs are created.
- Propagate CancellationToken for async operations.
- Keep offline-first behavior (no network dependencies unless explicitly configured).
## Testing Strategy
- Unit tests for bundling, export serialization, and hash stability.
- Schema evolution tests for bundle compatibility.

View File

@@ -0,0 +1,233 @@
// -----------------------------------------------------------------------------
// ExportEndpoints.cs
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
// Tasks: T020, T021
// Description: Minimal API endpoints for evidence bundle export.
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace StellaOps.EvidenceLocker.Api;
/// <summary>
/// Minimal API endpoints for evidence bundle export.
/// </summary>
public static class ExportEndpoints
{
/// <summary>
/// Maps export endpoints to the application.
/// </summary>
public static void MapExportEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/v1/bundles")
.WithTags("Export");
// POST /api/v1/bundles/{bundleId}/export
group.MapPost("/{bundleId}/export", TriggerExportAsync)
.WithName("TriggerExport")
.WithSummary("Trigger an async evidence bundle export")
.Produces<ExportTriggerResponse>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
// GET /api/v1/bundles/{bundleId}/export/{exportId}
group.MapGet("/{bundleId}/export/{exportId}", GetExportStatusAsync)
.WithName("GetExportStatus")
.WithSummary("Get export status or download exported bundle")
.Produces<ExportStatusResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization();
// GET /api/v1/bundles/{bundleId}/export/{exportId}/download
group.MapGet("/{bundleId}/export/{exportId}/download", DownloadExportAsync)
.WithName("DownloadExport")
.WithSummary("Download the exported bundle")
.Produces(StatusCodes.Status200OK, contentType: "application/gzip")
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization();
}
private static async Task<IResult> TriggerExportAsync(
string bundleId,
[FromBody] ExportTriggerRequest request,
[FromServices] IExportJobService exportJobService,
CancellationToken cancellationToken)
{
var result = await exportJobService.CreateExportJobAsync(bundleId, request, cancellationToken);
if (result.IsNotFound)
{
return Results.NotFound(new { message = $"Bundle '{bundleId}' not found" });
}
return Results.Accepted(
$"/api/v1/bundles/{bundleId}/export/{result.ExportId}",
new ExportTriggerResponse
{
ExportId = result.ExportId,
Status = result.Status,
EstimatedSize = result.EstimatedSize,
StatusUrl = $"/api/v1/bundles/{bundleId}/export/{result.ExportId}"
});
}
private static async Task<IResult> GetExportStatusAsync(
string bundleId,
string exportId,
[FromServices] IExportJobService exportJobService,
CancellationToken cancellationToken)
{
var result = await exportJobService.GetExportStatusAsync(bundleId, exportId, cancellationToken);
if (result is null)
{
return Results.NotFound(new { message = $"Export '{exportId}' not found" });
}
if (result.Status == ExportJobStatusEnum.Ready)
{
return Results.Ok(new ExportStatusResponse
{
ExportId = result.ExportId,
Status = result.Status.ToString().ToLowerInvariant(),
DownloadUrl = $"/api/v1/bundles/{bundleId}/export/{exportId}/download",
FileSize = result.FileSize,
CompletedAt = result.CompletedAt
});
}
return Results.Accepted(value: new ExportStatusResponse
{
ExportId = result.ExportId,
Status = result.Status.ToString().ToLowerInvariant(),
Progress = result.Progress,
EstimatedTimeRemaining = result.EstimatedTimeRemaining
});
}
private static async Task<IResult> DownloadExportAsync(
string bundleId,
string exportId,
[FromServices] IExportJobService exportJobService,
CancellationToken cancellationToken)
{
var result = await exportJobService.GetExportFileAsync(bundleId, exportId, cancellationToken);
if (result is null)
{
return Results.NotFound(new { message = $"Export '{exportId}' not found" });
}
if (result.Status != ExportJobStatusEnum.Ready)
{
return Results.Conflict(new { message = "Export is not ready for download", status = result.Status.ToString().ToLowerInvariant() });
}
return Results.File(
result.FileStream!,
contentType: "application/gzip",
fileDownloadName: result.FileName);
}
}
/// <summary>
/// Request to trigger an evidence bundle export.
/// </summary>
public sealed record ExportTriggerRequest
{
/// <summary>
/// Export format (default: tar.gz).
/// </summary>
public string Format { get; init; } = "tar.gz";
/// <summary>
/// Compression type (gzip, brotli).
/// </summary>
public string Compression { get; init; } = "gzip";
/// <summary>
/// Compression level (1-9).
/// </summary>
public int CompressionLevel { get; init; } = 6;
/// <summary>
/// Whether to include Rekor transparency proofs.
/// </summary>
public bool IncludeRekorProofs { get; init; } = true;
/// <summary>
/// Whether to include per-layer SBOMs.
/// </summary>
public bool IncludeLayerSboms { get; init; } = true;
}
/// <summary>
/// Response after triggering an export.
/// </summary>
public sealed record ExportTriggerResponse
{
/// <summary>
/// Unique export job ID.
/// </summary>
public required string ExportId { get; init; }
/// <summary>
/// Current export status.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Estimated final bundle size in bytes.
/// </summary>
public long? EstimatedSize { get; init; }
/// <summary>
/// URL to poll for status updates.
/// </summary>
public required string StatusUrl { get; init; }
}
/// <summary>
/// Response for export status queries.
/// </summary>
public sealed record ExportStatusResponse
{
/// <summary>
/// Export job ID.
/// </summary>
public required string ExportId { get; init; }
/// <summary>
/// Current status: pending, processing, ready, failed.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// Progress percentage (0-100) when processing.
/// </summary>
public int? Progress { get; init; }
/// <summary>
/// Estimated time remaining when processing.
/// </summary>
public string? EstimatedTimeRemaining { get; init; }
/// <summary>
/// Download URL when ready.
/// </summary>
public string? DownloadUrl { get; init; }
/// <summary>
/// Final file size in bytes when ready.
/// </summary>
public long? FileSize { get; init; }
/// <summary>
/// Completion timestamp when ready.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
}

View File

@@ -0,0 +1,219 @@
// -----------------------------------------------------------------------------
// ExportJobService.cs
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
// Tasks: T020, T022, T023
// Description: Service implementation for managing evidence bundle export jobs.
// -----------------------------------------------------------------------------
using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Logging;
using StellaOps.EvidenceLocker.Export;
using StellaOps.EvidenceLocker.Storage;
namespace StellaOps.EvidenceLocker.Api;
/// <summary>
/// Service for managing evidence bundle export jobs with background processing.
/// </summary>
public sealed class ExportJobService : IExportJobService
{
private readonly IEvidenceLockerStorage _storage;
private readonly IEvidenceBundleExporter _exporter;
private readonly ILogger<ExportJobService> _logger;
private readonly TimeProvider _timeProvider;
private readonly ConcurrentDictionary<string, ExportJob> _jobs = new();
public ExportJobService(
IEvidenceLockerStorage storage,
IEvidenceBundleExporter exporter,
ILogger<ExportJobService> logger,
TimeProvider timeProvider)
{
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
_exporter = exporter ?? throw new ArgumentNullException(nameof(exporter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <inheritdoc/>
public async Task<ExportJobResult> CreateExportJobAsync(
string bundleId,
ExportTriggerRequest request,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(bundleId);
ArgumentNullException.ThrowIfNull(request);
// Check if bundle exists
var bundle = await _storage.GetBundleMetadataAsync(bundleId, cancellationToken);
if (bundle is null)
{
return new ExportJobResult { IsNotFound = true };
}
// Generate export ID
var exportId = GenerateExportId();
// Create job record
var job = new ExportJob
{
ExportId = exportId,
BundleId = bundleId,
Request = request,
Status = ExportJobStatusEnum.Pending,
CreatedAt = _timeProvider.GetUtcNow(),
EstimatedSize = bundle.Size
};
_jobs[exportId] = job;
_logger.LogInformation(
"Created export job {ExportId} for bundle {BundleId}",
exportId,
bundleId);
// Start background processing
_ = ProcessExportJobAsync(job, cancellationToken);
return new ExportJobResult
{
ExportId = exportId,
Status = "pending",
EstimatedSize = bundle.Size
};
}
/// <inheritdoc/>
public Task<ExportJobStatus?> GetExportStatusAsync(
string bundleId,
string exportId,
CancellationToken cancellationToken = default)
{
if (!_jobs.TryGetValue(exportId, out var job) || job.BundleId != bundleId)
{
return Task.FromResult<ExportJobStatus?>(null);
}
return Task.FromResult<ExportJobStatus?>(new ExportJobStatus
{
ExportId = job.ExportId,
Status = job.Status,
Progress = job.Progress,
EstimatedTimeRemaining = job.EstimatedTimeRemaining,
FileSize = job.FileSize,
CompletedAt = job.CompletedAt
});
}
/// <inheritdoc/>
public async Task<ExportFileResult?> GetExportFileAsync(
string bundleId,
string exportId,
CancellationToken cancellationToken = default)
{
if (!_jobs.TryGetValue(exportId, out var job) || job.BundleId != bundleId)
{
return null;
}
if (job.Status != ExportJobStatusEnum.Ready || string.IsNullOrEmpty(job.OutputPath))
{
return new ExportFileResult
{
Status = job.Status
};
}
var stream = new FileStream(
job.OutputPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
useAsync: true);
return new ExportFileResult
{
Status = job.Status,
FileStream = stream,
FileName = Path.GetFileName(job.OutputPath)
};
}
private async Task ProcessExportJobAsync(ExportJob job, CancellationToken cancellationToken)
{
try
{
job.Status = ExportJobStatusEnum.Processing;
job.Progress = 0;
_logger.LogInformation(
"Starting export processing for job {ExportId}",
job.ExportId);
var request = new ExportRequest
{
BundleId = job.BundleId,
OutputDirectory = Path.GetTempPath(),
Configuration = new ExportConfiguration
{
CompressionLevel = job.Request.CompressionLevel,
IncludeLayerSboms = job.Request.IncludeLayerSboms,
IncludeRekorProofs = job.Request.IncludeRekorProofs
}
};
// Update progress during export
job.Progress = 25;
var result = await _exporter.ExportAsync(request, cancellationToken);
job.Progress = 100;
job.Status = ExportJobStatusEnum.Ready;
job.OutputPath = result.FilePath;
job.FileSize = result.FileSize;
job.CompletedAt = _timeProvider.GetUtcNow();
_logger.LogInformation(
"Export job {ExportId} completed: {FilePath}, {FileSize} bytes",
job.ExportId,
result.FilePath,
result.FileSize);
}
catch (Exception ex)
{
job.Status = ExportJobStatusEnum.Failed;
job.ErrorMessage = ex.Message;
_logger.LogError(
ex,
"Export job {ExportId} failed",
job.ExportId);
}
}
private string GenerateExportId()
{
var timestamp = _timeProvider.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
var suffix = Guid.NewGuid().ToString("N")[..8];
return $"exp-{timestamp}-{suffix}";
}
private sealed class ExportJob
{
public required string ExportId { get; init; }
public required string BundleId { get; init; }
public required ExportTriggerRequest Request { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public ExportJobStatusEnum Status { get; set; }
public int Progress { get; set; }
public string? EstimatedTimeRemaining { get; set; }
public long? EstimatedSize { get; init; }
public long? FileSize { get; set; }
public string? OutputPath { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? ErrorMessage { get; set; }
}
}

View File

@@ -0,0 +1,132 @@
// -----------------------------------------------------------------------------
// IExportJobService.cs
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
// Tasks: T020, T022, T023
// Description: Service interface for managing evidence bundle export jobs.
// -----------------------------------------------------------------------------
namespace StellaOps.EvidenceLocker.Api;
/// <summary>
/// Service for managing evidence bundle export jobs.
/// </summary>
public interface IExportJobService
{
/// <summary>
/// Creates a new export job for a bundle.
/// </summary>
Task<ExportJobResult> CreateExportJobAsync(
string bundleId,
ExportTriggerRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current status of an export job.
/// </summary>
Task<ExportJobStatus?> GetExportStatusAsync(
string bundleId,
string exportId,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the exported file stream when ready.
/// </summary>
Task<ExportFileResult?> GetExportFileAsync(
string bundleId,
string exportId,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Result of creating an export job.
/// </summary>
public sealed record ExportJobResult
{
/// <summary>
/// Whether the bundle was not found.
/// </summary>
public bool IsNotFound { get; init; }
/// <summary>
/// Unique export job ID.
/// </summary>
public string ExportId { get; init; } = string.Empty;
/// <summary>
/// Current job status.
/// </summary>
public string Status { get; init; } = "pending";
/// <summary>
/// Estimated bundle size in bytes.
/// </summary>
public long? EstimatedSize { get; init; }
}
/// <summary>
/// Export job status details.
/// </summary>
public sealed record ExportJobStatus
{
/// <summary>
/// Export job ID.
/// </summary>
public required string ExportId { get; init; }
/// <summary>
/// Current status.
/// </summary>
public required ExportJobStatusEnum Status { get; init; }
/// <summary>
/// Progress percentage (0-100).
/// </summary>
public int Progress { get; init; }
/// <summary>
/// Estimated time remaining.
/// </summary>
public string? EstimatedTimeRemaining { get; init; }
/// <summary>
/// Final file size when ready.
/// </summary>
public long? FileSize { get; init; }
/// <summary>
/// Completion timestamp.
/// </summary>
public DateTimeOffset? CompletedAt { get; init; }
}
/// <summary>
/// Export job status enumeration.
/// </summary>
public enum ExportJobStatusEnum
{
Pending,
Processing,
Ready,
Failed
}
/// <summary>
/// Result containing the export file.
/// </summary>
public sealed record ExportFileResult
{
/// <summary>
/// Current status.
/// </summary>
public required ExportJobStatusEnum Status { get; init; }
/// <summary>
/// File stream (when ready).
/// </summary>
public Stream? FileStream { get; init; }
/// <summary>
/// Filename for download.
/// </summary>
public string FileName { get; init; } = "evidence-bundle.tar.gz";
}

View File

@@ -0,0 +1,89 @@
// -----------------------------------------------------------------------------
// IEvidenceBundleExporter.cs
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
// Description: Interface for exporting evidence bundles to archive format.
// -----------------------------------------------------------------------------
namespace StellaOps.EvidenceLocker.Export;
/// <summary>
/// Interface for exporting evidence bundles to archive format.
/// </summary>
public interface IEvidenceBundleExporter
{
/// <summary>
/// Exports an evidence bundle to the specified output location.
/// </summary>
/// <param name="request">Export request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result containing the exported file path and metadata.</returns>
Task<ExportResult> ExportAsync(ExportRequest request, CancellationToken cancellationToken);
}
/// <summary>
/// Request parameters for evidence bundle export.
/// </summary>
public sealed record ExportRequest
{
/// <summary>
/// Bundle ID to export.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Output directory for the exported archive.
/// </summary>
public required string OutputDirectory { get; init; }
/// <summary>
/// Export configuration options.
/// </summary>
public ExportConfiguration? Configuration { get; init; }
}
/// <summary>
/// Export configuration options.
/// </summary>
public sealed record ExportConfiguration
{
/// <summary>
/// Compression level (1-9).
/// </summary>
public int CompressionLevel { get; init; } = 6;
/// <summary>
/// Whether to include per-layer SBOMs.
/// </summary>
public bool IncludeLayerSboms { get; init; } = true;
/// <summary>
/// Whether to include Rekor transparency proofs.
/// </summary>
public bool IncludeRekorProofs { get; init; } = true;
}
/// <summary>
/// Result of an evidence bundle export operation.
/// </summary>
public sealed record ExportResult
{
/// <summary>
/// Full path to the exported archive file.
/// </summary>
public required string FilePath { get; init; }
/// <summary>
/// Size of the exported file in bytes.
/// </summary>
public required long FileSize { get; init; }
/// <summary>
/// SHA-256 hash of the exported file.
/// </summary>
public string? Sha256 { get; init; }
/// <summary>
/// Timestamp when export completed.
/// </summary>
public DateTimeOffset CompletedAt { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -1,5 +1,6 @@
using System.Buffers.Binary;
using System.Formats.Tar;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Text;
@@ -235,10 +236,10 @@ public sealed class EvidenceBundlePackagingService
builder.AppendLine("============================");
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O", CultureInfo.InvariantCulture));
if (details.Signature?.TimestampedAt is { } timestampedAt)
{
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O", CultureInfo.InvariantCulture));
}
builder.AppendLine();
builder.AppendLine("Verification steps:");

View File

@@ -1,6 +1,7 @@
using System.Buffers.Binary;
using System.Collections.ObjectModel;
using System.Formats.Tar;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
@@ -232,18 +233,18 @@ public sealed class EvidencePortableBundleService
builder.AppendLine("===================================");
builder.Append("Bundle ID: ").AppendLine(details.Bundle.Id.Value.ToString("D"));
builder.Append("Root Hash: ").AppendLine(details.Bundle.RootHash);
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O"));
builder.Append("Created At: ").AppendLine(manifest.CreatedAt.ToString("O", CultureInfo.InvariantCulture));
if (details.Bundle.SealedAt is { } sealedAt)
{
builder.Append("Sealed At: ").AppendLine(sealedAt.ToString("O"));
builder.Append("Sealed At: ").AppendLine(sealedAt.ToString("O", CultureInfo.InvariantCulture));
}
if (details.Signature?.TimestampedAt is { } timestampedAt)
{
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O"));
builder.Append("Timestamped At: ").AppendLine(timestampedAt.ToString("O", CultureInfo.InvariantCulture));
}
builder.Append("Portable Generated At: ").AppendLine(generatedAt.ToString("O"));
builder.Append("Portable Generated At: ").AppendLine(generatedAt.ToString("O", CultureInfo.InvariantCulture));
builder.AppendLine();
builder.AppendLine("Verification steps:");
builder.Append("1. Copy '").Append(options.ArtifactName).AppendLine("' into the sealed environment.");

View File

@@ -1,5 +1,6 @@
using System;
using System.Buffers;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
@@ -198,7 +199,7 @@ public sealed class EvidenceSignatureService : IEvidenceSignatureService
writer.WriteString("bundleId", manifest.BundleId.Value.ToString("D"));
writer.WriteString("tenantId", manifest.TenantId.Value.ToString("D"));
writer.WriteNumber("kind", (int)manifest.Kind);
writer.WriteString("createdAt", manifest.CreatedAt.UtcDateTime.ToString("O"));
writer.WriteString("createdAt", manifest.CreatedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture));
writer.WriteStartObject("metadata");
foreach (var kvp in manifest.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))

View File

@@ -0,0 +1,281 @@
// -----------------------------------------------------------------------------
// ExportEndpointsTests.cs
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
// Task: T024
// Description: Integration tests for evidence bundle export API endpoints.
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using StellaOps.EvidenceLocker.Api;
using Xunit;
namespace StellaOps.EvidenceLocker.Tests;
/// <summary>
/// Integration tests for export API endpoints.
/// </summary>
public sealed class ExportEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public ExportEndpointsTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
[Fact]
public async Task TriggerExport_ValidBundle_Returns202Accepted()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.CreateExportJobAsync(
"bundle-123",
It.IsAny<ExportTriggerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobResult
{
ExportId = "exp-123",
Status = "pending",
EstimatedSize = 1024
});
var client = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest();
// Act
var response = await client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ExportTriggerResponse>();
Assert.NotNull(result);
Assert.Equal("exp-123", result.ExportId);
Assert.Equal("pending", result.Status);
}
[Fact]
public async Task TriggerExport_BundleNotFound_Returns404()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.CreateExportJobAsync(
"nonexistent",
It.IsAny<ExportTriggerRequest>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobResult { IsNotFound = true });
var client = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest();
// Act
var response = await client.PostAsJsonAsync("/api/v1/bundles/nonexistent/export", request);
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task GetExportStatus_ExportReady_Returns200WithDownloadUrl()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportStatusAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobStatus
{
ExportId = "exp-123",
Status = ExportJobStatusEnum.Ready,
FileSize = 2048,
CompletedAt = DateTimeOffset.Parse("2026-01-07T12:00:00Z")
});
var client = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ExportStatusResponse>();
Assert.NotNull(result);
Assert.Equal("ready", result.Status);
Assert.Contains("download", result.DownloadUrl);
}
[Fact]
public async Task GetExportStatus_ExportProcessing_Returns202()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportStatusAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportJobStatus
{
ExportId = "exp-123",
Status = ExportJobStatusEnum.Processing,
Progress = 50,
EstimatedTimeRemaining = "30s"
});
var client = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123");
// Assert
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<ExportStatusResponse>();
Assert.NotNull(result);
Assert.Equal("processing", result.Status);
Assert.Equal(50, result.Progress);
}
[Fact]
public async Task GetExportStatus_ExportNotFound_Returns404()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportStatusAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportJobStatus?)null);
var client = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task DownloadExport_ExportReady_ReturnsFileStream()
{
// Arrange
var mockService = new Mock<IExportJobService>();
var testStream = new MemoryStream(new byte[] { 0x1f, 0x8b, 0x08 }); // gzip magic bytes
mockService
.Setup(s => s.GetExportFileAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportFileResult
{
Status = ExportJobStatusEnum.Ready,
FileStream = testStream,
FileName = "evidence-bundle-123.tar.gz"
});
var client = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/gzip", response.Content.Headers.ContentType?.MediaType);
}
[Fact]
public async Task DownloadExport_ExportNotReady_Returns409Conflict()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportFileAsync("bundle-123", "exp-123", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ExportFileResult
{
Status = ExportJobStatusEnum.Processing
});
var client = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/exp-123/download");
// Assert
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
}
[Fact]
public async Task DownloadExport_ExportNotFound_Returns404()
{
// Arrange
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.GetExportFileAsync("bundle-123", "nonexistent", It.IsAny<CancellationToken>()))
.ReturnsAsync((ExportFileResult?)null);
var client = CreateClientWithMock(mockService.Object);
// Act
var response = await client.GetAsync("/api/v1/bundles/bundle-123/export/nonexistent/download");
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task TriggerExport_WithOptions_PassesOptionsToService()
{
// Arrange
ExportTriggerRequest? capturedRequest = null;
var mockService = new Mock<IExportJobService>();
mockService
.Setup(s => s.CreateExportJobAsync(
"bundle-123",
It.IsAny<ExportTriggerRequest>(),
It.IsAny<CancellationToken>()))
.Callback<string, ExportTriggerRequest, CancellationToken>((_, req, _) => capturedRequest = req)
.ReturnsAsync(new ExportJobResult
{
ExportId = "exp-123",
Status = "pending"
});
var client = CreateClientWithMock(mockService.Object);
var request = new ExportTriggerRequest
{
CompressionLevel = 9,
IncludeLayerSboms = false,
IncludeRekorProofs = false
};
// Act
await client.PostAsJsonAsync("/api/v1/bundles/bundle-123/export", request);
// Assert
Assert.NotNull(capturedRequest);
Assert.Equal(9, capturedRequest.CompressionLevel);
Assert.False(capturedRequest.IncludeLayerSboms);
Assert.False(capturedRequest.IncludeRekorProofs);
}
private HttpClient CreateClientWithMock(IExportJobService mockService)
{
return _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove existing registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IExportJobService));
if (descriptor != null)
{
services.Remove(descriptor);
}
// Add mock
services.AddSingleton(mockService);
});
}).CreateClient();
}
}

View File

@@ -12,9 +12,10 @@
<ItemGroup>
<PackageReference Include="DotNet.Testcontainers" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Moq" />
<PackageReference Include="Npgsql" />
<PackageReference Include="xunit.v3" />
</ItemGroup>
<ItemGroup>
@@ -35,5 +36,26 @@
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
</ItemGroup>
<!-- Alias transitive web service references to avoid Program type conflicts -->
<Target Name="AliasTransitiveWebServices" AfterTargets="ResolveAssemblyReferences">
<ItemGroup>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Policy.Engine'">
<Aliases>PolicyEngineAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.SbomService'">
<Aliases>SbomServiceAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Scheduler'">
<Aliases>SchedulerAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Orchestrator'">
<Aliases>OrchestratorAlias</Aliases>
</ReferencePath>
<ReferencePath Condition="'%(FileName)' == 'StellaOps.Signals'">
<Aliases>SignalsAlias</Aliases>
</ReferencePath>
</ItemGroup>
</Target>
</Project>

View File

@@ -0,0 +1,60 @@
// -----------------------------------------------------------------------------
// IEvidenceLockerStorage.cs
// Sprint: SPRINT_20260106_003_003_EVIDENCE_export_bundle
// Description: Interface for evidence locker storage operations.
// -----------------------------------------------------------------------------
namespace StellaOps.EvidenceLocker.Storage;
/// <summary>
/// Interface for evidence locker storage operations.
/// </summary>
public interface IEvidenceLockerStorage
{
/// <summary>
/// Gets metadata for a specific evidence bundle.
/// </summary>
/// <param name="bundleId">Bundle ID to retrieve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Bundle metadata if found, null otherwise.</returns>
Task<BundleMetadata?> GetBundleMetadataAsync(string bundleId, CancellationToken cancellationToken);
/// <summary>
/// Checks if a bundle exists.
/// </summary>
/// <param name="bundleId">Bundle ID to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if bundle exists, false otherwise.</returns>
Task<bool> BundleExistsAsync(string bundleId, CancellationToken cancellationToken);
}
/// <summary>
/// Metadata for an evidence bundle.
/// </summary>
public sealed record BundleMetadata
{
/// <summary>
/// Unique bundle identifier.
/// </summary>
public required string BundleId { get; init; }
/// <summary>
/// Bundle size in bytes.
/// </summary>
public required long Size { get; init; }
/// <summary>
/// Creation timestamp.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// SHA-256 hash of the bundle.
/// </summary>
public string? Sha256 { get; init; }
/// <summary>
/// Storage location/key.
/// </summary>
public string? StorageKey { get; init; }
}

View File

@@ -0,0 +1,26 @@
# EvidenceLocker Export Library Charter
## Mission
- Export deterministic evidence bundles for offline verification.
## Responsibilities
- Implement tar.gz export, manifest/metadata serialization, and checksum generation.
- Enforce deterministic ordering, timestamps, permissions, and offline-friendly outputs.
- Keep export behavior aligned with docs/modules/evidence-locker/export-format.md.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/evidence-locker/architecture.md
- docs/modules/evidence-locker/export-format.md
## Working Agreement
- Use TimeProvider and injected ID generators for timestamps and identifiers.
- Validate file paths to prevent traversal in tar entries and output paths.
- Keep outputs deterministic (ordering, metadata, invariant formatting).
- Propagate CancellationToken for async operations.
## Testing Strategy
- Unit tests for checksum coverage, manifest ordering, and export determinism.
- Tests for tar/gzip metadata (permissions, timestamps) and path validation.

View File

@@ -0,0 +1,24 @@
# EvidenceLocker Export Tests Charter
## Mission
- Validate evidence bundle export behavior and determinism.
## Responsibilities
- Cover manifest/metadata serialization and checksum parsing/verification.
- Validate tar.gz export structure and verify script outputs.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/evidence-locker/architecture.md
- docs/modules/evidence-locker/export-format.md
## Working Agreement
- Use fixed timestamps and IDs in tests (no DateTimeOffset.UtcNow or Guid.NewGuid).
- Avoid network dependencies in tests.
- Assert deterministic ordering and metadata in archives.
## Testing Strategy
- Unit tests for export path validation, checksum coverage, and merkle proofs.
- Deterministic tar/gzip metadata verification.

View File

@@ -10,12 +10,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Moq" />
</ItemGroup>

View File

@@ -0,0 +1,24 @@
# EvidenceLocker SchemaEvolution Tests Charter
## Mission
- Validate EvidenceLocker schema evolution and backward compatibility.
## Responsibilities
- Exercise migrations, seed data, and compatibility checks for evidence locker schemas.
## Required Reading
- docs/README.md
- docs/07_HIGH_LEVEL_ARCHITECTURE.md
- docs/modules/platform/architecture-overview.md
- docs/modules/evidence-locker/architecture.md
- docs/modules/evidence-locker/bundle-packaging.md
- docs/modules/evidence-locker/evidence-bundle-v1.md
## Working Agreement
- Use deterministic fixtures and fixed timestamps/IDs.
- Avoid network dependencies in tests.
- Exercise real migrations and rollback paths when available.
## Testing Strategy
- Schema upgrade and rollback tests with real migrations.
- Compatibility tests for bundle and evidence tables.