444 lines
16 KiB
C#
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;
|
|
}
|
|
}
|