sprints completion. new product advisories prepared
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_EVIDENCE_reindex_tooling
|
||||
// Tasks: REINDEX-003
|
||||
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Core.Reindexing;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence re-indexing service for recomputing bundle roots and verifying continuity.
|
||||
/// </summary>
|
||||
public interface IEvidenceReindexService
|
||||
{
|
||||
/// <summary>
|
||||
/// Recompute Merkle roots for evidence bundles.
|
||||
/// </summary>
|
||||
Task<ReindexResult> ReindexAsync(
|
||||
ReindexOptions options,
|
||||
IProgress<ReindexProgress> progress,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Verify chain-of-custody between old and new roots.
|
||||
/// </summary>
|
||||
Task<ContinuityVerificationResult> VerifyContinuityAsync(
|
||||
TenantId tenantId,
|
||||
string oldRoot,
|
||||
string newRoot,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Generate cross-reference mapping between old and new roots.
|
||||
/// </summary>
|
||||
Task<RootCrossReferenceMap> GenerateCrossReferenceAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Create a rollback checkpoint before a migration.
|
||||
/// </summary>
|
||||
Task<ReindexCheckpoint> CreateCheckpointAsync(
|
||||
TenantId tenantId,
|
||||
string checkpointName,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Rollback to a previous checkpoint.
|
||||
/// </summary>
|
||||
Task<RollbackResult> RollbackToCheckpointAsync(
|
||||
TenantId tenantId,
|
||||
string checkpointId,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// List available rollback checkpoints.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ReindexCheckpoint>> ListCheckpointsAsync(
|
||||
TenantId tenantId,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ReindexOptions
|
||||
{
|
||||
public TenantId TenantId { get; init; }
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
public int BatchSize { get; init; } = 100;
|
||||
public bool DryRun { get; init; }
|
||||
public string? FromVersion { get; init; }
|
||||
public string? ToVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReindexProgress
|
||||
{
|
||||
public required int TotalBundles { get; init; }
|
||||
public required int BundlesProcessed { get; init; }
|
||||
public string? CurrentBundleId { get; init; }
|
||||
public string? Message { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReindexResult
|
||||
{
|
||||
public required int TotalBundles { get; init; }
|
||||
public required int ReindexedBundles { get; init; }
|
||||
public required int FailedBundles { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public sealed record RootCrossReferenceMap
|
||||
{
|
||||
public required string SchemaVersion { get; init; }
|
||||
public required DateTimeOffset GeneratedAt { get; init; }
|
||||
public string? FromVersion { get; init; }
|
||||
public string? ToVersion { get; init; }
|
||||
public required IReadOnlyList<RootCrossReferenceEntry> Entries { get; init; }
|
||||
public required RootCrossReferenceSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RootCrossReferenceEntry
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public required string OldRoot { get; init; }
|
||||
public required string NewRoot { get; init; }
|
||||
public required int EvidenceCount { get; init; }
|
||||
public required bool Verified { get; init; }
|
||||
public required bool DigestsPreserved { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RootCrossReferenceSummary
|
||||
{
|
||||
public required int TotalBundles { get; init; }
|
||||
public required int SuccessfulMigrations { get; init; }
|
||||
public required int FailedMigrations { get; init; }
|
||||
public required int DigestsPreserved { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ContinuityVerificationResult
|
||||
{
|
||||
public required bool OldRootValid { get; init; }
|
||||
public required bool NewRootValid { get; init; }
|
||||
public required bool AllEvidencePreserved { get; init; }
|
||||
public required bool CrossReferenceValid { get; init; }
|
||||
public required bool OldProofsStillValid { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ReindexCheckpoint
|
||||
{
|
||||
public required string CheckpointId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required int BundleCount { get; init; }
|
||||
public required string SchemaVersion { get; init; }
|
||||
public IReadOnlyList<CheckpointBundleSnapshot> Snapshots { get; init; } = Array.Empty<CheckpointBundleSnapshot>();
|
||||
}
|
||||
|
||||
public sealed record CheckpointBundleSnapshot
|
||||
{
|
||||
public required string BundleId { get; init; }
|
||||
public required string RootHash { get; init; }
|
||||
public required DateTimeOffset CapturedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RollbackResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required int BundlesRestored { get; init; }
|
||||
public required int BundlesFailed { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public required DateTimeOffset CompletedAt { get; init; }
|
||||
public IReadOnlyList<string> Errors { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -27,6 +27,14 @@ public interface IEvidenceBundleRepository
|
||||
|
||||
Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken);
|
||||
|
||||
Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken);
|
||||
|
||||
@@ -8,3 +8,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0288-M | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0288-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0288-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REINDEX-003 | DONE | Reindex service contract scaffolding (2026-01-16). |
|
||||
| REINDEX-004 | DONE | Reindex service root recomputation (2026-01-16). |
|
||||
| REINDEX-005 | DONE | Cross-reference mapping (2026-01-16). |
|
||||
| REINDEX-006 | DONE | Continuity verification (2026-01-16). |
|
||||
|
||||
@@ -16,11 +16,13 @@ using StellaOps.EvidenceLocker.Core.Configuration;
|
||||
using StellaOps.EvidenceLocker.Core.Incident;
|
||||
using StellaOps.EvidenceLocker.Core.Notifications;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Core.Reindexing;
|
||||
using StellaOps.EvidenceLocker.Core.Signing;
|
||||
using StellaOps.EvidenceLocker.Core.Storage;
|
||||
using StellaOps.EvidenceLocker.Core.Timeline;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Builders;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Reindexing;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Repositories;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Services;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Signing;
|
||||
@@ -73,6 +75,7 @@ public static class EvidenceLockerInfrastructureServiceCollectionExtensions
|
||||
});
|
||||
services.AddScoped<IEvidenceBundleBuilder, EvidenceBundleBuilder>();
|
||||
services.AddScoped<IEvidenceBundleRepository, EvidenceBundleRepository>();
|
||||
services.AddScoped<IEvidenceReindexService, EvidenceReindexService>();
|
||||
|
||||
// Verdict attestation repository
|
||||
services.AddScoped<StellaOps.EvidenceLocker.Storage.IVerdictRepository>(provider =>
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Reindexing;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Infrastructure.Reindexing;
|
||||
|
||||
public sealed class EvidenceReindexService : IEvidenceReindexService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly IEvidenceBundleRepository _repository;
|
||||
private readonly IMerkleTreeCalculator _merkleTreeCalculator;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public EvidenceReindexService(
|
||||
IEvidenceBundleRepository repository,
|
||||
IMerkleTreeCalculator merkleTreeCalculator,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_merkleTreeCalculator = merkleTreeCalculator ?? throw new ArgumentNullException(nameof(merkleTreeCalculator));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<ReindexResult> ReindexAsync(
|
||||
ReindexOptions options,
|
||||
IProgress<ReindexProgress> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (options.TenantId == default)
|
||||
{
|
||||
throw new ArgumentException("TenantId is required for reindex operations.", nameof(options));
|
||||
}
|
||||
|
||||
if (options.BatchSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(options.BatchSize), "BatchSize must be positive.");
|
||||
}
|
||||
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var errors = new List<string>();
|
||||
var processed = 0;
|
||||
var reindexed = 0;
|
||||
var failed = 0;
|
||||
DateTimeOffset? cursorUpdatedAt = options.Since;
|
||||
EvidenceBundleId? cursorBundleId = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var batch = await _repository.GetBundlesForReindexAsync(
|
||||
options.TenantId,
|
||||
options.Since,
|
||||
cursorUpdatedAt,
|
||||
cursorBundleId,
|
||||
options.BatchSize,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var details in batch)
|
||||
{
|
||||
processed++;
|
||||
try
|
||||
{
|
||||
if (details.Signature is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Missing signature for bundle {details.Bundle.Id.Value:D}.");
|
||||
}
|
||||
|
||||
var manifest = DecodeManifest(details.Signature.Payload);
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
|
||||
var rootHash = _merkleTreeCalculator.CalculateRootHash(
|
||||
entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256.ToLowerInvariant()}"));
|
||||
|
||||
if (!string.Equals(rootHash, details.Bundle.RootHash, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
reindexed++;
|
||||
if (!options.DryRun)
|
||||
{
|
||||
await _repository.SetBundleAssemblyAsync(
|
||||
details.Bundle.Id,
|
||||
details.Bundle.TenantId,
|
||||
details.Bundle.Status,
|
||||
rootHash,
|
||||
_timeProvider.GetUtcNow(),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(new ReindexProgress
|
||||
{
|
||||
TotalBundles = processed,
|
||||
BundlesProcessed = processed,
|
||||
CurrentBundleId = details.Bundle.Id.Value.ToString("D"),
|
||||
Message = options.DryRun ? "assessed" : "reindexed"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
|
||||
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
||||
cursorBundleId = details.Bundle.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
return new ReindexResult
|
||||
{
|
||||
TotalBundles = processed,
|
||||
ReindexedBundles = reindexed,
|
||||
FailedBundles = failed,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
public Task<ContinuityVerificationResult> VerifyContinuityAsync(
|
||||
TenantId tenantId,
|
||||
string oldRoot,
|
||||
string newRoot,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("TenantId is required for continuity verification.", nameof(tenantId));
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(oldRoot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(newRoot);
|
||||
|
||||
return VerifyContinuityInternalAsync(tenantId, oldRoot, newRoot, ct);
|
||||
}
|
||||
|
||||
public Task<RootCrossReferenceMap> GenerateCrossReferenceAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("TenantId is required for cross-reference generation.", nameof(tenantId));
|
||||
}
|
||||
|
||||
return GenerateCrossReferenceInternalAsync(tenantId, since, ct);
|
||||
}
|
||||
|
||||
private static ManifestDocument DecodeManifest(string payload)
|
||||
{
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(payload);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Manifest payload is not valid base64.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ManifestDocument>(bytes, SerializerOptions)
|
||||
?? throw new InvalidOperationException("Manifest payload is empty.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Manifest payload is not valid JSON.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ManifestDocument(
|
||||
Guid BundleId,
|
||||
Guid TenantId,
|
||||
int Kind,
|
||||
DateTimeOffset CreatedAt,
|
||||
IDictionary<string, string>? Metadata,
|
||||
ManifestEntryDocument[]? Entries);
|
||||
|
||||
private sealed record ManifestEntryDocument(
|
||||
string Section,
|
||||
string CanonicalPath,
|
||||
string Sha256,
|
||||
long SizeBytes,
|
||||
string? MediaType,
|
||||
IDictionary<string, string>? Attributes);
|
||||
|
||||
private async Task<RootCrossReferenceMap> GenerateCrossReferenceInternalAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset since,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entries = new List<RootCrossReferenceEntry>();
|
||||
var failed = 0;
|
||||
DateTimeOffset? cursorUpdatedAt = since;
|
||||
EvidenceBundleId? cursorBundleId = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var batch = await _repository.GetBundlesForReindexAsync(
|
||||
tenantId,
|
||||
since,
|
||||
cursorUpdatedAt,
|
||||
cursorBundleId,
|
||||
250,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var details in batch)
|
||||
{
|
||||
var bundleId = details.Bundle.Id.Value.ToString("D");
|
||||
var oldRoot = details.Bundle.RootHash;
|
||||
var evidenceCount = 0;
|
||||
var verified = false;
|
||||
var digestsPreserved = false;
|
||||
var newRoot = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (details.Signature is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Missing signature for bundle {bundleId}.");
|
||||
}
|
||||
|
||||
var manifest = DecodeManifest(details.Signature.Payload);
|
||||
evidenceCount = manifest.Entries?.Length ?? 0;
|
||||
newRoot = ComputeRootHash(manifest);
|
||||
verified = true;
|
||||
digestsPreserved = string.Equals(oldRoot, newRoot, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
|
||||
if (verified)
|
||||
{
|
||||
entries.Add(new RootCrossReferenceEntry
|
||||
{
|
||||
BundleId = bundleId,
|
||||
OldRoot = oldRoot,
|
||||
NewRoot = newRoot,
|
||||
EvidenceCount = evidenceCount,
|
||||
Verified = verified,
|
||||
DigestsPreserved = digestsPreserved
|
||||
});
|
||||
}
|
||||
|
||||
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
||||
cursorBundleId = details.Bundle.Id;
|
||||
}
|
||||
}
|
||||
|
||||
return new RootCrossReferenceMap
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
GeneratedAt = _timeProvider.GetUtcNow(),
|
||||
Entries = entries,
|
||||
Summary = new RootCrossReferenceSummary
|
||||
{
|
||||
TotalBundles = entries.Count + failed,
|
||||
SuccessfulMigrations = entries.Count,
|
||||
FailedMigrations = failed,
|
||||
DigestsPreserved = entries.Count
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ContinuityVerificationResult> VerifyContinuityInternalAsync(
|
||||
TenantId tenantId,
|
||||
string oldRoot,
|
||||
string newRoot,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var foundOldRoot = false;
|
||||
var crossReferenceValid = false;
|
||||
var recomputedMatchesOld = false;
|
||||
DateTimeOffset? cursorUpdatedAt = null;
|
||||
EvidenceBundleId? cursorBundleId = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var batch = await _repository.GetBundlesForReindexAsync(
|
||||
tenantId,
|
||||
null,
|
||||
cursorUpdatedAt,
|
||||
cursorBundleId,
|
||||
250,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var details in batch)
|
||||
{
|
||||
if (!string.Equals(details.Bundle.RootHash, oldRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
||||
cursorBundleId = details.Bundle.Id;
|
||||
continue;
|
||||
}
|
||||
|
||||
foundOldRoot = true;
|
||||
if (details.Signature is not null)
|
||||
{
|
||||
var manifest = DecodeManifest(details.Signature.Payload);
|
||||
var recomputed = ComputeRootHash(manifest);
|
||||
recomputedMatchesOld = string.Equals(recomputed, oldRoot, StringComparison.OrdinalIgnoreCase);
|
||||
if (string.Equals(recomputed, newRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
crossReferenceValid = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
||||
cursorBundleId = details.Bundle.Id;
|
||||
}
|
||||
|
||||
if (crossReferenceValid)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var notes = !foundOldRoot
|
||||
? "Old root not found in evidence bundles."
|
||||
: crossReferenceValid
|
||||
? null
|
||||
: recomputedMatchesOld
|
||||
? "Old root recomputed successfully but does not match the provided new root."
|
||||
: "Old root found but manifest recomputation did not match the stored root.";
|
||||
|
||||
return new ContinuityVerificationResult
|
||||
{
|
||||
OldRootValid = foundOldRoot,
|
||||
NewRootValid = crossReferenceValid,
|
||||
AllEvidencePreserved = crossReferenceValid,
|
||||
CrossReferenceValid = crossReferenceValid,
|
||||
OldProofsStillValid = foundOldRoot && recomputedMatchesOld,
|
||||
Notes = notes
|
||||
};
|
||||
}
|
||||
|
||||
private string ComputeRootHash(ManifestDocument manifest)
|
||||
{
|
||||
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
|
||||
return _merkleTreeCalculator.CalculateRootHash(
|
||||
entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256.ToLowerInvariant()}")
|
||||
);
|
||||
}
|
||||
|
||||
// In-memory checkpoint storage (production would use persistent storage)
|
||||
private readonly Dictionary<string, ReindexCheckpoint> _checkpoints = new();
|
||||
|
||||
public async Task<ReindexCheckpoint> CreateCheckpointAsync(
|
||||
TenantId tenantId,
|
||||
string checkpointName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("TenantId is required for checkpoint creation.", nameof(tenantId));
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointName);
|
||||
|
||||
var snapshots = new List<CheckpointBundleSnapshot>();
|
||||
DateTimeOffset? cursorUpdatedAt = null;
|
||||
EvidenceBundleId? cursorBundleId = null;
|
||||
|
||||
// Capture current state of all bundles
|
||||
while (true)
|
||||
{
|
||||
var batch = await _repository.GetBundlesForReindexAsync(
|
||||
tenantId,
|
||||
null,
|
||||
cursorUpdatedAt,
|
||||
cursorBundleId,
|
||||
250,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var details in batch)
|
||||
{
|
||||
snapshots.Add(new CheckpointBundleSnapshot
|
||||
{
|
||||
BundleId = details.Bundle.Id.Value.ToString("D"),
|
||||
RootHash = details.Bundle.RootHash,
|
||||
CapturedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
cursorUpdatedAt = details.Bundle.UpdatedAt;
|
||||
cursorBundleId = details.Bundle.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var checkpointId = $"ckpt-{Guid.NewGuid():N}";
|
||||
var checkpoint = new ReindexCheckpoint
|
||||
{
|
||||
CheckpointId = checkpointId,
|
||||
Name = checkpointName,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
BundleCount = snapshots.Count,
|
||||
SchemaVersion = "1.0.0",
|
||||
Snapshots = snapshots
|
||||
};
|
||||
|
||||
_checkpoints[checkpointId] = checkpoint;
|
||||
return checkpoint;
|
||||
}
|
||||
|
||||
public async Task<RollbackResult> RollbackToCheckpointAsync(
|
||||
TenantId tenantId,
|
||||
string checkpointId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("TenantId is required for rollback.", nameof(tenantId));
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointId);
|
||||
|
||||
if (!_checkpoints.TryGetValue(checkpointId, out var checkpoint))
|
||||
{
|
||||
throw new InvalidOperationException($"Checkpoint '{checkpointId}' not found.");
|
||||
}
|
||||
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var restored = 0;
|
||||
var failed = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var snapshot in checkpoint.Snapshots)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.Parse(snapshot.BundleId));
|
||||
await _repository.SetBundleAssemblyAsync(
|
||||
bundleId,
|
||||
tenantId,
|
||||
EvidenceBundleStatus.Sealed,
|
||||
snapshot.RootHash,
|
||||
_timeProvider.GetUtcNow(),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
restored++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
errors.Add($"Failed to restore bundle {snapshot.BundleId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new RollbackResult
|
||||
{
|
||||
Success = failed == 0,
|
||||
BundlesRestored = restored,
|
||||
BundlesFailed = failed,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Errors = errors
|
||||
};
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReindexCheckpoint>> ListCheckpointsAsync(
|
||||
TenantId tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (tenantId == default)
|
||||
{
|
||||
throw new ArgumentException("TenantId is required for listing checkpoints.", nameof(tenantId));
|
||||
}
|
||||
|
||||
// Return checkpoints ordered by creation time (newest first)
|
||||
var checkpoints = _checkpoints.Values
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReindexCheckpoint>>(checkpoints);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Npgsql;
|
||||
@@ -71,6 +72,24 @@ internal sealed class EvidenceBundleRepository(EvidenceLockerDataSource dataSour
|
||||
WHERE bundle_id = @bundle_id AND tenant_id = @tenant_id;
|
||||
""";
|
||||
|
||||
private const string SelectBundlesForReindexSql = """
|
||||
SELECT b.bundle_id, b.tenant_id, b.kind, b.status, b.root_hash, b.storage_key, b.description, b.sealed_at, b.created_at, b.updated_at, b.expires_at,
|
||||
b.portable_storage_key, b.portable_generated_at,
|
||||
s.payload_type, s.payload, s.signature, s.key_id, s.algorithm, s.provider, s.signed_at, s.timestamped_at, s.timestamp_authority, s.timestamp_token
|
||||
FROM evidence_locker.evidence_bundles b
|
||||
LEFT JOIN evidence_locker.evidence_bundle_signatures s
|
||||
ON s.bundle_id = b.bundle_id AND s.tenant_id = b.tenant_id
|
||||
WHERE b.tenant_id = @tenant_id
|
||||
AND b.status = @status
|
||||
AND (@since IS NULL OR b.updated_at >= @since)
|
||||
AND (
|
||||
@cursor_updated_at IS NULL OR
|
||||
(b.updated_at, b.bundle_id) > (@cursor_updated_at, @cursor_bundle_id)
|
||||
)
|
||||
ORDER BY b.updated_at, b.bundle_id
|
||||
LIMIT @limit;
|
||||
""";
|
||||
|
||||
private const string InsertHoldSql = """
|
||||
INSERT INTO evidence_locker.evidence_holds
|
||||
(hold_id, tenant_id, bundle_id, case_id, reason, notes, created_at, expires_at)
|
||||
@@ -203,6 +222,40 @@ internal sealed class EvidenceBundleRepository(EvidenceLockerDataSource dataSour
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapBundleDetails(reader);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(tenantId, cancellationToken);
|
||||
await using var command = new NpgsqlCommand(SelectBundlesForReindexSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId.Value);
|
||||
command.Parameters.AddWithValue("status", (int)EvidenceBundleStatus.Sealed);
|
||||
command.Parameters.AddWithValue("since", (object?)since?.UtcDateTime ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("cursor_updated_at", (object?)cursorUpdatedAt?.UtcDateTime ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("cursor_bundle_id", (object?)cursorBundleId?.Value ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("limit", limit);
|
||||
|
||||
var results = new List<EvidenceBundleDetails>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
results.Add(MapBundleDetails(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static EvidenceBundleDetails MapBundleDetails(NpgsqlDataReader reader)
|
||||
{
|
||||
var bundleId = EvidenceBundleId.FromGuid(reader.GetGuid(0));
|
||||
var tenantId = TenantId.FromGuid(reader.GetGuid(1));
|
||||
var createdAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(8), DateTimeKind.Utc));
|
||||
var updatedAt = new DateTimeOffset(DateTime.SpecifyKind(reader.GetDateTime(9), DateTimeKind.Utc));
|
||||
|
||||
@@ -243,8 +296,8 @@ internal sealed class EvidenceBundleRepository(EvidenceLockerDataSource dataSour
|
||||
}
|
||||
|
||||
signature = new EvidenceBundleSignature(
|
||||
EvidenceBundleId.FromGuid(reader.GetGuid(0)),
|
||||
TenantId.FromGuid(reader.GetGuid(1)),
|
||||
bundleId,
|
||||
tenantId,
|
||||
reader.GetString(13),
|
||||
reader.GetString(14),
|
||||
reader.GetString(15),
|
||||
|
||||
@@ -113,6 +113,15 @@ public sealed class EvidenceBundleBuilderTests
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(null);
|
||||
|
||||
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(Array.Empty<EvidenceBundleDetails>());
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
|
||||
@@ -397,6 +397,15 @@ public sealed class EvidenceBundlePackagingServiceTests
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
|
||||
|
||||
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(Array.Empty<EvidenceBundleDetails>());
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
|
||||
@@ -276,6 +276,29 @@ internal sealed class TestEvidenceBundleRepository : IEvidenceBundleRepository
|
||||
return Task.FromResult<EvidenceBundleDetails?>(bundle is null ? null : new EvidenceBundleDetails(bundle, signature));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = _bundles.Values
|
||||
.Where(bundle => bundle.TenantId == tenantId)
|
||||
.OrderBy(bundle => bundle.UpdatedAt)
|
||||
.ThenBy(bundle => bundle.Id.Value)
|
||||
.Take(limit)
|
||||
.Select(bundle =>
|
||||
{
|
||||
var signature = _signatures.FirstOrDefault(sig => sig.BundleId == bundle.Id && sig.TenantId == tenantId);
|
||||
return new EvidenceBundleDetails(bundle, signature);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(results);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_bundles.ContainsKey((bundleId.Value, tenantId.Value)));
|
||||
|
||||
|
||||
@@ -296,6 +296,15 @@ public sealed class EvidencePortableBundleServiceTests
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
|
||||
|
||||
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(Array.Empty<EvidenceBundleDetails>());
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_EVIDENCE_reindex_tooling
|
||||
// Tasks: REINDEX-013
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Reindexing;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Reindexing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for evidence re-indexing operations.
|
||||
/// Tests the full flow of reindex, cross-reference, and continuity verification.
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class EvidenceReindexIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly EvidenceLockerWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
private bool _disposed;
|
||||
|
||||
public EvidenceReindexIntegrationTests()
|
||||
{
|
||||
_factory = new EvidenceLockerWebApplicationFactory();
|
||||
_client = _factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexFlow_CreateBundle_ThenReindex_PreservesChainOfCustody()
|
||||
{
|
||||
// Arrange - Create an evidence bundle first
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
var tenantGuid = Guid.Parse(tenantId);
|
||||
ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var configContent = "{\"test\": \"reindex-integration\"}";
|
||||
var sha256Hash = ComputeSha256(configContent);
|
||||
|
||||
var snapshotPayload = new
|
||||
{
|
||||
kind = 1,
|
||||
metadata = new Dictionary<string, string>
|
||||
{
|
||||
["run"] = "reindex-test",
|
||||
["correlationId"] = Guid.NewGuid().ToString("D")
|
||||
},
|
||||
materials = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "inputs",
|
||||
path = "config.json",
|
||||
sha256 = sha256Hash,
|
||||
sizeBytes = (long)Encoding.UTF8.GetByteCount(configContent),
|
||||
mediaType = "application/json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act - Store evidence
|
||||
var storeResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
snapshotPayload,
|
||||
CancellationToken.None);
|
||||
storeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var storeResult = await storeResponse.Content.ReadFromJsonAsync<JsonElement>(CancellationToken.None);
|
||||
var bundleId = storeResult.GetProperty("bundleId").GetString();
|
||||
var originalRootHash = storeResult.GetProperty("rootHash").GetString();
|
||||
|
||||
bundleId.Should().NotBeNullOrEmpty();
|
||||
originalRootHash.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Verify using the reindex service directly
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var reindexService = scope.ServiceProvider.GetService<IEvidenceReindexService>();
|
||||
|
||||
// Skip if service not registered (minimal test setup)
|
||||
if (reindexService == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = TenantId.FromGuid(tenantGuid),
|
||||
BatchSize = 100,
|
||||
DryRun = true
|
||||
};
|
||||
|
||||
var progressReports = new List<ReindexProgress>();
|
||||
var progress = new Progress<ReindexProgress>(p => progressReports.Add(p));
|
||||
|
||||
// Act - Run reindex in dry-run mode
|
||||
var result = await reindexService.ReindexAsync(options, progress, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.TotalBundles.Should().BeGreaterThanOrEqualTo(1);
|
||||
result.FailedBundles.Should().Be(0);
|
||||
result.StartedAt.Should().BeBefore(result.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CrossReferenceGeneration_AfterBundleCreation_ContainsEntry()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
var tenantGuid = Guid.Parse(tenantId);
|
||||
ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var configContent = "{\"test\": \"crossref-integration\"}";
|
||||
var sha256Hash = ComputeSha256(configContent);
|
||||
|
||||
var snapshotPayload = new
|
||||
{
|
||||
kind = 1,
|
||||
metadata = new Dictionary<string, string> { ["test"] = "crossref" },
|
||||
materials = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "outputs",
|
||||
path = "result.json",
|
||||
sha256 = sha256Hash,
|
||||
sizeBytes = (long)Encoding.UTF8.GetByteCount(configContent),
|
||||
mediaType = "application/json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create bundle
|
||||
var storeResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
snapshotPayload,
|
||||
CancellationToken.None);
|
||||
storeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
// Get reindex service
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var reindexService = scope.ServiceProvider.GetService<IEvidenceReindexService>();
|
||||
|
||||
if (reindexService == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act - Generate cross-reference
|
||||
var crossRef = await reindexService.GenerateCrossReferenceAsync(
|
||||
TenantId.FromGuid(tenantGuid),
|
||||
DateTimeOffset.MinValue,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
crossRef.SchemaVersion.Should().Be("1.0.0");
|
||||
crossRef.Summary.TotalBundles.Should().BeGreaterThanOrEqualTo(1);
|
||||
crossRef.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckpointAndRollback_PreservesEvidenceIntegrity()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
var tenantGuid = Guid.Parse(tenantId);
|
||||
ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var configContent = "{\"test\": \"checkpoint-integration\"}";
|
||||
var sha256Hash = ComputeSha256(configContent);
|
||||
|
||||
var snapshotPayload = new
|
||||
{
|
||||
kind = 1,
|
||||
metadata = new Dictionary<string, string> { ["test"] = "checkpoint" },
|
||||
materials = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "inputs",
|
||||
path = "data.json",
|
||||
sha256 = sha256Hash,
|
||||
sizeBytes = (long)Encoding.UTF8.GetByteCount(configContent),
|
||||
mediaType = "application/json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create bundle
|
||||
var storeResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
snapshotPayload,
|
||||
CancellationToken.None);
|
||||
storeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
// Get reindex service
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var reindexService = scope.ServiceProvider.GetService<IEvidenceReindexService>();
|
||||
|
||||
if (reindexService == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var tid = TenantId.FromGuid(tenantGuid);
|
||||
|
||||
// Act - Create checkpoint
|
||||
var checkpoint = await reindexService.CreateCheckpointAsync(tid, "pre-migration-test", CancellationToken.None);
|
||||
|
||||
// Assert checkpoint created
|
||||
checkpoint.CheckpointId.Should().StartWith("ckpt-");
|
||||
checkpoint.Name.Should().Be("pre-migration-test");
|
||||
checkpoint.BundleCount.Should().BeGreaterThanOrEqualTo(1);
|
||||
|
||||
// Act - List checkpoints
|
||||
var checkpoints = await reindexService.ListCheckpointsAsync(tid, CancellationToken.None);
|
||||
checkpoints.Should().Contain(c => c.CheckpointId == checkpoint.CheckpointId);
|
||||
|
||||
// Act - Rollback
|
||||
var rollbackResult = await reindexService.RollbackToCheckpointAsync(
|
||||
tid,
|
||||
checkpoint.CheckpointId,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert rollback succeeded
|
||||
rollbackResult.Success.Should().BeTrue();
|
||||
rollbackResult.BundlesFailed.Should().Be(0);
|
||||
rollbackResult.BundlesRestored.Should().Be(checkpoint.BundleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ContinuityVerification_WithValidRoots_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
var tenantGuid = Guid.Parse(tenantId);
|
||||
ConfigureAuthHeaders(_client, tenantId, $"{StellaOpsScopes.EvidenceCreate} {StellaOpsScopes.EvidenceRead}");
|
||||
|
||||
var configContent = "{\"test\": \"continuity-integration\"}";
|
||||
var sha256Hash = ComputeSha256(configContent);
|
||||
|
||||
var snapshotPayload = new
|
||||
{
|
||||
kind = 1,
|
||||
metadata = new Dictionary<string, string> { ["test"] = "continuity" },
|
||||
materials = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
section = "inputs",
|
||||
path = "verify.json",
|
||||
sha256 = sha256Hash,
|
||||
sizeBytes = (long)Encoding.UTF8.GetByteCount(configContent),
|
||||
mediaType = "application/json"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create bundle
|
||||
var storeResponse = await _client.PostAsJsonAsync(
|
||||
"/evidence/snapshot",
|
||||
snapshotPayload,
|
||||
CancellationToken.None);
|
||||
storeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var storeResult = await storeResponse.Content.ReadFromJsonAsync<JsonElement>(CancellationToken.None);
|
||||
var rootHash = storeResult.GetProperty("rootHash").GetString();
|
||||
|
||||
// Get reindex service
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var reindexService = scope.ServiceProvider.GetService<IEvidenceReindexService>();
|
||||
|
||||
if (reindexService == null || string.IsNullOrEmpty(rootHash))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Act - Verify continuity (same root = no migration happened)
|
||||
var result = await reindexService.VerifyContinuityAsync(
|
||||
TenantId.FromGuid(tenantGuid),
|
||||
rootHash,
|
||||
rootHash,
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.OldRootValid.Should().BeTrue();
|
||||
result.OldProofsStillValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
private static void ConfigureAuthHeaders(HttpClient client, string tenantId, string scopes)
|
||||
{
|
||||
client.DefaultRequestHeaders.Clear();
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-Auth-Subject", "test-user@example.com");
|
||||
client.DefaultRequestHeaders.Add("X-Auth-Scopes", scopes);
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string input)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_client.Dispose();
|
||||
_factory.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// Copyright © StellaOps. All rights reserved.
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Sprint: SPRINT_20260112_018_EVIDENCE_reindex_tooling
|
||||
// Tasks: REINDEX-012
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.EvidenceLocker.Core.Builders;
|
||||
using StellaOps.EvidenceLocker.Core.Domain;
|
||||
using StellaOps.EvidenceLocker.Core.Reindexing;
|
||||
using StellaOps.EvidenceLocker.Core.Repositories;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Reindexing;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.EvidenceLocker.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class EvidenceReindexServiceTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FakeMerkleTreeCalculator _merkleCalculator;
|
||||
private readonly FakeReindexRepository _repository;
|
||||
private readonly EvidenceReindexService _service;
|
||||
|
||||
public EvidenceReindexServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 16, 12, 0, 0, TimeSpan.Zero));
|
||||
_merkleCalculator = new FakeMerkleTreeCalculator();
|
||||
_repository = new FakeReindexRepository();
|
||||
_service = new EvidenceReindexService(_repository, _merkleCalculator, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexAsync_WithEmptyRepository_ReturnsZeroCounts()
|
||||
{
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = TenantId.FromGuid(Guid.NewGuid()),
|
||||
BatchSize = 100,
|
||||
DryRun = false
|
||||
};
|
||||
|
||||
var result = await _service.ReindexAsync(options, null!, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.TotalBundles);
|
||||
Assert.Equal(0, result.ReindexedBundles);
|
||||
Assert.Equal(0, result.FailedBundles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexAsync_WithMatchingRootHash_DoesNotUpdate()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var rootHash = "sha256:abc123";
|
||||
_merkleCalculator.NextHash = rootHash;
|
||||
|
||||
var bundle = CreateBundle(bundleId, tenantId, rootHash);
|
||||
_repository.AddBundle(bundle);
|
||||
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BatchSize = 100,
|
||||
DryRun = false
|
||||
};
|
||||
|
||||
var result = await _service.ReindexAsync(options, null!, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.TotalBundles);
|
||||
Assert.Equal(0, result.ReindexedBundles);
|
||||
Assert.Equal(0, _repository.UpdateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexAsync_WithDifferentRootHash_UpdatesBundle()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var oldRoot = "sha256:oldroot";
|
||||
var newRoot = "sha256:newroot";
|
||||
_merkleCalculator.NextHash = newRoot;
|
||||
|
||||
var bundle = CreateBundle(bundleId, tenantId, oldRoot);
|
||||
_repository.AddBundle(bundle);
|
||||
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BatchSize = 100,
|
||||
DryRun = false
|
||||
};
|
||||
|
||||
var result = await _service.ReindexAsync(options, null!, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.TotalBundles);
|
||||
Assert.Equal(1, result.ReindexedBundles);
|
||||
Assert.Equal(1, _repository.UpdateCount);
|
||||
Assert.Equal(newRoot, _repository.LastUpdatedRootHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexAsync_DryRunMode_DoesNotUpdate()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var oldRoot = "sha256:oldroot";
|
||||
var newRoot = "sha256:newroot";
|
||||
_merkleCalculator.NextHash = newRoot;
|
||||
|
||||
var bundle = CreateBundle(bundleId, tenantId, oldRoot);
|
||||
_repository.AddBundle(bundle);
|
||||
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BatchSize = 100,
|
||||
DryRun = true
|
||||
};
|
||||
|
||||
var result = await _service.ReindexAsync(options, null!, CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.TotalBundles);
|
||||
Assert.Equal(1, result.ReindexedBundles);
|
||||
Assert.Equal(0, _repository.UpdateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexAsync_ReportsProgress()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
_merkleCalculator.NextHash = "sha256:hash";
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
_repository.AddBundle(CreateBundle(bundleId, tenantId, "sha256:hash"));
|
||||
}
|
||||
|
||||
var progressReports = new List<ReindexProgress>();
|
||||
var progress = new Progress<ReindexProgress>(p => progressReports.Add(p));
|
||||
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BatchSize = 100,
|
||||
DryRun = false
|
||||
};
|
||||
|
||||
await _service.ReindexAsync(options, progress, CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, progressReports.Count);
|
||||
Assert.Equal(3, progressReports.Last().BundlesProcessed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexAsync_RequiresTenantId()
|
||||
{
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = default,
|
||||
BatchSize = 100
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _service.ReindexAsync(options, null!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReindexAsync_RequiresPositiveBatchSize()
|
||||
{
|
||||
var options = new ReindexOptions
|
||||
{
|
||||
TenantId = TenantId.FromGuid(Guid.NewGuid()),
|
||||
BatchSize = 0
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
|
||||
() => _service.ReindexAsync(options, null!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyContinuityAsync_WithMatchingRoot_ReturnsValid()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var rootHash = "sha256:abc123";
|
||||
_merkleCalculator.NextHash = rootHash;
|
||||
|
||||
var bundle = CreateBundle(bundleId, tenantId, rootHash);
|
||||
_repository.AddBundle(bundle);
|
||||
|
||||
var result = await _service.VerifyContinuityAsync(tenantId, rootHash, rootHash, CancellationToken.None);
|
||||
|
||||
Assert.True(result.OldRootValid);
|
||||
Assert.True(result.OldProofsStillValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyContinuityAsync_RequiresTenantId()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _service.VerifyContinuityAsync(default, "old", "new", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateCrossReferenceAsync_ReturnsMapWithEntries()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var rootHash = "sha256:abc123";
|
||||
_merkleCalculator.NextHash = rootHash;
|
||||
|
||||
var bundle = CreateBundle(bundleId, tenantId, rootHash);
|
||||
_repository.AddBundle(bundle);
|
||||
|
||||
var result = await _service.GenerateCrossReferenceAsync(
|
||||
tenantId,
|
||||
DateTimeOffset.MinValue,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("1.0.0", result.SchemaVersion);
|
||||
Assert.Single(result.Entries);
|
||||
Assert.Equal(1, result.Summary.TotalBundles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_CapturesCurrentState()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
_merkleCalculator.NextHash = "sha256:hash";
|
||||
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
_repository.AddBundle(CreateBundle(bundleId, tenantId, $"sha256:root{i}"));
|
||||
}
|
||||
|
||||
var checkpoint = await _service.CreateCheckpointAsync(tenantId, "pre-migration", CancellationToken.None);
|
||||
|
||||
Assert.StartsWith("ckpt-", checkpoint.CheckpointId);
|
||||
Assert.Equal("pre-migration", checkpoint.Name);
|
||||
Assert.Equal(2, checkpoint.BundleCount);
|
||||
Assert.Equal(2, checkpoint.Snapshots.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCheckpointAsync_RequiresTenantId()
|
||||
{
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => _service.CreateCheckpointAsync(default, "test", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RollbackToCheckpointAsync_RestoresState()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid());
|
||||
var originalRoot = "sha256:original";
|
||||
_merkleCalculator.NextHash = originalRoot;
|
||||
|
||||
var bundle = CreateBundle(bundleId, tenantId, originalRoot);
|
||||
_repository.AddBundle(bundle);
|
||||
|
||||
// Create checkpoint
|
||||
var checkpoint = await _service.CreateCheckpointAsync(tenantId, "backup", CancellationToken.None);
|
||||
|
||||
// Simulate modification
|
||||
_repository.UpdateCount = 0;
|
||||
|
||||
// Rollback
|
||||
var result = await _service.RollbackToCheckpointAsync(tenantId, checkpoint.CheckpointId, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(1, result.BundlesRestored);
|
||||
Assert.Equal(0, result.BundlesFailed);
|
||||
Assert.Equal(1, _repository.UpdateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RollbackToCheckpointAsync_ThrowsForUnknownCheckpoint()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => _service.RollbackToCheckpointAsync(tenantId, "unknown-checkpoint", CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListCheckpointsAsync_ReturnsOrderedByCreationTime()
|
||||
{
|
||||
var tenantId = TenantId.FromGuid(Guid.NewGuid());
|
||||
|
||||
await _service.CreateCheckpointAsync(tenantId, "first", CancellationToken.None);
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await _service.CreateCheckpointAsync(tenantId, "second", CancellationToken.None);
|
||||
|
||||
var checkpoints = await _service.ListCheckpointsAsync(tenantId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, checkpoints.Count);
|
||||
Assert.Equal("second", checkpoints[0].Name);
|
||||
Assert.Equal("first", checkpoints[1].Name);
|
||||
}
|
||||
|
||||
private EvidenceBundleDetails CreateBundle(EvidenceBundleId bundleId, TenantId tenantId, string rootHash)
|
||||
{
|
||||
var bundle = new EvidenceBundle
|
||||
{
|
||||
Id = bundleId,
|
||||
TenantId = tenantId,
|
||||
Kind = EvidenceBundleKind.Evaluation,
|
||||
Status = EvidenceBundleStatus.Sealed,
|
||||
RootHash = rootHash,
|
||||
StorageKey = $"bundles/{bundleId.Value:D}",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
UpdatedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
BundleId = bundleId.Value,
|
||||
TenantId = tenantId.Value,
|
||||
Kind = (int)EvidenceBundleKind.Evaluation,
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
Metadata = new Dictionary<string, string>(),
|
||||
Entries = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Section = "inputs",
|
||||
CanonicalPath = "inputs/config.json",
|
||||
Sha256 = "abc123",
|
||||
SizeBytes = 100L,
|
||||
MediaType = "application/json",
|
||||
Attributes = (Dictionary<string, string>?)null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var payload = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes(JsonSerializer.Serialize(manifest)));
|
||||
|
||||
var signature = new EvidenceBundleSignature
|
||||
{
|
||||
BundleId = bundleId,
|
||||
KeyId = "test-key",
|
||||
Algorithm = "ES256",
|
||||
Payload = payload,
|
||||
Signature = "sig"
|
||||
};
|
||||
|
||||
return new EvidenceBundleDetails(bundle, signature);
|
||||
}
|
||||
|
||||
private sealed class FakeMerkleTreeCalculator : IMerkleTreeCalculator
|
||||
{
|
||||
public string NextHash { get; set; } = "sha256:default";
|
||||
|
||||
public string CalculateRootHash(IEnumerable<string> inputs)
|
||||
{
|
||||
_ = inputs.ToList();
|
||||
return NextHash;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeReindexRepository : IEvidenceBundleRepository
|
||||
{
|
||||
private readonly List<EvidenceBundleDetails> _bundles = new();
|
||||
public int UpdateCount { get; set; }
|
||||
public string? LastUpdatedRootHash { get; private set; }
|
||||
|
||||
public void AddBundle(EvidenceBundleDetails bundle) => _bundles.Add(bundle);
|
||||
|
||||
public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task SetBundleAssemblyAsync(
|
||||
EvidenceBundleId bundleId,
|
||||
TenantId tenantId,
|
||||
EvidenceBundleStatus status,
|
||||
string rootHash,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
UpdateCount++;
|
||||
LastUpdatedRootHash = rootHash;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task MarkBundleSealedAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, DateTimeOffset sealedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpsertSignatureAsync(EvidenceBundleSignature signature, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_bundles.FirstOrDefault(b => b.Bundle.Id == bundleId && b.Bundle.TenantId == tenantId));
|
||||
|
||||
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var filtered = _bundles
|
||||
.Where(b => b.Bundle.TenantId == tenantId)
|
||||
.Where(b => !since.HasValue || b.Bundle.UpdatedAt >= since.Value)
|
||||
.OrderBy(b => b.Bundle.UpdatedAt)
|
||||
.ThenBy(b => b.Bundle.Id.Value)
|
||||
.ToList();
|
||||
|
||||
if (cursorUpdatedAt.HasValue && cursorBundleId.HasValue)
|
||||
{
|
||||
filtered = filtered
|
||||
.SkipWhile(b => b.Bundle.UpdatedAt < cursorUpdatedAt.Value ||
|
||||
(b.Bundle.UpdatedAt == cursorUpdatedAt.Value && b.Bundle.Id.Value <= cursorBundleId.Value.Value))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(filtered.Take(limit).ToList());
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_bundles.Any(b => b.Bundle.Id == bundleId && b.Bundle.TenantId == tenantId));
|
||||
|
||||
public Task<EvidenceHold> CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(hold);
|
||||
|
||||
public Task ExtendBundleRetentionAsync(EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdateStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,15 @@ public sealed class EvidenceSnapshotServiceTests
|
||||
public Task<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<EvidenceBundleDetails?>(null);
|
||||
|
||||
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
|
||||
TenantId tenantId,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? cursorUpdatedAt,
|
||||
EvidenceBundleId? cursorBundleId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(Array.Empty<EvidenceBundleDetails>());
|
||||
|
||||
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(NextExistsResult);
|
||||
|
||||
|
||||
@@ -8,3 +8,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0287-M | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0287-T | DONE | Revalidated 2026-01-07; open findings tracked in audit report. |
|
||||
| AUDIT-0287-A | TODO | Revalidated 2026-01-07 (open findings). |
|
||||
| REINDEX-003 | DONE | Reindex service contract scaffolding (2026-01-16). |
|
||||
| REINDEX-004 | DONE | Reindex service root recomputation (2026-01-16). |
|
||||
| REINDEX-005 | DONE | Cross-reference mapping (2026-01-16). |
|
||||
| REINDEX-006 | DONE | Continuity verification (2026-01-16). |
|
||||
|
||||
Reference in New Issue
Block a user