audit work, fixed StellaOps.sln warnings/errors, fixed tests, sprints work, new advisories
This commit is contained in:
28
src/EvidenceLocker/AGENTS.md
Normal file
28
src/EvidenceLocker/AGENTS.md
Normal 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.
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:");
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user