Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/EvidenceReindexServiceTests.cs

444 lines
16 KiB
C#

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