sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

@@ -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>();
}

View File

@@ -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);

View File

@@ -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). |

View File

@@ -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 =>

View File

@@ -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);
}
}

View File

@@ -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),

View File

@@ -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);

View File

@@ -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);

View File

@@ -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)));

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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). |