notify doctors work, audit work, new product advisory sprints
This commit is contained in:
@@ -30,7 +30,7 @@ public sealed class CombinedRuntimeAdapter : IExportAdapter
|
||||
|
||||
private static readonly JsonWriterOptions WriterOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Encoder = JavaScriptEncoder.Default,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
@@ -66,7 +66,9 @@ public sealed class CombinedRuntimeAdapter : IExportAdapter
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return ExportAdapterResult.Failed(result.ErrorMessage ?? "Combined export failed");
|
||||
return ExportAdapterResult.Failed(
|
||||
result.ErrorMessage ?? "Combined export failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
var counts = new ExportManifestCounts
|
||||
@@ -106,12 +108,12 @@ public sealed class CombinedRuntimeAdapter : IExportAdapter
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ExportAdapterResult.Failed("Export cancelled");
|
||||
return ExportAdapterResult.Failed("Export cancelled", context.TimeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Combined runtime export failed");
|
||||
return ExportAdapterResult.Failed($"Export failed: {ex.Message}");
|
||||
return ExportAdapterResult.Failed($"Export failed: {ex.Message}", context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,10 +189,13 @@ public sealed class CombinedRuntimeAdapter : IExportAdapter
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var content = await context.DataFetcher.FetchAsync(item, cancellationToken);
|
||||
var content = await context.DataFetcher.FetchAsync(item, cancellationToken);
|
||||
if (!content.Success)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, content.ErrorMessage ?? "Failed to fetch"));
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -220,7 +225,10 @@ public sealed class CombinedRuntimeAdapter : IExportAdapter
|
||||
var content = await context.DataFetcher.FetchAsync(item, cancellationToken);
|
||||
if (!content.Success)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, content.ErrorMessage ?? "Failed to fetch"));
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ public sealed record AdapterItemResult
|
||||
|
||||
public DateTimeOffset ProcessedAt { get; init; }
|
||||
|
||||
public static AdapterItemResult Failed(Guid itemId, string errorMessage)
|
||||
public static AdapterItemResult Failed(Guid itemId, string errorMessage, TimeProvider? timeProvider = null)
|
||||
=> new()
|
||||
{
|
||||
ItemId = itemId,
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
ProcessedAt = (timeProvider ?? TimeProvider.System).GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,8 +87,13 @@ public sealed record ExportAdapterResult
|
||||
|
||||
public DateTimeOffset CompletedAt { get; init; }
|
||||
|
||||
public static ExportAdapterResult Failed(string errorMessage)
|
||||
=> new() { Success = false, ErrorMessage = errorMessage, CompletedAt = DateTimeOffset.UtcNow };
|
||||
public static ExportAdapterResult Failed(string errorMessage, TimeProvider? timeProvider = null)
|
||||
=> new()
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = errorMessage,
|
||||
CompletedAt = (timeProvider ?? TimeProvider.System).GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Planner;
|
||||
|
||||
namespace StellaOps.ExportCenter.Core.Adapters;
|
||||
@@ -88,6 +89,11 @@ public sealed record ExportAdapterContext
|
||||
/// Time provider for deterministic timestamps.
|
||||
/// </summary>
|
||||
public TimeProvider TimeProvider { get; init; } = TimeProvider.System;
|
||||
|
||||
/// <summary>
|
||||
/// GUID provider for deterministic identifiers.
|
||||
/// </summary>
|
||||
public IGuidProvider GuidProvider { get; init; } = SystemGuidProvider.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -78,7 +78,9 @@ public sealed class JsonPolicyAdapter : IExportAdapter
|
||||
}
|
||||
else
|
||||
{
|
||||
return ExportAdapterResult.Failed(ndjsonResult.ErrorMessage ?? "NDJSON export failed");
|
||||
return ExportAdapterResult.Failed(
|
||||
ndjsonResult.ErrorMessage ?? "NDJSON export failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -131,12 +133,12 @@ public sealed class JsonPolicyAdapter : IExportAdapter
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ExportAdapterResult.Failed("Export cancelled");
|
||||
return ExportAdapterResult.Failed("Export cancelled", context.TimeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "JSON policy export failed");
|
||||
return ExportAdapterResult.Failed($"Export failed: {ex.Message}");
|
||||
return ExportAdapterResult.Failed($"Export failed: {ex.Message}", context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,19 +187,25 @@ public sealed class JsonPolicyAdapter : IExportAdapter
|
||||
var content = await context.DataFetcher.FetchAsync(item, cancellationToken);
|
||||
if (!content.Success)
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, content.ErrorMessage ?? "Failed to fetch content");
|
||||
return AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch content",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(content.JsonContent))
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, "Item content is empty");
|
||||
return AdapterItemResult.Failed(item.ItemId, "Item content is empty", context.TimeProvider);
|
||||
}
|
||||
|
||||
// Normalize the data content
|
||||
var normalized = _normalizer.Normalize(content.JsonContent);
|
||||
if (!normalized.Success)
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, normalized.ErrorMessage ?? "Normalization failed");
|
||||
return AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
normalized.ErrorMessage ?? "Normalization failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
// Get policy metadata if evaluator is available
|
||||
@@ -223,12 +231,15 @@ public sealed class JsonPolicyAdapter : IExportAdapter
|
||||
if (compression != CompressionFormat.None)
|
||||
{
|
||||
var compressed = _compressor.CompressBytes(outputBytes, compression);
|
||||
if (!compressed.Success)
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, compressed.ErrorMessage ?? "Compression failed");
|
||||
if (!compressed.Success)
|
||||
{
|
||||
return AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
compressed.ErrorMessage ?? "Compression failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
outputBytes = compressed.CompressedData!;
|
||||
}
|
||||
outputBytes = compressed.CompressedData!;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
var fileName = BuildFileName(item, context.Config);
|
||||
@@ -257,7 +268,7 @@ public sealed class JsonPolicyAdapter : IExportAdapter
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process item {ItemId}", item.ItemId);
|
||||
return AdapterItemResult.Failed(item.ItemId, ex.Message);
|
||||
return AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,20 +318,26 @@ public sealed class JsonPolicyAdapter : IExportAdapter
|
||||
var content = await context.DataFetcher.FetchAsync(item, cancellationToken);
|
||||
if (!content.Success)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, content.ErrorMessage ?? "Failed to fetch"));
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(content.JsonContent))
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, "Empty content"));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, "Empty content", context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = _normalizer.Normalize(content.JsonContent);
|
||||
if (!normalized.Success)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, normalized.ErrorMessage ?? "Normalization failed"));
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
normalized.ErrorMessage ?? "Normalization failed",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -348,7 +365,7 @@ public sealed class JsonPolicyAdapter : IExportAdapter
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,9 @@ public sealed class JsonRawAdapter : IExportAdapter
|
||||
}
|
||||
else
|
||||
{
|
||||
return ExportAdapterResult.Failed(ndjsonResult.ErrorMessage ?? "NDJSON export failed");
|
||||
return ExportAdapterResult.Failed(
|
||||
ndjsonResult.ErrorMessage ?? "NDJSON export failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -124,12 +126,12 @@ public sealed class JsonRawAdapter : IExportAdapter
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return ExportAdapterResult.Failed("Export cancelled");
|
||||
return ExportAdapterResult.Failed("Export cancelled", context.TimeProvider);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "JSON raw export failed");
|
||||
return ExportAdapterResult.Failed($"Export failed: {ex.Message}");
|
||||
return ExportAdapterResult.Failed($"Export failed: {ex.Message}", context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,19 +180,25 @@ public sealed class JsonRawAdapter : IExportAdapter
|
||||
var content = await context.DataFetcher.FetchAsync(item, cancellationToken);
|
||||
if (!content.Success)
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, content.ErrorMessage ?? "Failed to fetch content");
|
||||
return AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch content",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(content.JsonContent))
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, "Item content is empty");
|
||||
return AdapterItemResult.Failed(item.ItemId, "Item content is empty", context.TimeProvider);
|
||||
}
|
||||
|
||||
// Normalize JSON
|
||||
var normalized = _normalizer.Normalize(content.JsonContent);
|
||||
if (!normalized.Success)
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, normalized.ErrorMessage ?? "Normalization failed");
|
||||
return AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
normalized.ErrorMessage ?? "Normalization failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
// Apply pretty print if requested
|
||||
@@ -209,7 +217,10 @@ public sealed class JsonRawAdapter : IExportAdapter
|
||||
var compressed = _compressor.CompressBytes(outputBytes, compression);
|
||||
if (!compressed.Success)
|
||||
{
|
||||
return AdapterItemResult.Failed(item.ItemId, compressed.ErrorMessage ?? "Compression failed");
|
||||
return AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
compressed.ErrorMessage ?? "Compression failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
outputBytes = compressed.CompressedData!;
|
||||
}
|
||||
@@ -241,7 +252,7 @@ public sealed class JsonRawAdapter : IExportAdapter
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process item {ItemId}", item.ItemId);
|
||||
return AdapterItemResult.Failed(item.ItemId, ex.Message);
|
||||
return AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,20 +272,26 @@ public sealed class JsonRawAdapter : IExportAdapter
|
||||
var content = await context.DataFetcher.FetchAsync(item, cancellationToken);
|
||||
if (!content.Success)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, content.ErrorMessage ?? "Failed to fetch"));
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(content.JsonContent))
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, "Empty content"));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, "Empty content", context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = _normalizer.Normalize(content.JsonContent);
|
||||
if (!normalized.Success)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, normalized.ErrorMessage ?? "Normalization failed"));
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
normalized.ErrorMessage ?? "Normalization failed",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -292,7 +309,7 @@ public sealed class JsonRawAdapter : IExportAdapter
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
context.Items.Count);
|
||||
|
||||
// Create temp directory for staging files
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"mirror-{Guid.NewGuid():N}");
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"mirror-{context.GuidProvider.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
@@ -81,7 +81,7 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
|
||||
// Build the mirror bundle
|
||||
var request = new MirrorBundleBuildRequest(
|
||||
Guid.TryParse(context.CorrelationId, out var runId) ? runId : Guid.NewGuid(),
|
||||
Guid.TryParse(context.CorrelationId, out var runId) ? runId : context.GuidProvider.NewGuid(),
|
||||
context.TenantId,
|
||||
MirrorBundleVariant.Full,
|
||||
selectors,
|
||||
@@ -176,7 +176,7 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to build mirror bundle");
|
||||
return ExportAdapterResult.Failed($"Mirror bundle build failed: {ex.Message}");
|
||||
return ExportAdapterResult.Failed($"Mirror bundle build failed: {ex.Message}", context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,13 +297,13 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
OutputPath = tempFilePath,
|
||||
OutputSizeBytes = new FileInfo(tempFilePath).Length,
|
||||
ContentHash = content.OriginalHash,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
ProcessedAt = context.TimeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process item {ItemId}", item.ItemId);
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
if (deltaOptions is null)
|
||||
{
|
||||
return ExportAdapterResult.Failed(
|
||||
"Delta options required: provide 'baseExportId' and 'baseManifestDigest' in context metadata");
|
||||
"Delta options required: provide 'baseExportId' and 'baseManifestDigest' in context metadata",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -68,7 +69,7 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
deltaOptions.BaseExportId, context.Items.Count);
|
||||
|
||||
// Create temp directory for staging files
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"mirror-delta-{Guid.NewGuid():N}");
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"mirror-delta-{context.GuidProvider.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
@@ -100,7 +101,9 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
var deltaResult = await _deltaService.ComputeDeltaAsync(deltaRequest, cancellationToken);
|
||||
if (!deltaResult.Success)
|
||||
{
|
||||
return ExportAdapterResult.Failed(deltaResult.ErrorMessage ?? "Delta computation failed");
|
||||
return ExportAdapterResult.Failed(
|
||||
deltaResult.ErrorMessage ?? "Delta computation failed",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
// If no changes, return early with empty delta
|
||||
@@ -123,7 +126,7 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
|
||||
// Create the delta bundle request
|
||||
var bundleRequest = new MirrorBundleBuildRequest(
|
||||
Guid.TryParse(context.CorrelationId, out var runId) ? runId : Guid.NewGuid(),
|
||||
Guid.TryParse(context.CorrelationId, out var runId) ? runId : context.GuidProvider.NewGuid(),
|
||||
context.TenantId,
|
||||
MirrorBundleVariant.Delta,
|
||||
selectors,
|
||||
@@ -236,7 +239,9 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to build mirror delta bundle");
|
||||
return ExportAdapterResult.Failed($"Mirror delta bundle build failed: {ex.Message}");
|
||||
return ExportAdapterResult.Failed(
|
||||
$"Mirror delta bundle build failed: {ex.Message}",
|
||||
context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +325,8 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch content or content is empty"));
|
||||
content.ErrorMessage ?? "Failed to fetch content or content is empty",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -330,7 +336,8 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
$"Unknown item kind: {item.Kind}"));
|
||||
$"Unknown item kind: {item.Kind}",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -388,7 +395,7 @@ public sealed class MirrorDeltaAdapter : IExportAdapter
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process item {ItemId}", item.ItemId);
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,11 +71,12 @@ public sealed class TrivyDbAdapter : IExportAdapter
|
||||
if (_options.SchemaVersion != SupportedSchemaVersion)
|
||||
{
|
||||
return ExportAdapterResult.Failed(
|
||||
$"Unsupported Trivy DB schema version {_options.SchemaVersion}. Only v{SupportedSchemaVersion} is supported.");
|
||||
$"Unsupported Trivy DB schema version {_options.SchemaVersion}. Only v{SupportedSchemaVersion} is supported.",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
// Create temp directory for staging
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"trivy-db-{Guid.NewGuid():N}");
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"trivy-db-{context.GuidProvider.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
@@ -100,7 +101,8 @@ public sealed class TrivyDbAdapter : IExportAdapter
|
||||
if (totalVulnCount == 0 && !_options.AllowEmpty)
|
||||
{
|
||||
return ExportAdapterResult.Failed(
|
||||
"No vulnerabilities mapped. Set AllowEmpty=true to allow empty bundles.");
|
||||
"No vulnerabilities mapped. Set AllowEmpty=true to allow empty bundles.",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -202,7 +204,9 @@ public sealed class TrivyDbAdapter : IExportAdapter
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to build Trivy DB bundle");
|
||||
return ExportAdapterResult.Failed($"Trivy DB bundle build failed: {ex.Message}");
|
||||
return ExportAdapterResult.Failed(
|
||||
$"Trivy DB bundle build failed: {ex.Message}",
|
||||
context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +289,8 @@ public sealed class TrivyDbAdapter : IExportAdapter
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch content or content is empty"));
|
||||
content.ErrorMessage ?? "Failed to fetch content or content is empty",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -298,7 +303,7 @@ public sealed class TrivyDbAdapter : IExportAdapter
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
Success = true,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
ProcessedAt = context.TimeProvider.GetUtcNow()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -327,13 +332,13 @@ public sealed class TrivyDbAdapter : IExportAdapter
|
||||
ItemId = item.ItemId,
|
||||
Success = true,
|
||||
ContentHash = content.OriginalHash,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
ProcessedAt = context.TimeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process item {ItemId}", item.ItemId);
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,7 +389,7 @@ public sealed class TrivyDbAdapter : IExportAdapter
|
||||
int vulnerabilityCount)
|
||||
{
|
||||
var now = context.TimeProvider.GetUtcNow();
|
||||
var runId = Guid.TryParse(context.CorrelationId, out var id) ? id : Guid.NewGuid();
|
||||
var runId = Guid.TryParse(context.CorrelationId, out var id) ? id : context.GuidProvider.NewGuid();
|
||||
|
||||
return new TrivyDbMetadata
|
||||
{
|
||||
|
||||
@@ -77,7 +77,7 @@ public sealed class TrivyJavaDbAdapter : IExportAdapter
|
||||
context.Items.Count);
|
||||
|
||||
// Create temp directory for staging
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"trivy-java-db-{Guid.NewGuid():N}");
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"trivy-java-db-{context.GuidProvider.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
try
|
||||
@@ -110,7 +110,8 @@ public sealed class TrivyJavaDbAdapter : IExportAdapter
|
||||
if (totalVulnCount == 0 && !_options.AllowEmpty)
|
||||
{
|
||||
return ExportAdapterResult.Failed(
|
||||
"No Java vulnerabilities mapped. Set AllowEmpty=true to allow empty bundles.");
|
||||
"No Java vulnerabilities mapped. Set AllowEmpty=true to allow empty bundles.",
|
||||
context.TimeProvider);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
@@ -209,7 +210,9 @@ public sealed class TrivyJavaDbAdapter : IExportAdapter
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to build Trivy Java DB bundle");
|
||||
return ExportAdapterResult.Failed($"Trivy Java DB bundle build failed: {ex.Message}");
|
||||
return ExportAdapterResult.Failed(
|
||||
$"Trivy Java DB bundle build failed: {ex.Message}",
|
||||
context.TimeProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +289,8 @@ public sealed class TrivyJavaDbAdapter : IExportAdapter
|
||||
{
|
||||
itemResults.Add(AdapterItemResult.Failed(
|
||||
item.ItemId,
|
||||
content.ErrorMessage ?? "Failed to fetch content or content is empty"));
|
||||
content.ErrorMessage ?? "Failed to fetch content or content is empty",
|
||||
context.TimeProvider));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -299,7 +303,7 @@ public sealed class TrivyJavaDbAdapter : IExportAdapter
|
||||
{
|
||||
ItemId = item.ItemId,
|
||||
Success = true,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
ProcessedAt = context.TimeProvider.GetUtcNow()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -359,13 +363,13 @@ public sealed class TrivyJavaDbAdapter : IExportAdapter
|
||||
ItemId = item.ItemId,
|
||||
Success = true,
|
||||
ContentHash = content.OriginalHash,
|
||||
ProcessedAt = DateTimeOffset.UtcNow
|
||||
ProcessedAt = context.TimeProvider.GetUtcNow()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to process item {ItemId}", item.ItemId);
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message));
|
||||
itemResults.Add(AdapterItemResult.Failed(item.ItemId, ex.Message, context.TimeProvider));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,7 +458,7 @@ public sealed class TrivyJavaDbAdapter : IExportAdapter
|
||||
int vulnerabilityCount)
|
||||
{
|
||||
var now = context.TimeProvider.GetUtcNow();
|
||||
var runId = Guid.TryParse(context.CorrelationId, out var id) ? id : Guid.NewGuid();
|
||||
var runId = Guid.TryParse(context.CorrelationId, out var id) ? id : context.GuidProvider.NewGuid();
|
||||
|
||||
return new TrivyJavaDbMetadata
|
||||
{
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -40,7 +43,7 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
var items = GenerateResolvedItems(tenantId, scope);
|
||||
|
||||
// Apply sampling if configured
|
||||
var (sampledItems, samplingMetadata) = ApplySampling(items, scope.Sampling);
|
||||
var (sampledItems, samplingMetadata) = ApplySampling(items, scope.Sampling, tenantId, scope);
|
||||
|
||||
// Apply max items limit
|
||||
var maxItems = scope.MaxItems ?? DefaultMaxItems;
|
||||
@@ -223,7 +226,7 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
foreach (var sourceRef in scope.SourceRefs)
|
||||
{
|
||||
var kind = scope.TargetKinds.FirstOrDefault() ?? "sbom";
|
||||
items.Add(CreateResolvedItem(sourceRef, kind, now));
|
||||
items.Add(CreateResolvedItem(tenantId, sourceRef, kind, now));
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -237,7 +240,7 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
for (var i = 0; i < itemsPerKind; i++)
|
||||
{
|
||||
var sourceRef = $"{kind}-{tenantId:N}-{i:D4}";
|
||||
items.Add(CreateResolvedItem(sourceRef, kind, now.AddHours(-i)));
|
||||
items.Add(CreateResolvedItem(tenantId, sourceRef, kind, now.AddHours(-i)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,11 +288,12 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
return items;
|
||||
}
|
||||
|
||||
private ResolvedExportItem CreateResolvedItem(string sourceRef, string kind, DateTimeOffset createdAt)
|
||||
private static ResolvedExportItem CreateResolvedItem(Guid tenantId, string sourceRef, string kind, DateTimeOffset createdAt)
|
||||
{
|
||||
var itemId = CreateDeterministicItemId(tenantId, sourceRef, kind);
|
||||
return new ResolvedExportItem
|
||||
{
|
||||
ItemId = Guid.NewGuid(),
|
||||
ItemId = itemId,
|
||||
Kind = kind,
|
||||
SourceRef = sourceRef,
|
||||
Name = $"{kind}-{sourceRef}",
|
||||
@@ -308,14 +312,16 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
|
||||
private static (List<ResolvedExportItem> Items, SamplingMetadata? Metadata) ApplySampling(
|
||||
List<ResolvedExportItem> items,
|
||||
SamplingConfig? sampling)
|
||||
SamplingConfig? sampling,
|
||||
Guid tenantId,
|
||||
ExportScope scope)
|
||||
{
|
||||
if (sampling is null || sampling.Strategy == SamplingStrategy.None)
|
||||
{
|
||||
return (items, null);
|
||||
}
|
||||
|
||||
var seed = sampling.Seed ?? Environment.TickCount;
|
||||
var seed = sampling.Seed ?? ComputeDeterministicSeed(tenantId, scope);
|
||||
var size = Math.Min(sampling.Size, items.Count);
|
||||
|
||||
List<ResolvedExportItem> sampled;
|
||||
@@ -382,4 +388,66 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
_ => item.Metadata.TryGetValue(field, out var value) ? value : "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateDeterministicItemId(Guid tenantId, string sourceRef, string kind)
|
||||
{
|
||||
var seed = $"{tenantId:D}|{kind}|{sourceRef}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
return new Guid(hash.AsSpan(0, 16).ToArray());
|
||||
}
|
||||
|
||||
private static int ComputeDeterministicSeed(Guid tenantId, ExportScope scope)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("tenant=").Append(tenantId.ToString("D"));
|
||||
AppendList(builder, "targets", scope.TargetKinds);
|
||||
AppendList(builder, "sources", scope.SourceRefs);
|
||||
AppendList(builder, "tags", scope.Tags);
|
||||
AppendList(builder, "namespaces", scope.Namespaces);
|
||||
AppendList(builder, "exclude", scope.ExcludePatterns);
|
||||
AppendList(builder, "runIds", scope.RunIds.Select(id => id.ToString("D")).ToList());
|
||||
|
||||
if (scope.DateRange is not null)
|
||||
{
|
||||
builder.Append("|dateField=").Append(scope.DateRange.Field.ToString());
|
||||
if (scope.DateRange.From.HasValue)
|
||||
{
|
||||
builder.Append("|dateFrom=").Append(scope.DateRange.From.Value.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
if (scope.DateRange.To.HasValue)
|
||||
{
|
||||
builder.Append("|dateTo=").Append(scope.DateRange.To.Value.ToString("O", CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
if (scope.MaxItems.HasValue)
|
||||
{
|
||||
builder.Append("|maxItems=").Append(scope.MaxItems.Value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
if (scope.Sampling is not null)
|
||||
{
|
||||
builder.Append("|sampling=").Append(scope.Sampling.Strategy.ToString());
|
||||
builder.Append("|sampleSize=").Append(scope.Sampling.Size.ToString(CultureInfo.InvariantCulture));
|
||||
if (!string.IsNullOrWhiteSpace(scope.Sampling.StratifyBy))
|
||||
{
|
||||
builder.Append("|stratifyBy=").Append(scope.Sampling.StratifyBy);
|
||||
}
|
||||
}
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return BinaryPrimitives.ReadInt32LittleEndian(hash.AsSpan(0, 4));
|
||||
}
|
||||
|
||||
private static void AppendList(StringBuilder builder, string label, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Append('|').Append(label).Append('=');
|
||||
var ordered = values.OrderBy(v => v, StringComparer.Ordinal);
|
||||
builder.Append(string.Join(",", ordered));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Reflection;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.WebService.Api;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Api;
|
||||
|
||||
public sealed class ExportApiEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapToProfileResponse_InvalidJson_ReturnsNullConfig()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 1, 2, 14, 0, 0, TimeSpan.Zero);
|
||||
var profile = new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
Name = "test",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
ScopeJson = "{invalid",
|
||||
FormatJson = "{invalid",
|
||||
SigningJson = "{invalid",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
var method = typeof(ExportApiEndpoints).GetMethod(
|
||||
"MapToProfileResponse",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var response = (ExportProfileResponse)method!.Invoke(null, new object[] { profile })!;
|
||||
|
||||
Assert.Null(response.Scope);
|
||||
Assert.Null(response.Format);
|
||||
Assert.Null(response.Signing);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.WebService.Api;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Api;
|
||||
|
||||
public class ExportApiRepositoryTests
|
||||
{
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
private readonly Guid _tenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public ExportApiRepositoryTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Profile Repository Tests
|
||||
@@ -16,7 +23,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_CreateAsync_StoresProfile()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile();
|
||||
|
||||
// Act
|
||||
@@ -31,7 +38,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_GetByIdAsync_ReturnsStoredProfile()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile();
|
||||
await repo.CreateAsync(profile);
|
||||
|
||||
@@ -48,7 +55,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_GetByIdAsync_ReturnsNull_WhenNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
|
||||
// Act
|
||||
var retrieved = await repo.GetByIdAsync(_tenantId, Guid.NewGuid());
|
||||
@@ -61,7 +68,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_GetByIdAsync_ReturnsNull_WhenWrongTenant()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile();
|
||||
await repo.CreateAsync(profile);
|
||||
|
||||
@@ -76,7 +83,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_ListAsync_ReturnsAllProfilesForTenant()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile1 = CreateTestProfile("Profile 1");
|
||||
var profile2 = CreateTestProfile("Profile 2");
|
||||
var otherTenantProfile = CreateTestProfile("Other Tenant") with { TenantId = Guid.NewGuid() };
|
||||
@@ -98,7 +105,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_ListAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var activeProfile = CreateTestProfile("Active") with { Status = ExportProfileStatus.Active };
|
||||
var draftProfile = CreateTestProfile("Draft") with { Status = ExportProfileStatus.Draft };
|
||||
|
||||
@@ -118,7 +125,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_ListAsync_FiltersByKind()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var adhocProfile = CreateTestProfile("AdHoc") with { Kind = ExportProfileKind.AdHoc };
|
||||
var scheduledProfile = CreateTestProfile("Scheduled") with { Kind = ExportProfileKind.Scheduled };
|
||||
|
||||
@@ -138,7 +145,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_ListAsync_SearchesByName()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile1 = CreateTestProfile("Daily SBOM Export");
|
||||
var profile2 = CreateTestProfile("Weekly VEX Export");
|
||||
|
||||
@@ -158,7 +165,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_UpdateAsync_ModifiesProfile()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile();
|
||||
await repo.CreateAsync(profile);
|
||||
|
||||
@@ -179,7 +186,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_ArchiveAsync_SetsArchivedStatus()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile();
|
||||
await repo.CreateAsync(profile);
|
||||
|
||||
@@ -193,13 +200,15 @@ public class ExportApiRepositoryTests
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(ExportProfileStatus.Archived, retrieved.Status);
|
||||
Assert.NotNull(retrieved.ArchivedAt);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), retrieved.ArchivedAt);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), retrieved.UpdatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProfileRepo_IsNameUniqueAsync_ReturnsTrueForUniqueName()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile("Existing Profile");
|
||||
await repo.CreateAsync(profile);
|
||||
|
||||
@@ -214,7 +223,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_IsNameUniqueAsync_ReturnsFalseForDuplicateName()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile("Existing Profile");
|
||||
await repo.CreateAsync(profile);
|
||||
|
||||
@@ -229,7 +238,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task ProfileRepo_IsNameUniqueAsync_ExcludesSpecifiedProfile()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance);
|
||||
var repo = new InMemoryExportProfileRepository(NullLogger<InMemoryExportProfileRepository>.Instance, _timeProvider);
|
||||
var profile = CreateTestProfile("Existing Profile");
|
||||
await repo.CreateAsync(profile);
|
||||
|
||||
@@ -248,7 +257,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_CreateAsync_StoresRun()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var run = CreateTestRun();
|
||||
|
||||
// Act
|
||||
@@ -263,7 +272,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_GetByIdAsync_ReturnsStoredRun()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var run = CreateTestRun();
|
||||
await repo.CreateAsync(run);
|
||||
|
||||
@@ -279,7 +288,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_ListAsync_FiltersByProfileId()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var profileId1 = Guid.NewGuid();
|
||||
var profileId2 = Guid.NewGuid();
|
||||
|
||||
@@ -302,7 +311,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_ListAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var runningRun = CreateTestRun() with { Status = ExportRunStatus.Running };
|
||||
var completedRun = CreateTestRun() with { Status = ExportRunStatus.Completed };
|
||||
|
||||
@@ -322,7 +331,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_CancelAsync_CancelsQueuedRun()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var run = CreateTestRun() with { Status = ExportRunStatus.Queued };
|
||||
await repo.CreateAsync(run);
|
||||
|
||||
@@ -334,13 +343,14 @@ public class ExportApiRepositoryTests
|
||||
|
||||
var retrieved = await repo.GetByIdAsync(_tenantId, run.RunId);
|
||||
Assert.Equal(ExportRunStatus.Cancelled, retrieved?.Status);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), retrieved?.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunRepo_CancelAsync_CancelsRunningRun()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var run = CreateTestRun() with { Status = ExportRunStatus.Running };
|
||||
await repo.CreateAsync(run);
|
||||
|
||||
@@ -355,7 +365,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_CancelAsync_ReturnsFalseForCompletedRun()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var run = CreateTestRun() with { Status = ExportRunStatus.Completed };
|
||||
await repo.CreateAsync(run);
|
||||
|
||||
@@ -370,7 +380,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_GetActiveRunsCountAsync_CountsRunningRuns()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
|
||||
await repo.CreateAsync(CreateTestRun() with { Status = ExportRunStatus.Running });
|
||||
await repo.CreateAsync(CreateTestRun() with { Status = ExportRunStatus.Running });
|
||||
@@ -388,7 +398,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_GetActiveRunsCountAsync_FiltersByProfileId()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var profileId = Guid.NewGuid();
|
||||
|
||||
await repo.CreateAsync(CreateTestRun() with { ProfileId = profileId, Status = ExportRunStatus.Running });
|
||||
@@ -405,7 +415,7 @@ public class ExportApiRepositoryTests
|
||||
public async Task RunRepo_GetQueuedRunsCountAsync_CountsQueuedRuns()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance);
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
|
||||
await repo.CreateAsync(CreateTestRun() with { Status = ExportRunStatus.Queued });
|
||||
await repo.CreateAsync(CreateTestRun() with { Status = ExportRunStatus.Queued });
|
||||
@@ -418,6 +428,23 @@ public class ExportApiRepositoryTests
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunRepo_DequeueNextRunAsync_MarksRunAsRunning()
|
||||
{
|
||||
// Arrange
|
||||
var repo = new InMemoryExportRunRepository(NullLogger<InMemoryExportRunRepository>.Instance, _timeProvider);
|
||||
var run = CreateTestRun() with { Status = ExportRunStatus.Queued };
|
||||
await repo.CreateAsync(run);
|
||||
|
||||
// Act
|
||||
var dequeued = await repo.DequeueNextRunAsync(_tenantId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(dequeued);
|
||||
Assert.Equal(ExportRunStatus.Running, dequeued!.Status);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), dequeued.StartedAt);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Artifact Repository Tests
|
||||
// ========================================================================
|
||||
@@ -507,8 +534,8 @@ public class ExportApiRepositoryTests
|
||||
Description = "Test profile description",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -522,7 +549,7 @@ public class ExportApiRepositoryTests
|
||||
Status = ExportRunStatus.Running,
|
||||
Trigger = ExportRunTrigger.Api,
|
||||
CorrelationId = Guid.NewGuid().ToString(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -539,7 +566,8 @@ public class ExportApiRepositoryTests
|
||||
SizeBytes = 1024,
|
||||
ContentType = "application/json",
|
||||
Checksum = "sha256:abc123",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.ExportCenter.WebService.Api;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Api;
|
||||
|
||||
public sealed class ExportApiServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddExportApiServices_Throws_WhenInMemoryNotAllowed()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() =>
|
||||
services.AddExportApiServices(_ => { }, allowInMemoryRepositories: false));
|
||||
|
||||
Assert.Contains("In-memory export repositories are disabled", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddExportApiServices_AllowsExplicitInMemoryRegistration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddExportApiServices(_ => { }, allowInMemoryRepositories: true);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var repo = provider.GetService<IExportProfileRepository>();
|
||||
Assert.NotNull(repo);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.ExportCenter.WebService.Api;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Api;
|
||||
|
||||
@@ -12,6 +13,7 @@ public class ExportAuditServiceTests
|
||||
{
|
||||
_auditService = new ExportAuditService(
|
||||
NullLogger<ExportAuditService>.Instance,
|
||||
new SequentialGuidProvider(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.ExportCenter.WebService;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Api;
|
||||
|
||||
public sealed class OpenApiDiscoveryEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public void MapOpenApiDiscovery_AllowsAnonymousWhenConfigured()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
builder.Configuration["OpenApi:AllowAnonymous"] = "true";
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapOpenApiDiscovery();
|
||||
|
||||
var endpoint = GetEndpoint(app, "/.well-known/openapi");
|
||||
var allowAnonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>();
|
||||
|
||||
Assert.NotNull(allowAnonymous);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapOpenApiDiscovery_DoesNotAllowAnonymousWhenDisabled()
|
||||
{
|
||||
var builder = CreateBuilder();
|
||||
builder.Configuration["OpenApi:AllowAnonymous"] = "false";
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapOpenApiDiscovery();
|
||||
|
||||
var endpoint = GetEndpoint(app, "/.well-known/openapi");
|
||||
var allowAnonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>();
|
||||
|
||||
Assert.Null(allowAnonymous);
|
||||
}
|
||||
|
||||
private static RouteEndpoint GetEndpoint(IEndpointRouteBuilder app, string pattern)
|
||||
{
|
||||
var endpoints = app.DataSources.SelectMany(source => source.Endpoints).OfType<RouteEndpoint>();
|
||||
return endpoints.Single(endpoint => string.Equals(endpoint.RoutePattern.RawText, pattern, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static WebApplicationBuilder CreateBuilder()
|
||||
{
|
||||
var contentRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(contentRoot);
|
||||
|
||||
return WebApplication.CreateBuilder(new WebApplicationOptions
|
||||
{
|
||||
EnvironmentName = Environments.Production,
|
||||
ContentRootPath = contentRoot
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
using StellaOps.ExportCenter.Tests;
|
||||
using StellaOps.ExportCenter.WebService.AuditBundle;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.AuditBundle;
|
||||
|
||||
public sealed class AuditBundleJobHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateBundleAsync_UsesGuidProvider()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 10, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var handler = new AuditBundleJobHandler(
|
||||
NullLogger<AuditBundleJobHandler>.Instance,
|
||||
guidProvider,
|
||||
timeProvider);
|
||||
|
||||
var request = new CreateAuditBundleRequest(
|
||||
new BundleSubjectRefDto(
|
||||
"container",
|
||||
"example-image",
|
||||
new Dictionary<string, string> { ["sha256"] = "abc123" }),
|
||||
TimeWindow: null,
|
||||
IncludeContent: new AuditBundleContentSelection(
|
||||
VulnReports: false,
|
||||
Sbom: false,
|
||||
VexDecisions: false,
|
||||
PolicyEvaluations: false,
|
||||
Attestations: false));
|
||||
|
||||
var result = await handler.CreateBundleAsync(request, "actor-1", "Actor One", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result.Response);
|
||||
Assert.Equal("bndl-00000000000000000000000000000001", result.Response!.BundleId);
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ public sealed class DeprecationHeaderExtensionsTests
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
DeprecatedAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SunsetAt: new DateTimeOffset(2025, 7, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SuccessorPath: "/v1/new",
|
||||
DocumentationUrl: "https://docs.example.com/migration");
|
||||
|
||||
@@ -76,8 +76,8 @@ public sealed class DeprecationHeaderExtensionsTests
|
||||
{
|
||||
var context = CreateHttpContext();
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
DeprecatedAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SunsetAt: new DateTimeOffset(2025, 7, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SuccessorPath: "/v1/new",
|
||||
Reason: "Custom deprecation reason");
|
||||
|
||||
@@ -123,8 +123,8 @@ public sealed class DeprecationHeaderExtensionsTests
|
||||
private static DeprecationInfo CreateSampleDeprecationInfo()
|
||||
{
|
||||
return new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
DeprecatedAt: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SunsetAt: new DateTimeOffset(2025, 7, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SuccessorPath: "/v1/new-endpoint");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,46 +8,54 @@ public sealed class DeprecationInfoTests
|
||||
[Fact]
|
||||
public void IsPastSunset_WhenSunsetInFuture_ReturnsFalse()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(now);
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(6),
|
||||
DeprecatedAt: now.AddMonths(-1),
|
||||
SunsetAt: now.AddMonths(6),
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.False(info.IsPastSunset);
|
||||
Assert.False(info.IsPastSunsetAt(timeProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPastSunset_WhenSunsetInPast_ReturnsTrue()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(now);
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow.AddMonths(-12),
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
DeprecatedAt: now.AddMonths(-12),
|
||||
SunsetAt: now.AddMonths(-1),
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.True(info.IsPastSunset);
|
||||
Assert.True(info.IsPastSunsetAt(timeProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DaysUntilSunset_CalculatesCorrectly()
|
||||
{
|
||||
var sunset = DateTimeOffset.UtcNow.AddDays(30);
|
||||
var now = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(now);
|
||||
var sunset = now.AddDays(30);
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow,
|
||||
DeprecatedAt: now,
|
||||
SunsetAt: sunset,
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.Equal(30, info.DaysUntilSunset);
|
||||
Assert.Equal(30, info.DaysUntilSunsetAt(timeProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DaysUntilSunset_WhenPastSunset_ReturnsZero()
|
||||
{
|
||||
var now = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(now);
|
||||
var info = new DeprecationInfo(
|
||||
DeprecatedAt: DateTimeOffset.UtcNow.AddMonths(-12),
|
||||
SunsetAt: DateTimeOffset.UtcNow.AddMonths(-1),
|
||||
DeprecatedAt: now.AddMonths(-12),
|
||||
SunsetAt: now.AddMonths(-1),
|
||||
SuccessorPath: "/v1/new");
|
||||
|
||||
Assert.Equal(0, info.DaysUntilSunset);
|
||||
Assert.Equal(0, info.DaysUntilSunsetAt(timeProvider));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,4 +77,16 @@ public sealed class DeprecationInfoTests
|
||||
Assert.Equal("https://docs.example.com", info.DocumentationUrl);
|
||||
Assert.Equal("Replaced by new API", info.Reason);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.WebService.Distribution;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution;
|
||||
|
||||
@@ -9,17 +10,20 @@ public sealed class ExportDistributionLifecycleTests
|
||||
private readonly InMemoryExportDistributionRepository _repository;
|
||||
private readonly ExportDistributionLifecycle _lifecycle;
|
||||
private readonly TestTimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
private readonly Guid _runId = Guid.NewGuid();
|
||||
private readonly Guid _profileId = Guid.NewGuid();
|
||||
|
||||
public ExportDistributionLifecycleTests()
|
||||
{
|
||||
_repository = new InMemoryExportDistributionRepository();
|
||||
_timeProvider = new TestTimeProvider(new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
_guidProvider = new SequentialGuidProvider();
|
||||
_repository = new InMemoryExportDistributionRepository(_timeProvider);
|
||||
_lifecycle = new ExportDistributionLifecycle(
|
||||
_repository,
|
||||
NullLogger<ExportDistributionLifecycle>.Instance,
|
||||
_guidProvider,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.Tests;
|
||||
using StellaOps.ExportCenter.WebService.Distribution;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution;
|
||||
|
||||
public sealed class InMemoryExportDistributionRepositoryTests
|
||||
{
|
||||
private readonly InMemoryExportDistributionRepository _repository = new();
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly InMemoryExportDistributionRepository _repository;
|
||||
private readonly Guid _tenantId = Guid.NewGuid();
|
||||
private readonly Guid _runId = Guid.NewGuid();
|
||||
|
||||
public InMemoryExportDistributionRepositoryTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
_repository = new InMemoryExportDistributionRepository(_timeProvider);
|
||||
}
|
||||
|
||||
private ExportDistribution CreateDistribution(
|
||||
Guid? distributionId = null,
|
||||
Guid? tenantId = null,
|
||||
Guid? runId = null,
|
||||
string? idempotencyKey = null,
|
||||
ExportDistributionStatus status = ExportDistributionStatus.Pending)
|
||||
ExportDistributionStatus status = ExportDistributionStatus.Pending,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
return new ExportDistribution
|
||||
{
|
||||
@@ -28,7 +38,7 @@ public sealed class InMemoryExportDistributionRepositoryTests
|
||||
ArtifactHash = "sha256:abc123",
|
||||
SizeBytes = 1024,
|
||||
IdempotencyKey = idempotencyKey,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = createdAt ?? _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,7 +148,7 @@ public sealed class InMemoryExportDistributionRepositoryTests
|
||||
[Fact]
|
||||
public async Task ListExpiredAsync_ReturnsOnlyExpired()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var expired = new ExportDistribution
|
||||
{
|
||||
@@ -151,7 +161,7 @@ public sealed class InMemoryExportDistributionRepositoryTests
|
||||
ArtifactPath = "/test",
|
||||
RetentionExpiresAt = now.AddDays(-1),
|
||||
MarkedForDeletion = false,
|
||||
CreatedAt = now.AddDays(-30)
|
||||
CreatedAt = now.AddHours(-1)
|
||||
};
|
||||
|
||||
var notExpired = new ExportDistribution
|
||||
@@ -165,7 +175,7 @@ public sealed class InMemoryExportDistributionRepositoryTests
|
||||
ArtifactPath = "/test",
|
||||
RetentionExpiresAt = now.AddDays(30),
|
||||
MarkedForDeletion = false,
|
||||
CreatedAt = now.AddDays(-30)
|
||||
CreatedAt = now.AddHours(-1)
|
||||
};
|
||||
|
||||
await _repository.CreateAsync(expired);
|
||||
@@ -273,6 +283,7 @@ public sealed class InMemoryExportDistributionRepositoryTests
|
||||
var updated = await _repository.GetByIdAsync(_tenantId, distribution.DistributionId);
|
||||
Assert.True(updated?.MarkedForDeletion);
|
||||
Assert.NotNull(updated?.DeletedAt);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), updated?.DeletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -339,4 +350,50 @@ public sealed class InMemoryExportDistributionRepositoryTests
|
||||
var result = _repository.ListByRunAsync(_tenantId, _runId).GetAwaiter().GetResult();
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneStale_RemovesEntriesBeyondRetention()
|
||||
{
|
||||
var options = Options.Create(new InMemoryExportDistributionOptions
|
||||
{
|
||||
RetentionPeriod = TimeSpan.FromHours(1),
|
||||
MaxEntries = 0
|
||||
});
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
var repository = new InMemoryExportDistributionRepository(timeProvider, options);
|
||||
|
||||
var stale = CreateDistribution(createdAt: timeProvider.GetUtcNow().AddHours(-2));
|
||||
var fresh = CreateDistribution(createdAt: timeProvider.GetUtcNow().AddMinutes(-30));
|
||||
|
||||
await repository.CreateAsync(stale);
|
||||
await repository.CreateAsync(fresh);
|
||||
|
||||
var result = await repository.ListByRunAsync(_tenantId, _runId);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(fresh.DistributionId, result[0].DistributionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PruneStale_RespectsMaxEntries()
|
||||
{
|
||||
var options = Options.Create(new InMemoryExportDistributionOptions
|
||||
{
|
||||
RetentionPeriod = TimeSpan.Zero,
|
||||
MaxEntries = 1
|
||||
});
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 12, 0, 0, TimeSpan.Zero));
|
||||
var repository = new InMemoryExportDistributionRepository(timeProvider, options);
|
||||
|
||||
var older = CreateDistribution(createdAt: timeProvider.GetUtcNow().AddMinutes(-10));
|
||||
var newer = CreateDistribution(createdAt: timeProvider.GetUtcNow());
|
||||
|
||||
await repository.CreateAsync(older);
|
||||
await repository.CreateAsync(newer);
|
||||
|
||||
var result = await repository.ListByRunAsync(_tenantId, _runId);
|
||||
|
||||
Assert.Single(result);
|
||||
Assert.Equal(newer.DistributionId, result[0].DistributionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution.Oci;
|
||||
|
||||
public sealed class OciDistributionServiceExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddOciDistribution_AllowInsecureTls_UsesValidationCallback()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOciDistribution(options => options.AllowInsecureTls = true);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var factory = provider.GetRequiredService<IHttpMessageHandlerFactory>();
|
||||
|
||||
var handler = factory.CreateHandler(OciDistributionOptions.HttpClientName);
|
||||
var primary = GetPrimaryHandler(handler);
|
||||
|
||||
Assert.NotNull(primary.ServerCertificateCustomValidationCallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddOciDistribution_DisallowInsecureTls_DoesNotSetValidationCallback()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOciDistribution(options => options.AllowInsecureTls = false);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var factory = provider.GetRequiredService<IHttpMessageHandlerFactory>();
|
||||
|
||||
var handler = factory.CreateHandler(OciDistributionOptions.HttpClientName);
|
||||
var primary = GetPrimaryHandler(handler);
|
||||
|
||||
Assert.Null(primary.ServerCertificateCustomValidationCallback);
|
||||
}
|
||||
|
||||
private static HttpClientHandler GetPrimaryHandler(HttpMessageHandler handler)
|
||||
{
|
||||
var current = handler;
|
||||
while (current is DelegatingHandler delegating)
|
||||
{
|
||||
current = delegating.InnerHandler ?? throw new InvalidOperationException("Missing inner handler.");
|
||||
}
|
||||
|
||||
return Assert.IsType<HttpClientHandler>(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Net.Http;
|
||||
using StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution.Oci;
|
||||
|
||||
public sealed class OciHttpClientFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateClient_ConfiguresBaseAddressAndTimeout()
|
||||
{
|
||||
using var httpClient = new HttpClient();
|
||||
var config = new OciRegistryConfig
|
||||
{
|
||||
Global = new RegistryGlobalSettings
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(12),
|
||||
UserAgent = "StellaOps-Test"
|
||||
},
|
||||
Registries =
|
||||
{
|
||||
["registry.example.com"] = new RegistryEndpointConfig
|
||||
{
|
||||
Host = "registry.example.com",
|
||||
Port = 5000,
|
||||
Insecure = true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var factory = new OciHttpClientFactory(config, new FakeHttpClientFactory(httpClient));
|
||||
|
||||
var client = factory.CreateClient("registry.example.com");
|
||||
|
||||
Assert.Same(httpClient, client);
|
||||
Assert.Equal(new Uri("http://registry.example.com:5000"), client.BaseAddress);
|
||||
Assert.Equal(TimeSpan.FromSeconds(12), client.Timeout);
|
||||
Assert.Contains("StellaOps-Test", client.DefaultRequestHeaders.UserAgent.ToString());
|
||||
}
|
||||
|
||||
private sealed class FakeHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public FakeHttpClientFactory(HttpClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => _client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.EvidenceLocker;
|
||||
|
||||
public sealed class EvidenceLockerServiceCollectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddExportEvidenceLocker_InvalidBaseUrl_Throws()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddExportEvidenceLocker(options => options.BaseUrl = "not-a-url");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
provider.GetRequiredService<IExportEvidenceLockerClient>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Tests;
|
||||
using StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.EvidenceLocker;
|
||||
|
||||
public sealed class InMemoryExportEvidenceLockerClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task PushSnapshotAsync_SortsEntriesAndUsesDeterministicIds()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 8, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var calculator = new ExportMerkleTreeCalculator();
|
||||
var client = new InMemoryExportEvidenceLockerClient(calculator, timeProvider, guidProvider);
|
||||
|
||||
var request = new ExportEvidenceSnapshotRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
ExportRunId = "run-1",
|
||||
ProfileId = "profile-1",
|
||||
Kind = ExportBundleKind.Evidence,
|
||||
Materials = new[]
|
||||
{
|
||||
new ExportMaterialInput
|
||||
{
|
||||
Section = "reports",
|
||||
Path = "z.json",
|
||||
Sha256 = "ABCDEF",
|
||||
SizeBytes = 10,
|
||||
MediaType = "application/json"
|
||||
},
|
||||
new ExportMaterialInput
|
||||
{
|
||||
Section = "reports",
|
||||
Path = "a.json",
|
||||
Sha256 = "123456",
|
||||
SizeBytes = 20,
|
||||
MediaType = "application/json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.PushSnapshotAsync(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("00000000000000000000000000000001", result.BundleId);
|
||||
|
||||
var manifest = await client.GetBundleAsync(result.BundleId!, request.TenantId);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), manifest!.CreatedAt);
|
||||
|
||||
var paths = manifest.Entries.Select(e => e.CanonicalPath).ToList();
|
||||
var sorted = paths.OrderBy(p => p, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(sorted, paths);
|
||||
|
||||
Assert.All(manifest.Entries, entry => Assert.Equal(entry.Sha256, entry.Sha256.ToLowerInvariant()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Tests;
|
||||
using StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.ExceptionReport;
|
||||
|
||||
public sealed class ExceptionReportGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateReportAsync_UsesGuidProvider()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 11, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var exceptionRepo = new Mock<IExceptionRepository>();
|
||||
var applicationRepo = new Mock<IExceptionApplicationRepository>();
|
||||
|
||||
exceptionRepo
|
||||
.Setup(repo => repo.GetByFilterAsync(It.IsAny<ExceptionFilter>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<ExceptionObject>());
|
||||
|
||||
var generator = new ExceptionReportGenerator(
|
||||
exceptionRepo.Object,
|
||||
applicationRepo.Object,
|
||||
NullLogger<ExceptionReportGenerator>.Instance,
|
||||
guidProvider,
|
||||
timeProvider);
|
||||
|
||||
var response = await generator.CreateReportAsync(new ExceptionReportRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RequesterId = "user-1",
|
||||
Format = "json"
|
||||
});
|
||||
|
||||
Assert.StartsWith("exc-rpt-", response.JobId);
|
||||
Assert.EndsWith("00000000000000000000000000000001", response.JobId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateReportAsync_SummaryKeysAreOrdered()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 11, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var exceptionRepo = new Mock<IExceptionRepository>();
|
||||
var applicationRepo = new Mock<IExceptionApplicationRepository>();
|
||||
|
||||
var exceptions = new[]
|
||||
{
|
||||
CreateException("exc-2", ExceptionStatus.Revoked, tenantId, timeProvider.GetUtcNow()),
|
||||
CreateException("exc-1", ExceptionStatus.Active, tenantId, timeProvider.GetUtcNow())
|
||||
};
|
||||
|
||||
exceptionRepo
|
||||
.Setup(repo => repo.GetByFilterAsync(It.IsAny<ExceptionFilter>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(exceptions);
|
||||
|
||||
var generator = new ExceptionReportGenerator(
|
||||
exceptionRepo.Object,
|
||||
applicationRepo.Object,
|
||||
NullLogger<ExceptionReportGenerator>.Instance,
|
||||
guidProvider,
|
||||
timeProvider);
|
||||
|
||||
var response = await generator.CreateReportAsync(new ExceptionReportRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RequesterId = "user-1",
|
||||
Format = "json"
|
||||
});
|
||||
|
||||
var content = await WaitForContentAsync(generator, response.JobId);
|
||||
using var document = JsonDocument.Parse(content.Content);
|
||||
var byStatus = document.RootElement.GetProperty("summary").GetProperty("byStatus");
|
||||
var keys = byStatus.EnumerateObject().Select(p => p.Name).ToList();
|
||||
|
||||
Assert.Equal(new[] { "Active", "Revoked" }, keys);
|
||||
}
|
||||
|
||||
private static ExceptionObject CreateException(
|
||||
string exceptionId,
|
||||
ExceptionStatus status,
|
||||
Guid tenantId,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
return new ExceptionObject
|
||||
{
|
||||
ExceptionId = exceptionId,
|
||||
Version = 1,
|
||||
Status = status,
|
||||
Type = ExceptionType.Vulnerability,
|
||||
Scope = new ExceptionScope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
VulnerabilityId = "CVE-2024-0001"
|
||||
},
|
||||
OwnerId = "owner-1",
|
||||
RequesterId = "requester-1",
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
ExpiresAt = now.AddDays(30),
|
||||
ReasonCode = ExceptionReason.AcceptedRisk,
|
||||
Rationale = new string('a', 60),
|
||||
EvidenceRefs = ImmutableArray<string>.Empty,
|
||||
CompensatingControls = ImmutableArray<string>.Empty,
|
||||
Metadata = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<ExceptionReportContent> WaitForContentAsync(
|
||||
IExceptionReportGenerator generator,
|
||||
string jobId)
|
||||
{
|
||||
var timeout = TimeSpan.FromSeconds(2);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
while (stopwatch.Elapsed < timeout)
|
||||
{
|
||||
var content = await generator.GetReportContentAsync(jobId);
|
||||
if (content is not null)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(10));
|
||||
}
|
||||
|
||||
throw new TimeoutException("Report content not available.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Tests;
|
||||
using StellaOps.ExportCenter.WebService.Incident;
|
||||
using StellaOps.ExportCenter.WebService.Timeline;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Incident;
|
||||
|
||||
public sealed class ExportIncidentManagerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ActivateIncidentAsync_UsesGuidProvider()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 12, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var manager = new ExportIncidentManager(
|
||||
NullLogger<ExportIncidentManager>.Instance,
|
||||
new FakeTimelinePublisher(),
|
||||
new FakeNotificationEmitter(),
|
||||
guidProvider,
|
||||
timeProvider);
|
||||
|
||||
var request = new ExportIncidentActivationRequest
|
||||
{
|
||||
Type = ExportIncidentType.SecurityIncident,
|
||||
Severity = ExportIncidentSeverity.Critical,
|
||||
Summary = "Test incident",
|
||||
Description = "Test description",
|
||||
ActivatedBy = "tester"
|
||||
};
|
||||
|
||||
var result = await manager.ActivateIncidentAsync(request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Incident);
|
||||
|
||||
var expectedGuid = new Guid("00000000-0000-0000-0000-000000000001");
|
||||
var expectedId = $"inc-{expectedGuid:N}"[..20];
|
||||
Assert.Equal(expectedId, result.Incident!.IncidentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetRecentIncidentsAsync_PrunesResolvedByRetention()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 12, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var options = Options.Create(new ExportIncidentManagerOptions
|
||||
{
|
||||
RetentionPeriod = TimeSpan.FromMinutes(30),
|
||||
MaxIncidentCount = 0
|
||||
});
|
||||
|
||||
var manager = new ExportIncidentManager(
|
||||
NullLogger<ExportIncidentManager>.Instance,
|
||||
new FakeTimelinePublisher(),
|
||||
new FakeNotificationEmitter(),
|
||||
guidProvider,
|
||||
timeProvider,
|
||||
options);
|
||||
|
||||
var activation = await manager.ActivateIncidentAsync(new ExportIncidentActivationRequest
|
||||
{
|
||||
Type = ExportIncidentType.SecurityIncident,
|
||||
Severity = ExportIncidentSeverity.Critical,
|
||||
Summary = "Retention test",
|
||||
ActivatedBy = "tester"
|
||||
});
|
||||
|
||||
var incidentId = activation.Incident!.IncidentId;
|
||||
|
||||
await manager.ResolveIncidentAsync(incidentId, new ExportIncidentResolutionRequest
|
||||
{
|
||||
ResolutionMessage = "Resolved",
|
||||
ResolvedBy = "tester",
|
||||
IsFalsePositive = false
|
||||
});
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromHours(1));
|
||||
|
||||
var recent = await manager.GetRecentIncidentsAsync(limit: 10, includeResolved: true);
|
||||
|
||||
Assert.Empty(recent);
|
||||
}
|
||||
|
||||
private sealed class FakeTimelinePublisher : IExportTimelinePublisher
|
||||
{
|
||||
public Task<TimelinePublishResult> PublishStartedAsync(ExportStartedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("started"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishCompletedAsync(ExportCompletedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("completed"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishFailedAsync(ExportFailedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("failed"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishCancelledAsync(ExportCancelledEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("cancelled"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishArtifactCreatedAsync(ExportArtifactCreatedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("artifact"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishEventAsync(ExportTimelineEventBase @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("event"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishIncidentEventAsync(
|
||||
string eventType,
|
||||
string incidentId,
|
||||
string eventJson,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("incident"));
|
||||
}
|
||||
|
||||
private sealed class FakeNotificationEmitter : IExportNotificationEmitter
|
||||
{
|
||||
public Task EmitIncidentActivatedAsync(ExportIncident incident, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task EmitIncidentUpdatedAsync(ExportIncident incident, string updateMessage, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task EmitIncidentResolvedAsync(ExportIncident incident, string resolutionMessage, bool isFalsePositive, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Tests;
|
||||
using StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
using StellaOps.ExportCenter.WebService.Timeline;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.RiskBundle;
|
||||
|
||||
public sealed class RiskBundleJobHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubmitJobAsync_UsesGuidProvider()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 9, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var handler = new RiskBundleJobHandler(
|
||||
timeProvider,
|
||||
guidProvider,
|
||||
NullLogger<RiskBundleJobHandler>.Instance,
|
||||
new FakeTimelinePublisher());
|
||||
|
||||
var request = new RiskBundleJobSubmitRequest
|
||||
{
|
||||
TenantId = "tenant-1"
|
||||
};
|
||||
|
||||
var result = await handler.SubmitJobAsync(request, "actor");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("00000000000000000000000000000001", result.JobId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitJobAsync_RespectsMaxConcurrentJobs()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 9, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var options = Options.Create(new RiskBundleJobHandlerOptions
|
||||
{
|
||||
MaxConcurrentJobs = 1,
|
||||
JobTimeout = TimeSpan.FromMinutes(5),
|
||||
JobRetentionPeriod = TimeSpan.FromHours(1)
|
||||
});
|
||||
|
||||
var handler = new RiskBundleJobHandler(
|
||||
timeProvider,
|
||||
guidProvider,
|
||||
NullLogger<RiskBundleJobHandler>.Instance,
|
||||
new FakeTimelinePublisher(),
|
||||
options);
|
||||
|
||||
var request = new RiskBundleJobSubmitRequest
|
||||
{
|
||||
TenantId = "tenant-1"
|
||||
};
|
||||
|
||||
var first = await handler.SubmitJobAsync(request, "actor");
|
||||
var second = await handler.SubmitJobAsync(request, "actor");
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.False(second.Success);
|
||||
Assert.Equal("Maximum concurrent jobs reached", second.ErrorMessage);
|
||||
}
|
||||
|
||||
private sealed class FakeTimelinePublisher : IExportTimelinePublisher
|
||||
{
|
||||
public Task<TimelinePublishResult> PublishStartedAsync(ExportStartedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("started"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishCompletedAsync(ExportCompletedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("completed"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishFailedAsync(ExportFailedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("failed"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishCancelledAsync(ExportCancelledEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("cancelled"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishArtifactCreatedAsync(ExportArtifactCreatedEvent @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("artifact"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishEventAsync(ExportTimelineEventBase @event, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("event"));
|
||||
|
||||
public Task<TimelinePublishResult> PublishIncidentEventAsync(
|
||||
string eventType,
|
||||
string incidentId,
|
||||
string eventJson,
|
||||
string? correlationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(TimelinePublishResult.Succeeded("incident"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Tests;
|
||||
using StellaOps.ExportCenter.WebService.SimulationExport;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.SimulationExport;
|
||||
|
||||
public sealed class SimulationReportExporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAsync_UsesGuidProvider()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 13, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var exporter = new SimulationReportExporter(
|
||||
timeProvider,
|
||||
guidProvider,
|
||||
NullLogger<SimulationReportExporter>.Instance);
|
||||
|
||||
var simulationId = (await exporter.GetAvailableSimulationsAsync(null)).Simulations.First().SimulationId;
|
||||
var result = await exporter.ExportAsync(new SimulationExportRequest
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
Format = SimulationExportFormat.Json
|
||||
});
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("exp-00000000000000000000000000000001", result.ExportId);
|
||||
Assert.Equal(timeProvider.GetUtcNow(), result.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_PrunesMaxExports()
|
||||
{
|
||||
var timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 2, 13, 0, 0, TimeSpan.Zero));
|
||||
var guidProvider = new SequentialGuidProvider();
|
||||
var options = Options.Create(new SimulationReportExporterOptions
|
||||
{
|
||||
MaxExports = 1,
|
||||
MaxSimulations = 0,
|
||||
RetentionPeriod = TimeSpan.Zero
|
||||
});
|
||||
|
||||
var exporter = new SimulationReportExporter(
|
||||
timeProvider,
|
||||
guidProvider,
|
||||
NullLogger<SimulationReportExporter>.Instance,
|
||||
options);
|
||||
|
||||
var simulationId = (await exporter.GetAvailableSimulationsAsync(null)).Simulations.First().SimulationId;
|
||||
|
||||
var first = await exporter.ExportAsync(new SimulationExportRequest
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
Format = SimulationExportFormat.Json
|
||||
});
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
|
||||
var second = await exporter.ExportAsync(new SimulationExportRequest
|
||||
{
|
||||
SimulationId = simulationId,
|
||||
Format = SimulationExportFormat.Json
|
||||
});
|
||||
|
||||
await exporter.GetAvailableSimulationsAsync(null);
|
||||
|
||||
var removed = await exporter.GetExportDocumentAsync(first.ExportId);
|
||||
var retained = await exporter.GetExportDocumentAsync(second.ExportId);
|
||||
|
||||
Assert.Null(removed);
|
||||
Assert.NotNull(retained);
|
||||
}
|
||||
}
|
||||
@@ -781,15 +781,9 @@ public static class ExportApiEndpoints
|
||||
Description = profile.Description,
|
||||
Kind = profile.Kind,
|
||||
Status = profile.Status,
|
||||
Scope = profile.ScopeJson is not null
|
||||
? JsonSerializer.Deserialize<ExportScope>(profile.ScopeJson)
|
||||
: null,
|
||||
Format = profile.FormatJson is not null
|
||||
? JsonSerializer.Deserialize<ExportFormatOptions>(profile.FormatJson)
|
||||
: null,
|
||||
Signing = profile.SigningJson is not null
|
||||
? JsonSerializer.Deserialize<ExportSigningOptions>(profile.SigningJson)
|
||||
: null,
|
||||
Scope = TryDeserialize<ExportScope>(profile.ScopeJson),
|
||||
Format = TryDeserialize<ExportFormatOptions>(profile.FormatJson),
|
||||
Signing = TryDeserialize<ExportSigningOptions>(profile.SigningJson),
|
||||
Schedule = profile.Schedule,
|
||||
CreatedAt = profile.CreatedAt,
|
||||
UpdatedAt = profile.UpdatedAt,
|
||||
@@ -866,6 +860,23 @@ public static class ExportApiEndpoints
|
||||
};
|
||||
}
|
||||
|
||||
private static T? TryDeserialize<T>(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Verification endpoint registration
|
||||
// ========================================================================
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Api;
|
||||
|
||||
@@ -10,11 +11,10 @@ public static class ExportApiServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds export API services to the service collection.
|
||||
/// Uses in-memory repositories by default.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExportApiServices(this IServiceCollection services)
|
||||
{
|
||||
return services.AddExportApiServices(_ => { });
|
||||
return services.AddExportApiServices(_ => { }, allowInMemoryRepositories: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -22,7 +22,8 @@ public static class ExportApiServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExportApiServices(
|
||||
this IServiceCollection services,
|
||||
Action<ExportConcurrencyOptions> configureConcurrency)
|
||||
Action<ExportConcurrencyOptions> configureConcurrency,
|
||||
bool allowInMemoryRepositories)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configureConcurrency);
|
||||
@@ -32,6 +33,13 @@ public static class ExportApiServiceCollectionExtensions
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
|
||||
if (!allowInMemoryRepositories)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"In-memory export repositories are disabled. Register persistent repositories or set Export:AllowInMemoryRepositories to true.");
|
||||
}
|
||||
|
||||
// Register repositories (in-memory by default)
|
||||
services.TryAddSingleton<IExportProfileRepository, InMemoryExportProfileRepository>();
|
||||
@@ -64,6 +72,7 @@ public static class ExportApiServiceCollectionExtensions
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
|
||||
// Register custom repositories
|
||||
services.TryAddSingleton<IExportProfileRepository, TProfileRepo>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
@@ -104,13 +105,16 @@ public sealed class ExportAuditService : IExportAuditService
|
||||
{
|
||||
private readonly ILogger<ExportAuditService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public ExportAuditService(
|
||||
ILogger<ExportAuditService> logger,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider;
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public Task LogProfileOperationAsync(
|
||||
@@ -225,7 +229,7 @@ public sealed class ExportAuditService : IExportAuditService
|
||||
|
||||
return new ExportAuditEntry
|
||||
{
|
||||
AuditId = Guid.NewGuid(),
|
||||
AuditId = _guidProvider.NewGuid(),
|
||||
Operation = operation,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
|
||||
@@ -11,10 +11,14 @@ public sealed class InMemoryExportProfileRepository : IExportProfileRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid ProfileId), ExportProfile> _profiles = new();
|
||||
private readonly ILogger<InMemoryExportProfileRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryExportProfileRepository(ILogger<InMemoryExportProfileRepository> logger)
|
||||
public InMemoryExportProfileRepository(
|
||||
ILogger<InMemoryExportProfileRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ExportProfile?> GetByIdAsync(
|
||||
@@ -112,8 +116,8 @@ public sealed class InMemoryExportProfileRepository : IExportProfileRepository
|
||||
var archived = existing with
|
||||
{
|
||||
Status = ExportProfileStatus.Archived,
|
||||
ArchivedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
ArchivedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
if (!_profiles.TryUpdate(key, archived, existing))
|
||||
@@ -165,10 +169,14 @@ public sealed class InMemoryExportRunRepository : IExportRunRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<(Guid TenantId, Guid RunId), ExportRun> _runs = new();
|
||||
private readonly ILogger<InMemoryExportRunRepository> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public InMemoryExportRunRepository(ILogger<InMemoryExportRunRepository> logger)
|
||||
public InMemoryExportRunRepository(
|
||||
ILogger<InMemoryExportRunRepository> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task<ExportRun?> GetByIdAsync(
|
||||
@@ -277,24 +285,11 @@ public sealed class InMemoryExportRunRepository : IExportRunRepository
|
||||
if (existing.Status != ExportRunStatus.Queued && existing.Status != ExportRunStatus.Running)
|
||||
return Task.FromResult(false);
|
||||
|
||||
var cancelled = new ExportRun
|
||||
var cancelled = existing with
|
||||
{
|
||||
RunId = existing.RunId,
|
||||
ProfileId = existing.ProfileId,
|
||||
TenantId = existing.TenantId,
|
||||
Status = ExportRunStatus.Cancelled,
|
||||
Trigger = existing.Trigger,
|
||||
CorrelationId = existing.CorrelationId,
|
||||
InitiatedBy = existing.InitiatedBy,
|
||||
TotalItems = existing.TotalItems,
|
||||
ProcessedItems = existing.ProcessedItems,
|
||||
FailedItems = existing.FailedItems,
|
||||
TotalSizeBytes = existing.TotalSizeBytes,
|
||||
ErrorJson = null,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
StartedAt = existing.StartedAt,
|
||||
CompletedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = existing.ExpiresAt
|
||||
CompletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_runs[key] = cancelled;
|
||||
@@ -339,12 +334,27 @@ public sealed class InMemoryExportRunRepository : IExportRunRepository
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var nextRun = _runs.Values
|
||||
var candidates = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId && r.Status == ExportRunStatus.Queued)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult(nextRun);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var key = (candidate.TenantId, candidate.RunId);
|
||||
var updated = candidate with
|
||||
{
|
||||
Status = ExportRunStatus.Running,
|
||||
StartedAt = candidate.StartedAt ?? _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
if (_runs.TryUpdate(key, updated, candidate))
|
||||
{
|
||||
return Task.FromResult<ExportRun?>(updated);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<ExportRun?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using StellaOps.ExportCenter.Client.Models;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.AuditBundle;
|
||||
|
||||
@@ -17,16 +18,21 @@ public sealed class AuditBundleJobHandler : IAuditBundleJobHandler
|
||||
private readonly ConcurrentDictionary<string, AuditBundleJob> _jobs = new();
|
||||
private readonly ILogger<AuditBundleJobHandler> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public AuditBundleJobHandler(ILogger<AuditBundleJobHandler> logger, TimeProvider? timeProvider = null)
|
||||
public AuditBundleJobHandler(
|
||||
ILogger<AuditBundleJobHandler> logger,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public Task<AuditBundleCreateResult> CreateBundleAsync(
|
||||
@@ -50,7 +56,7 @@ public sealed class AuditBundleJobHandler : IAuditBundleJobHandler
|
||||
new ErrorDetail("INVALID_REQUEST", "Subject name is required")));
|
||||
}
|
||||
|
||||
var bundleId = $"bndl-{Guid.NewGuid():N}";
|
||||
var bundleId = $"bndl-{_guidProvider.NewGuid():N}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var job = new AuditBundleJob
|
||||
@@ -73,7 +79,7 @@ public sealed class AuditBundleJobHandler : IAuditBundleJobHandler
|
||||
|
||||
// In a real implementation, this would enqueue a background job
|
||||
// For now, we'll process it synchronously in-memory
|
||||
_ = Task.Run(async () => await ProcessBundleAsync(bundleId, cancellationToken), cancellationToken);
|
||||
_ = ProcessBundleAsync(bundleId, cancellationToken);
|
||||
|
||||
var response = new CreateAuditBundleResponse(
|
||||
bundleId,
|
||||
@@ -280,7 +286,14 @@ public sealed class AuditBundleJobHandler : IAuditBundleJobHandler
|
||||
"Completed audit bundle {BundleId} with hash {BundleHash}",
|
||||
bundleId, job.BundleHash);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
job.Status = "Cancelled";
|
||||
job.ErrorCode = "CANCELLED";
|
||||
job.ErrorMessage = "Bundle generation cancelled.";
|
||||
job.CompletedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process audit bundle {BundleId}", bundleId);
|
||||
job.Status = "Failed";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.AuditBundle;
|
||||
|
||||
/// <summary>
|
||||
@@ -10,6 +14,8 @@ public static class AuditBundleServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddAuditBundleJobHandler(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
services.AddSingleton<IAuditBundleJobHandler, AuditBundleJobHandler>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ public static class DeprecationHeaderExtensions
|
||||
return async (context, next) =>
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
var timeProvider = httpContext.RequestServices.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
|
||||
// Add deprecation headers
|
||||
httpContext.AddDeprecationHeaders(info);
|
||||
@@ -99,7 +100,7 @@ public static class DeprecationHeaderExtensions
|
||||
httpContext.Connection.RemoteIpAddress);
|
||||
|
||||
// If past sunset, optionally return 410 Gone
|
||||
if (info.IsPastSunset)
|
||||
if (info.IsPastSunsetAt(timeProvider))
|
||||
{
|
||||
logger?.LogError(
|
||||
"Sunset endpoint accessed after removal date: {Method} {Path} - Was removed: {Sunset}",
|
||||
|
||||
@@ -18,10 +18,21 @@ public sealed record DeprecationInfo(
|
||||
/// <summary>
|
||||
/// Returns true if the sunset date has passed.
|
||||
/// </summary>
|
||||
public bool IsPastSunset => DateTimeOffset.UtcNow >= SunsetAt;
|
||||
public bool IsPastSunset => IsPastSunsetAt(TimeProvider.System);
|
||||
|
||||
/// <summary>
|
||||
/// Days remaining until sunset.
|
||||
/// </summary>
|
||||
public int DaysUntilSunset => Math.Max(0, (int)(SunsetAt - DateTimeOffset.UtcNow).TotalDays);
|
||||
public int DaysUntilSunset => DaysUntilSunsetAt(TimeProvider.System);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the sunset date has passed, using the provided time provider.
|
||||
/// </summary>
|
||||
public bool IsPastSunsetAt(TimeProvider timeProvider) => timeProvider.GetUtcNow() >= SunsetAt;
|
||||
|
||||
/// <summary>
|
||||
/// Days remaining until sunset, using the provided time provider.
|
||||
/// </summary>
|
||||
public int DaysUntilSunsetAt(TimeProvider timeProvider) =>
|
||||
Math.Max(0, (int)(SunsetAt - timeProvider.GetUtcNow()).TotalDays);
|
||||
}
|
||||
|
||||
@@ -44,10 +44,14 @@ public sealed record DeprecationClientInfo(
|
||||
public sealed class DeprecationNotificationService : IDeprecationNotificationService
|
||||
{
|
||||
private readonly ILogger<DeprecationNotificationService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DeprecationNotificationService(ILogger<DeprecationNotificationService> logger)
|
||||
public DeprecationNotificationService(
|
||||
ILogger<DeprecationNotificationService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task RecordDeprecatedAccessAsync(
|
||||
@@ -67,7 +71,7 @@ public sealed class DeprecationNotificationService : IDeprecationNotificationSer
|
||||
path,
|
||||
info.DeprecatedAt,
|
||||
info.SunsetAt,
|
||||
info.DaysUntilSunset,
|
||||
info.DaysUntilSunsetAt(_timeProvider),
|
||||
info.SuccessorPath,
|
||||
clientInfo.ClientIp,
|
||||
clientInfo.UserAgent,
|
||||
@@ -81,7 +85,7 @@ public sealed class DeprecationNotificationService : IDeprecationNotificationSer
|
||||
new KeyValuePair<string, object?>("method", method),
|
||||
new KeyValuePair<string, object?>("path", path),
|
||||
new KeyValuePair<string, object?>("successor", info.SuccessorPath),
|
||||
new KeyValuePair<string, object?>("days_until_sunset", info.DaysUntilSunset));
|
||||
new KeyValuePair<string, object?>("days_until_sunset", info.DaysUntilSunsetAt(_timeProvider)));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution;
|
||||
@@ -13,14 +14,17 @@ public sealed class ExportDistributionLifecycle : IExportDistributionLifecycle
|
||||
private readonly IExportDistributionRepository _repository;
|
||||
private readonly ILogger<ExportDistributionLifecycle> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public ExportDistributionLifecycle(
|
||||
IExportDistributionRepository repository,
|
||||
ILogger<ExportDistributionLifecycle> logger,
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
@@ -53,7 +57,7 @@ public sealed class ExportDistributionLifecycle : IExportDistributionLifecycle
|
||||
|
||||
var distribution = new ExportDistribution
|
||||
{
|
||||
DistributionId = Guid.NewGuid(),
|
||||
DistributionId = _guidProvider.NewGuid(),
|
||||
RunId = runId,
|
||||
TenantId = tenantId,
|
||||
Kind = target.Kind,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution;
|
||||
@@ -13,6 +15,8 @@ public static class ExportDistributionServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddExportDistribution(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
services.AddSingleton<IExportDistributionRepository, InMemoryExportDistributionRepository>();
|
||||
services.AddSingleton<IExportDistributionLifecycle, ExportDistributionLifecycle>();
|
||||
|
||||
@@ -25,6 +29,8 @@ public static class ExportDistributionServiceCollectionExtensions
|
||||
public static IServiceCollection AddExportDistribution<TRepository>(this IServiceCollection services)
|
||||
where TRepository : class, IExportDistributionRepository
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
services.AddSingleton<IExportDistributionRepository, TRepository>();
|
||||
services.AddSingleton<IExportDistributionLifecycle, ExportDistributionLifecycle>();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution;
|
||||
@@ -10,6 +11,16 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, ExportDistribution> _distributions = new();
|
||||
private readonly ConcurrentDictionary<string, Guid> _idempotencyIndex = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly InMemoryExportDistributionOptions _options;
|
||||
|
||||
public InMemoryExportDistributionRepository(
|
||||
TimeProvider timeProvider,
|
||||
IOptions<InMemoryExportDistributionOptions>? options = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? InMemoryExportDistributionOptions.Default;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ExportDistribution?> GetByIdAsync(
|
||||
@@ -17,6 +28,8 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
Guid distributionId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneStale(_timeProvider.GetUtcNow());
|
||||
|
||||
_distributions.TryGetValue(distributionId, out var distribution);
|
||||
|
||||
if (distribution is not null && distribution.TenantId != tenantId)
|
||||
@@ -47,6 +60,8 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneStale(_timeProvider.GetUtcNow());
|
||||
|
||||
var distributions = _distributions.Values
|
||||
.Where(d => d.TenantId == tenantId && d.RunId == runId)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
@@ -62,6 +77,8 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneStale(_timeProvider.GetUtcNow());
|
||||
|
||||
var distributions = _distributions.Values
|
||||
.Where(d => d.TenantId == tenantId && d.Status == status)
|
||||
.OrderBy(d => d.CreatedAt)
|
||||
@@ -77,6 +94,8 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneStale(asOf);
|
||||
|
||||
var expired = _distributions.Values
|
||||
.Where(d =>
|
||||
d.RetentionExpiresAt.HasValue &&
|
||||
@@ -94,6 +113,8 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
ExportDistribution distribution,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneStale(_timeProvider.GetUtcNow());
|
||||
|
||||
if (!_distributions.TryAdd(distribution.DistributionId, distribution))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -193,7 +214,7 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
DistributedAt = distribution.DistributedAt,
|
||||
VerifiedAt = distribution.VerifiedAt,
|
||||
UpdatedAt = distribution.UpdatedAt,
|
||||
DeletedAt = DateTimeOffset.UtcNow
|
||||
DeletedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
_distributions[distributionId] = updated;
|
||||
@@ -232,6 +253,8 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneStale(_timeProvider.GetUtcNow());
|
||||
|
||||
var distributions = _distributions.Values
|
||||
.Where(d => d.TenantId == tenantId && d.RunId == runId)
|
||||
.ToList();
|
||||
@@ -259,4 +282,61 @@ public sealed class InMemoryExportDistributionRepository : IExportDistributionRe
|
||||
_distributions.Clear();
|
||||
_idempotencyIndex.Clear();
|
||||
}
|
||||
|
||||
private void PruneStale(DateTimeOffset now)
|
||||
{
|
||||
if (_options.RetentionPeriod > TimeSpan.Zero)
|
||||
{
|
||||
var cutoff = now - _options.RetentionPeriod;
|
||||
foreach (var (distributionId, distribution) in _distributions)
|
||||
{
|
||||
if (distribution.CreatedAt < cutoff)
|
||||
{
|
||||
RemoveDistribution(distributionId, distribution);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.MaxEntries > 0 && _distributions.Count > _options.MaxEntries)
|
||||
{
|
||||
var excess = _distributions.Count - _options.MaxEntries;
|
||||
var toRemove = _distributions
|
||||
.OrderBy(kvp => kvp.Value.CreatedAt)
|
||||
.Take(excess)
|
||||
.ToList();
|
||||
|
||||
foreach (var entry in toRemove)
|
||||
{
|
||||
RemoveDistribution(entry.Key, entry.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveDistribution(Guid distributionId, ExportDistribution distribution)
|
||||
{
|
||||
_distributions.TryRemove(distributionId, out _);
|
||||
|
||||
if (!string.IsNullOrEmpty(distribution.IdempotencyKey))
|
||||
{
|
||||
_idempotencyIndex.TryRemove(distribution.IdempotencyKey, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for in-memory distribution retention.
|
||||
/// </summary>
|
||||
public sealed record InMemoryExportDistributionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of distributions to keep in memory.
|
||||
/// </summary>
|
||||
public int MaxEntries { get; init; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for in-memory distributions.
|
||||
/// </summary>
|
||||
public TimeSpan RetentionPeriod { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
public static InMemoryExportDistributionOptions Default => new();
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@ public sealed class OciDistributionOptions
|
||||
/// </summary>
|
||||
public bool AllowHttpRegistries { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow invalid TLS certificates (testing only).
|
||||
/// </summary>
|
||||
public bool AllowInsecureTls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts for registry operations.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
@@ -28,10 +29,17 @@ public static class OciDistributionServiceExtensions
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-ExportCenter/1.0");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||
{
|
||||
// Allow configurable TLS validation (for testing with self-signed certs)
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
var options = sp.GetRequiredService<IOptions<OciDistributionOptions>>().Value;
|
||||
var handler = new HttpClientHandler();
|
||||
if (options.AllowInsecureTls)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
return handler;
|
||||
});
|
||||
|
||||
// Register the distribution client
|
||||
@@ -58,6 +66,18 @@ public static class OciDistributionServiceExtensions
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "StellaOps-ExportCenter/1.0");
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
})
|
||||
.ConfigurePrimaryHttpMessageHandler(sp =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<OciDistributionOptions>>().Value;
|
||||
var handler = new HttpClientHandler();
|
||||
if (options.AllowInsecureTls)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
|
||||
return handler;
|
||||
});
|
||||
|
||||
// Register the distribution client
|
||||
|
||||
@@ -423,10 +423,12 @@ public sealed class RegistryGlobalSettings
|
||||
public sealed class OciHttpClientFactory
|
||||
{
|
||||
private readonly OciRegistryConfig _config;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public OciHttpClientFactory(OciRegistryConfig config)
|
||||
public OciHttpClientFactory(OciRegistryConfig config, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_config = config ?? throw new ArgumentNullException(nameof(config));
|
||||
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -435,52 +437,13 @@ public sealed class OciHttpClientFactory
|
||||
public HttpClient CreateClient(string registry)
|
||||
{
|
||||
var endpointConfig = _config.GetEndpointConfig(registry);
|
||||
var handler = CreateHandler(endpointConfig);
|
||||
|
||||
var client = new HttpClient(handler)
|
||||
{
|
||||
Timeout = _config.Global.Timeout
|
||||
};
|
||||
|
||||
var client = _httpClientFactory.CreateClient(OciDistributionOptions.HttpClientName);
|
||||
client.Timeout = _config.Global.Timeout;
|
||||
client.BaseAddress = new Uri(endpointConfig.GetRegistryUrl());
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(_config.Global.UserAgent);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an HTTP message handler with TLS configuration.
|
||||
/// </summary>
|
||||
private static HttpClientHandler CreateHandler(RegistryEndpointConfig config)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
// Configure TLS
|
||||
if (config.Tls is not null)
|
||||
{
|
||||
if (config.Tls.SkipVerify)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback =
|
||||
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
|
||||
}
|
||||
else
|
||||
{
|
||||
var callback = config.Tls.GetCertificateValidationCallback();
|
||||
if (callback is not null)
|
||||
{
|
||||
handler.ServerCertificateCustomValidationCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
// Load client certificate for mTLS
|
||||
var clientCert = config.Tls.LoadClientCertificate();
|
||||
if (clientCert is not null)
|
||||
{
|
||||
handler.ClientCertificates.Add(clientCert);
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -516,10 +479,10 @@ public sealed record RegistryCapabilities
|
||||
/// <summary>
|
||||
/// When capabilities were probed.
|
||||
/// </summary>
|
||||
public DateTimeOffset ProbedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset ProbedAt { get; init; } = TimeProvider.System.GetUtcNow();
|
||||
|
||||
/// <summary>
|
||||
/// Whether capabilities are stale and should be re-probed.
|
||||
/// </summary>
|
||||
public bool IsStale(TimeSpan maxAge) => DateTimeOffset.UtcNow - ProbedAt > maxAge;
|
||||
public bool IsStale(TimeSpan maxAge) => TimeProvider.System.GetUtcNow() - ProbedAt > maxAge;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.EvidenceLocker;
|
||||
|
||||
@@ -35,7 +36,13 @@ public static class EvidenceLockerServiceCollectionExtensions
|
||||
var options = serviceProvider.GetService<Microsoft.Extensions.Options.IOptions<ExportEvidenceLockerOptions>>()?.Value
|
||||
?? ExportEvidenceLockerOptions.Default;
|
||||
|
||||
client.BaseAddress = new Uri(options.BaseUrl);
|
||||
if (string.IsNullOrWhiteSpace(options.BaseUrl) ||
|
||||
!Uri.TryCreate(options.BaseUrl, UriKind.Absolute, out var baseUri))
|
||||
{
|
||||
throw new InvalidOperationException("Evidence locker BaseUrl must be a valid absolute URI.");
|
||||
}
|
||||
|
||||
client.BaseAddress = baseUri;
|
||||
client.Timeout = options.Timeout;
|
||||
client.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
@@ -54,6 +61,8 @@ public static class EvidenceLockerServiceCollectionExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
services.TryAddSingleton<IExportMerkleTreeCalculator, ExportMerkleTreeCalculator>();
|
||||
services.TryAddSingleton<IExportEvidenceLockerClient, InMemoryExportEvidenceLockerClient>();
|
||||
|
||||
@@ -69,11 +78,17 @@ public sealed class InMemoryExportEvidenceLockerClient : IExportEvidenceLockerCl
|
||||
private readonly IExportMerkleTreeCalculator _merkleCalculator;
|
||||
private readonly Dictionary<string, ExportBundleManifest> _bundles = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _lock = new();
|
||||
private int _bundleCounter;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
|
||||
public InMemoryExportEvidenceLockerClient(IExportMerkleTreeCalculator merkleCalculator)
|
||||
public InMemoryExportEvidenceLockerClient(
|
||||
IExportMerkleTreeCalculator merkleCalculator,
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider)
|
||||
{
|
||||
_merkleCalculator = merkleCalculator ?? throw new ArgumentNullException(nameof(merkleCalculator));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
}
|
||||
|
||||
public Task<ExportEvidenceSnapshotResult> PushSnapshotAsync(
|
||||
@@ -82,7 +97,7 @@ public sealed class InMemoryExportEvidenceLockerClient : IExportEvidenceLockerCl
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var bundleId = Guid.NewGuid().ToString();
|
||||
var bundleId = _guidProvider.NewGuid().ToString("N");
|
||||
var entries = request.Materials.Select(m => new ExportManifestEntry
|
||||
{
|
||||
Section = m.Section,
|
||||
@@ -91,7 +106,9 @@ public sealed class InMemoryExportEvidenceLockerClient : IExportEvidenceLockerCl
|
||||
SizeBytes = m.SizeBytes,
|
||||
MediaType = m.MediaType ?? "application/octet-stream",
|
||||
Attributes = m.Attributes
|
||||
}).ToList();
|
||||
})
|
||||
.OrderBy(e => e.CanonicalPath, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var rootHash = _merkleCalculator.CalculateRootHash(entries);
|
||||
|
||||
@@ -102,7 +119,7 @@ public sealed class InMemoryExportEvidenceLockerClient : IExportEvidenceLockerCl
|
||||
ProfileId = request.ProfileId,
|
||||
ExportRunId = request.ExportRunId,
|
||||
Kind = request.Kind,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
RootHash = rootHash,
|
||||
Metadata = request.Metadata ?? new Dictionary<string, string>(),
|
||||
Entries = entries,
|
||||
@@ -112,7 +129,6 @@ public sealed class InMemoryExportEvidenceLockerClient : IExportEvidenceLockerCl
|
||||
lock (_lock)
|
||||
{
|
||||
_bundles[bundleId] = manifest;
|
||||
_bundleCounter++;
|
||||
}
|
||||
|
||||
return Task.FromResult(ExportEvidenceSnapshotResult.Succeeded(bundleId, rootHash));
|
||||
@@ -186,7 +202,6 @@ public sealed class InMemoryExportEvidenceLockerClient : IExportEvidenceLockerCl
|
||||
lock (_lock)
|
||||
{
|
||||
_bundles.Clear();
|
||||
_bundleCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.Policy.Exceptions.Models;
|
||||
using StellaOps.Policy.Exceptions.Repositories;
|
||||
|
||||
@@ -20,6 +22,8 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
|
||||
private readonly ConcurrentDictionary<string, ReportJob> _jobs = new();
|
||||
private readonly ILogger<ExceptionReportGenerator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ExceptionReportGeneratorOptions _options;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -32,20 +36,25 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
|
||||
IExceptionRepository exceptionRepository,
|
||||
IExceptionApplicationRepository applicationRepository,
|
||||
ILogger<ExceptionReportGenerator> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider? timeProvider = null,
|
||||
IOptions<ExceptionReportGeneratorOptions>? options = null)
|
||||
{
|
||||
_exceptionRepository = exceptionRepository;
|
||||
_applicationRepository = applicationRepository;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_options = options?.Value ?? ExceptionReportGeneratorOptions.Default;
|
||||
}
|
||||
|
||||
public async Task<ExceptionReportJobResponse> CreateReportAsync(
|
||||
ExceptionReportRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jobId = $"exc-rpt-{Guid.NewGuid():N}";
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
PruneExpiredJobs(now);
|
||||
var jobId = $"exc-rpt-{_guidProvider.NewGuid():N}";
|
||||
|
||||
var job = new ReportJob
|
||||
{
|
||||
@@ -64,7 +73,7 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
|
||||
jobId, request.TenantId);
|
||||
|
||||
// Start generation in background
|
||||
_ = Task.Run(() => GenerateReportAsync(job, cancellationToken), cancellationToken);
|
||||
_ = GenerateReportAsync(job, cancellationToken);
|
||||
|
||||
return new ExceptionReportJobResponse
|
||||
{
|
||||
@@ -253,12 +262,9 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
|
||||
Summary = new ExceptionReportSummary
|
||||
{
|
||||
TotalExceptions = entries.Count,
|
||||
ByStatus = entries.GroupBy(e => e.Exception.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count()),
|
||||
ByType = entries.GroupBy(e => e.Exception.Type)
|
||||
.ToDictionary(g => g.Key, g => g.Count()),
|
||||
ByReason = entries.GroupBy(e => e.Exception.ReasonCode)
|
||||
.ToDictionary(g => g.Key, g => g.Count())
|
||||
ByStatus = BuildSummaryMap(entries, e => e.Exception.Status),
|
||||
ByType = BuildSummaryMap(entries, e => e.Exception.Type),
|
||||
ByReason = BuildSummaryMap(entries, e => e.Exception.ReasonCode)
|
||||
},
|
||||
Exceptions = entries
|
||||
};
|
||||
@@ -353,6 +359,52 @@ public sealed class ExceptionReportGenerator : IExceptionReportGenerator
|
||||
FileSizeBytes = job.FileSizeBytes
|
||||
};
|
||||
|
||||
private void PruneExpiredJobs(DateTimeOffset now)
|
||||
{
|
||||
if (_options.RetentionPeriod > TimeSpan.Zero)
|
||||
{
|
||||
var cutoff = now - _options.RetentionPeriod;
|
||||
foreach (var (jobId, job) in _jobs)
|
||||
{
|
||||
var completedAt = job.CompletedAt ?? job.CreatedAt;
|
||||
if (completedAt < cutoff)
|
||||
{
|
||||
_jobs.TryRemove(jobId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.MaxStoredJobs > 0 && _jobs.Count > _options.MaxStoredJobs)
|
||||
{
|
||||
var excess = _jobs.Count - _options.MaxStoredJobs;
|
||||
var toRemove = _jobs
|
||||
.OrderBy(kvp => kvp.Value.CreatedAt)
|
||||
.Take(excess)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_jobs.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SortedDictionary<string, int> BuildSummaryMap(
|
||||
IEnumerable<ExceptionReportEntry> entries,
|
||||
Func<ExceptionReportEntry, string> keySelector)
|
||||
{
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var key = keySelector(entry);
|
||||
counts.TryGetValue(key, out var existing);
|
||||
counts[key] = existing + 1;
|
||||
}
|
||||
|
||||
return new SortedDictionary<string, int>(counts, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class ReportJob
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
@@ -399,9 +451,9 @@ internal sealed record ExceptionReportFilter
|
||||
internal sealed record ExceptionReportSummary
|
||||
{
|
||||
public int TotalExceptions { get; init; }
|
||||
public Dictionary<string, int> ByStatus { get; init; } = new();
|
||||
public Dictionary<string, int> ByType { get; init; } = new();
|
||||
public Dictionary<string, int> ByReason { get; init; } = new();
|
||||
public SortedDictionary<string, int> ByStatus { get; init; } = new(StringComparer.Ordinal);
|
||||
public SortedDictionary<string, int> ByType { get; init; } = new(StringComparer.Ordinal);
|
||||
public SortedDictionary<string, int> ByReason { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal sealed record ExceptionReportEntry
|
||||
@@ -461,3 +513,21 @@ internal sealed record ExceptionReportApplication
|
||||
public required string EffectName { get; init; }
|
||||
public required DateTimeOffset AppliedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for exception report job retention.
|
||||
/// </summary>
|
||||
public sealed record ExceptionReportGeneratorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of stored jobs.
|
||||
/// </summary>
|
||||
public int MaxStoredJobs { get; init; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for stored jobs.
|
||||
/// </summary>
|
||||
public TimeSpan RetentionPeriod { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
public static ExceptionReportGeneratorOptions Default => new();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Copyright (c) StellaOps Contributors. Licensed under the AGPL-3.0-or-later.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.ExceptionReport;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,6 +19,8 @@ public static class ExceptionReportServiceCollectionExtensions
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddExceptionReportServices(this IServiceCollection services)
|
||||
{
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
services.AddSingleton<IExceptionReportGenerator, ExceptionReportGenerator>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
using StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
@@ -24,6 +26,8 @@ public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
private readonly IExportTimelinePublisher _timelinePublisher;
|
||||
private readonly IExportNotificationEmitter _notificationEmitter;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ExportIncidentManagerOptions _options;
|
||||
|
||||
// In-memory store for incidents (production would use persistent storage)
|
||||
private readonly ConcurrentDictionary<string, ExportIncident> _incidents = new();
|
||||
@@ -32,12 +36,16 @@ public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
ILogger<ExportIncidentManager> logger,
|
||||
IExportTimelinePublisher timelinePublisher,
|
||||
IExportNotificationEmitter notificationEmitter,
|
||||
TimeProvider? timeProvider = null)
|
||||
IGuidProvider guidProvider,
|
||||
TimeProvider? timeProvider = null,
|
||||
IOptions<ExportIncidentManagerOptions>? options = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
|
||||
_notificationEmitter = notificationEmitter ?? throw new ArgumentNullException(nameof(notificationEmitter));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_options = options?.Value ?? ExportIncidentManagerOptions.Default;
|
||||
}
|
||||
|
||||
public async Task<ExportIncidentResult> ActivateIncidentAsync(
|
||||
@@ -45,6 +53,7 @@ public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
PruneExpiredIncidents(_timeProvider.GetUtcNow());
|
||||
|
||||
try
|
||||
{
|
||||
@@ -355,6 +364,8 @@ public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
public Task<ExportIncidentModeStatus> GetIncidentModeStatusAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneExpiredIncidents(_timeProvider.GetUtcNow());
|
||||
|
||||
var activeIncidents = _incidents.Values
|
||||
.Where(i => i.Status is not (ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive))
|
||||
.OrderByDescending(i => i.Severity)
|
||||
@@ -377,6 +388,8 @@ public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
public Task<IReadOnlyList<ExportIncident>> GetActiveIncidentsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneExpiredIncidents(_timeProvider.GetUtcNow());
|
||||
|
||||
var activeIncidents = _incidents.Values
|
||||
.Where(i => i.Status is not (ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive))
|
||||
.OrderByDescending(i => i.Severity)
|
||||
@@ -399,6 +412,8 @@ public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
bool includeResolved = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PruneExpiredIncidents(_timeProvider.GetUtcNow());
|
||||
|
||||
var query = _incidents.Values.AsEnumerable();
|
||||
|
||||
if (!includeResolved)
|
||||
@@ -460,14 +475,45 @@ public sealed class ExportIncidentManager : IExportIncidentManager
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateIncidentId()
|
||||
private void PruneExpiredIncidents(DateTimeOffset now)
|
||||
{
|
||||
return $"inc-{Guid.NewGuid():N}"[..20];
|
||||
if (_options.RetentionPeriod > TimeSpan.Zero)
|
||||
{
|
||||
var cutoff = now - _options.RetentionPeriod;
|
||||
foreach (var (incidentId, incident) in _incidents)
|
||||
{
|
||||
if (incident.Status is ExportIncidentStatus.Resolved or ExportIncidentStatus.FalsePositive &&
|
||||
incident.LastUpdatedAt < cutoff)
|
||||
{
|
||||
_incidents.TryRemove(incidentId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_options.MaxIncidentCount > 0 && _incidents.Count > _options.MaxIncidentCount)
|
||||
{
|
||||
var excess = _incidents.Count - _options.MaxIncidentCount;
|
||||
var toRemove = _incidents
|
||||
.OrderBy(kvp => kvp.Value.LastUpdatedAt)
|
||||
.Take(excess)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
_incidents.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateUpdateId()
|
||||
private string GenerateIncidentId()
|
||||
{
|
||||
return $"upd-{Guid.NewGuid():N}"[..16];
|
||||
return $"inc-{_guidProvider.NewGuid():N}"[..20];
|
||||
}
|
||||
|
||||
private string GenerateUpdateId()
|
||||
{
|
||||
return $"upd-{_guidProvider.NewGuid():N}"[..16];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,3 +579,21 @@ public sealed class LoggingNotificationEmitter : IExportNotificationEmitter
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for incident retention and limits.
|
||||
/// </summary>
|
||||
public sealed record ExportIncidentManagerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of incidents to retain in memory.
|
||||
/// </summary>
|
||||
public int MaxIncidentCount { get; init; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for resolved incidents.
|
||||
/// </summary>
|
||||
public TimeSpan RetentionPeriod { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
public static ExportIncidentManagerOptions Default => new();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Incident;
|
||||
|
||||
@@ -19,6 +20,7 @@ public static class IncidentServiceCollectionExtensions
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
|
||||
// Register notification emitter
|
||||
services.TryAddSingleton<IExportNotificationEmitter, LoggingNotificationEmitter>();
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService;
|
||||
|
||||
@@ -34,9 +36,16 @@ public static class OpenApiDiscoveryEndpoints
|
||||
public static IEndpointRouteBuilder MapOpenApiDiscovery(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("")
|
||||
.AllowAnonymous()
|
||||
.WithTags("discovery");
|
||||
|
||||
var configuration = app.ServiceProvider.GetService<IConfiguration>();
|
||||
var environment = app.ServiceProvider.GetService<IHostEnvironment>();
|
||||
var allowAnonymous = configuration?.GetValue("OpenApi:AllowAnonymous", environment?.IsDevelopment() ?? false) ?? false;
|
||||
if (allowAnonymous)
|
||||
{
|
||||
group.AllowAnonymous();
|
||||
}
|
||||
|
||||
group.MapGet("/.well-known/openapi", (Delegate)GetDiscoveryMetadata)
|
||||
.WithName("GetOpenApiDiscovery")
|
||||
.WithSummary("OpenAPI discovery metadata")
|
||||
|
||||
@@ -86,13 +86,16 @@ builder.Services.AddExceptionReportServices();
|
||||
builder.Services.AddLineageExportServices();
|
||||
|
||||
// Export API services (profiles, runs, artifacts)
|
||||
var allowInMemoryRepositories = builder.Configuration.GetValue(
|
||||
"Export:AllowInMemoryRepositories",
|
||||
builder.Environment.IsDevelopment());
|
||||
builder.Services.AddExportApiServices(options =>
|
||||
{
|
||||
options.MaxConcurrentRunsPerTenant = builder.Configuration.GetValue("Export:MaxConcurrentRunsPerTenant", 4);
|
||||
options.MaxConcurrentRunsPerProfile = builder.Configuration.GetValue("Export:MaxConcurrentRunsPerProfile", 2);
|
||||
options.QueueExcessRuns = builder.Configuration.GetValue("Export:QueueExcessRuns", true);
|
||||
options.MaxQueueSizePerTenant = builder.Configuration.GetValue("Export:MaxQueueSizePerTenant", 10);
|
||||
});
|
||||
}, allowInMemoryRepositories);
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
using StellaOps.ExportCenter.WebService.Timeline;
|
||||
|
||||
@@ -24,6 +27,7 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
private static readonly string[] OptionalProviderIds = ["nvd", "osv", "ghsa", "epss"];
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<RiskBundleJobHandler> _logger;
|
||||
private readonly IExportTimelinePublisher _timelinePublisher;
|
||||
private readonly RiskBundleJobHandlerOptions _options;
|
||||
@@ -33,11 +37,13 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
|
||||
public RiskBundleJobHandler(
|
||||
TimeProvider timeProvider,
|
||||
IGuidProvider guidProvider,
|
||||
ILogger<RiskBundleJobHandler> logger,
|
||||
IExportTimelinePublisher timelinePublisher,
|
||||
IOptions<RiskBundleJobHandlerOptions>? options = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timelinePublisher = timelinePublisher ?? throw new ArgumentNullException(nameof(timelinePublisher));
|
||||
_options = options?.Value ?? RiskBundleJobHandlerOptions.Default;
|
||||
@@ -80,7 +86,8 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var jobId = request.JobId?.ToString("N") ?? Guid.NewGuid().ToString("N");
|
||||
PruneExpiredJobs(now);
|
||||
var jobId = request.JobId?.ToString("N") ?? _guidProvider.NewGuid().ToString("N");
|
||||
|
||||
// Validate provider selection
|
||||
var selectedProviders = ResolveSelectedProviders(request.SelectedProviders);
|
||||
@@ -102,6 +109,20 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
};
|
||||
}
|
||||
|
||||
var activeJobs = _jobs.Values.Count(j => j.Status is RiskBundleJobStatus.Pending or RiskBundleJobStatus.Running);
|
||||
if (_options.MaxConcurrentJobs > 0 && activeJobs >= _options.MaxConcurrentJobs)
|
||||
{
|
||||
return new RiskBundleJobSubmitResult
|
||||
{
|
||||
Success = false,
|
||||
JobId = jobId,
|
||||
Status = RiskBundleJobStatus.Failed,
|
||||
ErrorMessage = "Maximum concurrent jobs reached",
|
||||
SubmittedAt = now,
|
||||
SelectedProviders = selectedProviders
|
||||
};
|
||||
}
|
||||
|
||||
// Create job state
|
||||
var jobState = new RiskBundleJobState
|
||||
{
|
||||
@@ -169,6 +190,8 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PruneExpiredJobs(_timeProvider.GetUtcNow());
|
||||
|
||||
if (!_jobs.TryGetValue(jobId, out var state))
|
||||
{
|
||||
return Task.FromResult<RiskBundleJobStatusDetail?>(null);
|
||||
@@ -184,6 +207,8 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
PruneExpiredJobs(_timeProvider.GetUtcNow());
|
||||
|
||||
var query = _jobs.Values.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
@@ -216,6 +241,7 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
return false;
|
||||
}
|
||||
|
||||
var originalStatus = state.Status;
|
||||
state.Status = RiskBundleJobStatus.Cancelled;
|
||||
state.CompletedAt = _timeProvider.GetUtcNow();
|
||||
state.CancellationSource?.Cancel();
|
||||
@@ -229,7 +255,7 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
state.CorrelationId,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["original_status"] = state.Status.ToString()
|
||||
["original_status"] = originalStatus.ToString()
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -242,6 +268,10 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
{
|
||||
state.CancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var linkedToken = state.CancellationSource.Token;
|
||||
if (_options.JobTimeout > TimeSpan.Zero)
|
||||
{
|
||||
state.CancellationSource.CancelAfter(_options.JobTimeout);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -263,14 +293,21 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
linkedToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Create simulated outcome
|
||||
var bundleId = Guid.NewGuid();
|
||||
var storagePrefix = string.IsNullOrWhiteSpace(state.Request?.StoragePrefix)
|
||||
? _options.DefaultStoragePrefix
|
||||
: state.Request.StoragePrefix!;
|
||||
var bundleFileName = string.IsNullOrWhiteSpace(state.Request?.BundleFileName)
|
||||
? "risk-bundle.tar.gz"
|
||||
: state.Request.BundleFileName!;
|
||||
var bundleId = CreateDeterministicGuid($"bundle:{state.JobId}");
|
||||
var rootHash = $"sha256:{ComputeDeterministicSha256($"root:{state.JobId}")}";
|
||||
state.Outcome = new RiskBundleOutcomeSummary
|
||||
{
|
||||
BundleId = bundleId,
|
||||
RootHash = $"sha256:{Guid.NewGuid():N}",
|
||||
BundleStorageKey = $"risk-bundles/{bundleId:N}/risk-bundle.tar.gz",
|
||||
ManifestStorageKey = $"risk-bundles/{bundleId:N}/provider-manifest.json",
|
||||
ManifestSignatureStorageKey = $"risk-bundles/{bundleId:N}/signatures/provider-manifest.dsse",
|
||||
RootHash = rootHash,
|
||||
BundleStorageKey = $"{storagePrefix}/{bundleId:N}/{bundleFileName}",
|
||||
ManifestStorageKey = $"{storagePrefix}/{bundleId:N}/provider-manifest.json",
|
||||
ManifestSignatureStorageKey = $"{storagePrefix}/{bundleId:N}/signatures/provider-manifest.dsse",
|
||||
ProviderCount = state.SelectedProviders.Count,
|
||||
TotalSizeBytes = state.SelectedProviders.Count * 1024 * 1024 // Simulated
|
||||
};
|
||||
@@ -279,7 +316,7 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
.Select(p => new RiskBundleProviderResult
|
||||
{
|
||||
ProviderId = p,
|
||||
Sha256 = $"sha256:{Guid.NewGuid():N}",
|
||||
Sha256 = $"sha256:{ComputeDeterministicSha256($"provider:{state.JobId}:{p}")}",
|
||||
SizeBytes = 1024 * 1024,
|
||||
Source = $"mirror://{p}/current",
|
||||
SnapshotDate = DateOnly.FromDateTime(_timeProvider.GetUtcNow().DateTime),
|
||||
@@ -299,11 +336,11 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["bundle_id"] = bundleId.ToString("N"),
|
||||
["root_hash"] = state.Outcome.RootHash,
|
||||
["root_hash"] = rootHash,
|
||||
["provider_count"] = state.Outcome.ProviderCount.ToString(),
|
||||
["total_size_bytes"] = state.Outcome.TotalSizeBytes.ToString()
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
linkedToken).ConfigureAwait(false);
|
||||
|
||||
// Record metrics
|
||||
ExportTelemetry.RiskBundleJobsCompleted.Add(1,
|
||||
@@ -322,7 +359,8 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
{
|
||||
if (state.Status != RiskBundleJobStatus.Cancelled)
|
||||
{
|
||||
state.Status = RiskBundleJobStatus.Cancelled;
|
||||
state.Status = RiskBundleJobStatus.Failed;
|
||||
state.ErrorMessage = "Job timed out or was cancelled.";
|
||||
state.CompletedAt = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
@@ -343,7 +381,7 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
["error"] = ex.Message,
|
||||
["error_type"] = ex.GetType().Name
|
||||
},
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
linkedToken).ConfigureAwait(false);
|
||||
|
||||
ExportTelemetry.RiskBundleJobsCompleted.Add(1,
|
||||
new KeyValuePair<string, object?>("tenant_id", state.TenantId ?? "unknown"),
|
||||
@@ -448,6 +486,27 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
return null;
|
||||
}
|
||||
|
||||
private void PruneExpiredJobs(DateTimeOffset now)
|
||||
{
|
||||
if (_options.JobRetentionPeriod <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cutoff = now - _options.JobRetentionPeriod;
|
||||
foreach (var (jobId, job) in _jobs)
|
||||
{
|
||||
if (job.Status is RiskBundleJobStatus.Completed or RiskBundleJobStatus.Failed or RiskBundleJobStatus.Cancelled)
|
||||
{
|
||||
var completedAt = job.CompletedAt ?? job.SubmittedAt;
|
||||
if (completedAt < cutoff)
|
||||
{
|
||||
_jobs.TryRemove(jobId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private RiskBundleAvailableProvider CreateProviderInfo(string providerId, bool mandatory)
|
||||
{
|
||||
var (displayName, description) = providerId switch
|
||||
@@ -489,6 +548,18 @@ public sealed class RiskBundleJobHandler : IRiskBundleJobHandler
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid CreateDeterministicGuid(string input)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return new Guid(hash.AsSpan(0, 16));
|
||||
}
|
||||
|
||||
private static string ComputeDeterministicSha256(string input)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private sealed class RiskBundleJobState
|
||||
{
|
||||
public required string JobId { get; init; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.RiskBundle;
|
||||
|
||||
@@ -22,6 +23,7 @@ public static class RiskBundleServiceCollectionExtensions
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
|
||||
// Configure options if provided
|
||||
if (configure is not null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Determinism;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.SimulationExport;
|
||||
|
||||
@@ -19,6 +20,7 @@ public static class SimulationExportServiceCollectionExtensions
|
||||
|
||||
// Register TimeProvider if not already registered
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
|
||||
|
||||
// Register the exporter
|
||||
services.TryAddSingleton<ISimulationReportExporter, SimulationReportExporter>();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.SimulationExport;
|
||||
@@ -31,7 +34,9 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
};
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidProvider _guidProvider;
|
||||
private readonly ILogger<SimulationReportExporter> _logger;
|
||||
private readonly SimulationReportExporterOptions _options;
|
||||
|
||||
// In-memory stores (would be replaced with persistent storage in production)
|
||||
private readonly ConcurrentDictionary<string, SimulationExportDocument> _exports = new();
|
||||
@@ -39,10 +44,14 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
|
||||
public SimulationReportExporter(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SimulationReportExporter> logger)
|
||||
IGuidProvider guidProvider,
|
||||
ILogger<SimulationReportExporter> logger,
|
||||
IOptions<SimulationReportExporterOptions>? options = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidProvider = guidProvider ?? throw new ArgumentNullException(nameof(guidProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? SimulationReportExporterOptions.Default;
|
||||
|
||||
// Initialize with sample simulations for demonstration
|
||||
InitializeSampleSimulations();
|
||||
@@ -54,6 +63,7 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
PruneExpiredEntries(_timeProvider.GetUtcNow());
|
||||
|
||||
var query = _simulations.Values.AsEnumerable();
|
||||
|
||||
@@ -92,7 +102,8 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var exportId = $"exp-{Guid.NewGuid():N}";
|
||||
PruneExpiredEntries(now);
|
||||
var exportId = $"exp-{_guidProvider.NewGuid():N}";
|
||||
|
||||
if (!_simulations.TryGetValue(request.SimulationId, out var simulation))
|
||||
{
|
||||
@@ -200,13 +211,15 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
PruneExpiredEntries(_timeProvider.GetUtcNow());
|
||||
|
||||
if (!_simulations.TryGetValue(request.SimulationId, out var simulation))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var exportId = $"exp-{Guid.NewGuid():N}";
|
||||
var exportId = $"exp-{_guidProvider.NewGuid():N}";
|
||||
|
||||
// Emit metadata first
|
||||
yield return new SimulationExportLine
|
||||
@@ -447,11 +460,11 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Sample simulation 1
|
||||
var sim1Id = "sim-001-" + Guid.NewGuid().ToString("N")[..8];
|
||||
var sim1Id = $"sim-001-{CreateDeterministicSuffix("sim-001")}";
|
||||
_simulations[sim1Id] = CreateSampleSimulation(sim1Id, "baseline-risk-v1", "1.0.0", now.AddHours(-2), 150);
|
||||
|
||||
// Sample simulation 2
|
||||
var sim2Id = "sim-002-" + Guid.NewGuid().ToString("N")[..8];
|
||||
var sim2Id = $"sim-002-{CreateDeterministicSuffix("sim-002")}";
|
||||
_simulations[sim2Id] = CreateSampleSimulation(sim2Id, "strict-risk-v2", "2.1.0", now.AddHours(-1), 85);
|
||||
}
|
||||
|
||||
@@ -462,7 +475,7 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
DateTimeOffset timestamp,
|
||||
int findingCount)
|
||||
{
|
||||
var random = new Random(simulationId.GetHashCode());
|
||||
var random = new SampleRandom(ComputeStableSeed(simulationId));
|
||||
var findings = new List<ExportedFindingScore>();
|
||||
var severities = new[] { "critical", "high", "medium", "low", "informational" };
|
||||
var actions = new[] { "upgrade", "patch", "monitor", "accept", "investigate" };
|
||||
@@ -518,7 +531,7 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
SimulationId = simulationId,
|
||||
ProfileId = profileId,
|
||||
ProfileVersion = profileVersion,
|
||||
ProfileHash = $"sha256:{Guid.NewGuid():N}",
|
||||
ProfileHash = $"sha256:{ComputeDeterministicHash($"profile:{simulationId}")}",
|
||||
Timestamp = timestamp,
|
||||
TenantId = "default",
|
||||
TotalFindings = findingCount,
|
||||
@@ -532,7 +545,7 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
MediumCount = medium,
|
||||
LowCount = low,
|
||||
InformationalCount = info,
|
||||
DeterminismHash = $"det-{Guid.NewGuid():N}",
|
||||
DeterminismHash = $"det-{ComputeDeterministicHash($"det:{simulationId}")}",
|
||||
FindingScores = findings,
|
||||
TopMovers = findings
|
||||
.OrderByDescending(f => f.NormalizedScore)
|
||||
@@ -628,6 +641,119 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
};
|
||||
}
|
||||
|
||||
private void PruneExpiredEntries(DateTimeOffset now)
|
||||
{
|
||||
if (_options.RetentionPeriod > TimeSpan.Zero)
|
||||
{
|
||||
var cutoff = now - _options.RetentionPeriod;
|
||||
foreach (var (exportId, document) in _exports)
|
||||
{
|
||||
if (document.Metadata.ExportTimestamp < cutoff)
|
||||
{
|
||||
_exports.TryRemove(exportId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (simulationId, simulation) in _simulations)
|
||||
{
|
||||
if (simulation.Timestamp < cutoff)
|
||||
{
|
||||
_simulations.TryRemove(simulationId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TrimToMax(_exports, _options.MaxExports, doc => doc.Metadata.ExportTimestamp);
|
||||
TrimToMax(_simulations, _options.MaxSimulations, sim => sim.Timestamp);
|
||||
}
|
||||
|
||||
private static void TrimToMax<TValue>(
|
||||
ConcurrentDictionary<string, TValue> store,
|
||||
int maxCount,
|
||||
Func<TValue, DateTimeOffset> timestampSelector)
|
||||
{
|
||||
if (maxCount <= 0 || store.Count <= maxCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var excess = store.Count - maxCount;
|
||||
var toRemove = store
|
||||
.OrderBy(kvp => timestampSelector(kvp.Value))
|
||||
.Take(excess)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
store.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ComputeStableSeed(string input)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var seed = (hash[0] << 24) | (hash[1] << 16) | (hash[2] << 8) | hash[3];
|
||||
return seed;
|
||||
}
|
||||
|
||||
private static string CreateDeterministicSuffix(string input)
|
||||
{
|
||||
return ComputeDeterministicHash(input)[..8];
|
||||
}
|
||||
|
||||
private static string ComputeDeterministicHash(string input)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private sealed class SampleRandom
|
||||
{
|
||||
private uint _state;
|
||||
|
||||
public SampleRandom(int seed)
|
||||
{
|
||||
_state = (uint)seed;
|
||||
if (_state == 0)
|
||||
{
|
||||
_state = 1;
|
||||
}
|
||||
}
|
||||
|
||||
public double NextDouble()
|
||||
{
|
||||
return (NextUInt() & 0x00FFFFFF) / (double)0x01000000;
|
||||
}
|
||||
|
||||
public int Next(int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)(NextUInt() % (uint)maxExclusive);
|
||||
}
|
||||
|
||||
public int Next(int minInclusive, int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= minInclusive)
|
||||
{
|
||||
return minInclusive;
|
||||
}
|
||||
|
||||
var range = (uint)(maxExclusive - minInclusive);
|
||||
return (int)(NextUInt() % range) + minInclusive;
|
||||
}
|
||||
|
||||
private uint NextUInt()
|
||||
{
|
||||
_state = 1664525u * _state + 1013904223u;
|
||||
return _state;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SimulatedSimulationResult
|
||||
{
|
||||
public required string SimulationId { get; init; }
|
||||
@@ -657,3 +783,26 @@ public sealed class SimulationReportExporter : ISimulationReportExporter
|
||||
public TrendSection? Trends { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for simulation report exporter retention.
|
||||
/// </summary>
|
||||
public sealed record SimulationReportExporterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of stored exports.
|
||||
/// </summary>
|
||||
public int MaxExports { get; init; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of stored simulations.
|
||||
/// </summary>
|
||||
public int MaxSimulations { get; init; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for in-memory entries.
|
||||
/// </summary>
|
||||
public TimeSpan RetentionPeriod { get; init; } = TimeSpan.FromHours(24);
|
||||
|
||||
public static SimulationReportExporterOptions Default => new();
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0337-M | DONE | Revalidated 2026-01-07; maintainability audit for ExportCenter.WebService. |
|
||||
| AUDIT-0337-T | DONE | Revalidated 2026-01-07; test coverage audit for ExportCenter.WebService. |
|
||||
| AUDIT-0337-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
|
||||
| AUDIT-0337-A | DONE | Applied 2026-01-13; determinism, DI guards, retention/TLS gating, tests added. |
|
||||
| AUDIT-HOTLIST-EXPORTCENTER-WEBSERVICE-0001 | DONE | Applied 2026-01-13; hotlist remediation and tests completed. |
|
||||
|
||||
Reference in New Issue
Block a user