// Copyright © StellaOps. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // 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(); var progress = new Progress(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( () => _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( () => _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( () => _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( () => _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( () => _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(), Entries = new[] { new { Section = "inputs", CanonicalPath = "inputs/config.json", Sha256 = "abc123", SizeBytes = 100L, MediaType = "application/json", Attributes = (Dictionary?)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 inputs) { _ = inputs.ToList(); return NextHash; } } private sealed class FakeReindexRepository : IEvidenceBundleRepository { private readonly List _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 GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken) => Task.FromResult(_bundles.FirstOrDefault(b => b.Bundle.Id == bundleId && b.Bundle.TenantId == tenantId)); public Task> 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>(filtered.Take(limit).ToList()); } public Task ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken) => Task.FromResult(_bundles.Any(b => b.Bundle.Id == bundleId && b.Bundle.TenantId == tenantId)); public Task 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; } }