using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Npgsql; using StellaOps.Cryptography; using StellaOps.EvidenceLocker.Core.Builders; using StellaOps.EvidenceLocker.Core.Configuration; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Core.Incident; using StellaOps.EvidenceLocker.Core.Repositories; using StellaOps.EvidenceLocker.Core.Signing; using StellaOps.EvidenceLocker.Core.Storage; using StellaOps.EvidenceLocker.Core.Timeline; using StellaOps.EvidenceLocker.Infrastructure.Services; using StellaOps.TestKit; using System.Linq; using System.Reflection; using System.Runtime.Serialization; using System.Security.Cryptography; namespace StellaOps.EvidenceLocker.Tests; public sealed class EvidenceSnapshotServiceTests { private static readonly string ValidSha256 = Sha('a'); private static readonly string DefaultRootHash = Sha('f'); private readonly FakeRepository _repository = new(); private readonly FakeBuilder _builder = new(); private readonly FakeSignatureService _signatureService = new(); private readonly FakeTimelinePublisher _timelinePublisher = new(); private readonly TestIncidentState _incidentState = new(); private readonly TestObjectStore _objectStore = new(); private readonly EvidenceSnapshotService _service; public EvidenceSnapshotServiceTests() { var options = Options.Create(new EvidenceLockerOptions { Database = new DatabaseOptions { ConnectionString = "Host=localhost" }, ObjectStore = new ObjectStoreOptions { Kind = ObjectStoreKind.FileSystem, FileSystem = new FileSystemStoreOptions { RootPath = "." } }, Quotas = new QuotaOptions { MaxMaterialCount = 4, MaxTotalMaterialSizeBytes = 1_024, MaxMetadataEntries = 4, MaxMetadataKeyLength = 32, MaxMetadataValueLength = 64 }, Signing = new SigningOptions { Enabled = false, Algorithm = SignatureAlgorithms.Es256, KeyId = "test-key" }, Incident = new IncidentModeOptions { Enabled = false, RetentionExtensionDays = 30, CaptureRequestSnapshot = true } }); _service = new EvidenceSnapshotService( _repository, _builder, _signatureService, _timelinePublisher, _incidentState, _objectStore, TimeProvider.System, options, NullLogger.Instance); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateSnapshotAsync_PersistsBundleAndBuildsManifest() { var request = new EvidenceSnapshotRequest { Kind = EvidenceBundleKind.Evaluation, Metadata = new Dictionary { ["run"] = "alpha" }, Materials = new List { new() { Section = "inputs", Path = "config.json", Sha256 = ValidSha256, SizeBytes = 128, MediaType = "application/json" } } }; var tenantId = TenantId.FromGuid(Guid.NewGuid()); var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None); Assert.NotEqual(Guid.Empty, result.BundleId); Assert.Equal(_builder.RootHash, result.RootHash); Assert.Equal(tenantId, _repository.LastCreateTenant); Assert.Equal(EvidenceBundleStatus.Pending, _repository.LastCreatedStatus); Assert.Equal(EvidenceBundleStatus.Assembling, _repository.AssemblyStatus); Assert.Equal(_builder.RootHash, _repository.AssemblyRootHash); Assert.True(_builder.Invoked); Assert.Null(result.Signature); Assert.False(_timelinePublisher.BundleSealedPublished); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateSnapshotAsync_StoresSignature_WhenSignerReturnsEnvelope() { var tenantId = TenantId.FromGuid(Guid.NewGuid()); var bundleId = EvidenceBundleId.FromGuid(Guid.NewGuid()); _signatureService.NextSignature = new EvidenceBundleSignature( bundleId, tenantId, "application/vnd.test", "payload", Convert.ToBase64String(new byte[] { 1, 2, 3 }), "key-1", SignatureAlgorithms.Es256, "default", DateTimeOffset.UtcNow, null, null, null); _builder.OverrideBundleId = bundleId; var request = new EvidenceSnapshotRequest { Kind = EvidenceBundleKind.Evaluation, Materials = new List { new() { Section = "inputs", Path = "config.json", Sha256 = ValidSha256, SizeBytes = 128 } } }; var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None); Assert.NotNull(result.Signature); Assert.True(_repository.SignatureUpserted); Assert.True(_timelinePublisher.BundleSealedPublished); Assert.Equal(_repository.LastCreatedBundleId?.Value ?? Guid.Empty, result.BundleId); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateSnapshotAsync_ThrowsWhenMaterialQuotaExceeded() { var request = new EvidenceSnapshotRequest { Kind = EvidenceBundleKind.Job, Materials = new List { new() { Section = "a", Path = "1", Sha256 = Sha('a'), SizeBytes = 900 }, new() { Section = "b", Path = "2", Sha256 = Sha('b'), SizeBytes = 300 } } }; await Assert.ThrowsAsync(() => _service.CreateSnapshotAsync(TenantId.FromGuid(Guid.NewGuid()), request, CancellationToken.None)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateHoldAsync_ReturnsHoldWhenValid() { var tenantId = TenantId.FromGuid(Guid.NewGuid()); _repository.NextExistsResult = true; var holdRequest = new EvidenceHoldRequest { BundleId = Guid.NewGuid(), Reason = "legal", Notes = "note" }; var hold = await _service.CreateHoldAsync(tenantId, "case-123", holdRequest, CancellationToken.None); Assert.Equal("case-123", hold.CaseId); Assert.True(_repository.RetentionExtended); Assert.Equal(holdRequest.BundleId, _repository.RetentionBundleId?.Value); Assert.Null(_repository.RetentionExpiresAt); Assert.NotNull(_repository.RetentionProcessedAt); Assert.Equal(tenantId, _repository.RetentionTenant); Assert.True(_timelinePublisher.HoldPublished); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateHoldAsyncThrowsWhenBundleMissing() { var tenantId = TenantId.FromGuid(Guid.NewGuid()); _repository.NextExistsResult = false; await Assert.ThrowsAsync(() => _service.CreateHoldAsync(tenantId, "case-999", new EvidenceHoldRequest { BundleId = Guid.NewGuid(), Reason = "legal" }, CancellationToken.None)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateHoldAsyncThrowsWhenCaseAlreadyExists() { _repository.ThrowUniqueViolationForHolds = true; await Assert.ThrowsAsync(() => _service.CreateHoldAsync( TenantId.FromGuid(Guid.NewGuid()), "case-dup", new EvidenceHoldRequest { Reason = "legal" }, CancellationToken.None)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task CreateSnapshotAsync_ExtendsRetentionAndCapturesIncidentArtifacts_WhenIncidentModeActive() { _incidentState.SetState(true, retentionExtensionDays: 45, captureSnapshot: true); Assert.True(_incidentState.Current.IsActive); Assert.Equal(45, _incidentState.Current.RetentionExtensionDays); var request = new EvidenceSnapshotRequest { Kind = EvidenceBundleKind.Job, Metadata = new Dictionary { ["run"] = "diagnostic" }, Materials = new List { new() { Section = "inputs", Path = "input.txt", Sha256 = ValidSha256, SizeBytes = 32 } } }; var tenantId = TenantId.FromGuid(Guid.NewGuid()); var result = await _service.CreateSnapshotAsync(tenantId, request, CancellationToken.None); Assert.NotNull(_repository.CreatedBundle); Assert.True(_repository.CreatedBundle!.ExpiresAt.HasValue); Assert.NotNull(_repository.LastCreatedExpiresAt); Assert.NotNull(_repository.LastCreatedAt); Assert.Equal( _repository.LastCreatedAt!.Value.AddDays(45), _repository.LastCreatedExpiresAt!.Value, TimeSpan.FromSeconds(1)); Assert.NotEmpty(_objectStore.StoredArtifacts); var incidentEntry = result.Manifest.Entries.Single(e => e.Section == "incident"); Assert.True(result.Manifest.Metadata.ContainsKey("incident.mode")); Assert.Equal("enabled", result.Manifest.Metadata["incident.mode"]); Assert.StartsWith("incident/request-", incidentEntry.CanonicalPath, StringComparison.Ordinal); Assert.Equal("application/json", incidentEntry.MediaType); } private static string Sha(char fill) => new string(fill, 64); private sealed class FakeRepository : IEvidenceBundleRepository { public TenantId? LastCreateTenant { get; private set; } public EvidenceBundleStatus? LastCreatedStatus { get; private set; } public EvidenceBundleId? LastCreatedBundleId { get; private set; } public bool NextExistsResult { get; set; } = true; public bool ThrowUniqueViolationForHolds { get; set; } public bool SignatureUpserted { get; private set; } public bool RetentionExtended { get; private set; } public EvidenceBundleId? RetentionBundleId { get; private set; } public TenantId? RetentionTenant { get; private set; } public DateTimeOffset? RetentionExpiresAt { get; private set; } public DateTimeOffset? RetentionProcessedAt { get; private set; } public string? AssemblyRootHash { get; private set; } public EvidenceBundleStatus? AssemblyStatus { get; private set; } public DateTimeOffset? LastCreatedExpiresAt { get; private set; } public DateTimeOffset? LastCreatedAt { get; private set; } public EvidenceBundle? CreatedBundle { get; private set; } public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken) { LastCreateTenant = bundle.TenantId; LastCreatedStatus = bundle.Status; LastCreatedBundleId = bundle.Id; LastCreatedExpiresAt = bundle.ExpiresAt; LastCreatedAt = bundle.CreatedAt; CreatedBundle = bundle; return Task.CompletedTask; } public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken) { AssemblyStatus = status; AssemblyRootHash = 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) { SignatureUpserted = true; return Task.CompletedTask; } public Task GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken) => Task.FromResult(null); public Task> GetBundlesForReindexAsync( TenantId tenantId, DateTimeOffset? since, DateTimeOffset? cursorUpdatedAt, EvidenceBundleId? cursorBundleId, int limit, CancellationToken cancellationToken) => Task.FromResult>(Array.Empty()); public Task ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken) => Task.FromResult(NextExistsResult); public Task CreateHoldAsync(EvidenceHold hold, CancellationToken cancellationToken) { if (ThrowUniqueViolationForHolds) { throw CreateUniqueViolationException(); } return Task.FromResult(hold); } public Task ExtendBundleRetentionAsync( EvidenceBundleId bundleId, TenantId tenantId, DateTimeOffset? holdExpiresAt, DateTimeOffset processedAt, CancellationToken cancellationToken) { RetentionExtended = true; RetentionBundleId = bundleId; RetentionTenant = tenantId; RetentionExpiresAt = holdExpiresAt; RetentionProcessedAt = processedAt; return 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; #pragma warning disable SYSLIB0050 private static PostgresException CreateUniqueViolationException() { var exception = (PostgresException)FormatterServices.GetUninitializedObject(typeof(PostgresException)); SetStringField(exception, "k__BackingField", PostgresErrorCodes.UniqueViolation); SetStringField(exception, "_sqlState", PostgresErrorCodes.UniqueViolation); return exception; } #pragma warning restore SYSLIB0050 private static void SetStringField(object target, string fieldName, string value) { var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); field?.SetValue(target, value); } } private sealed class FakeBuilder : IEvidenceBundleBuilder { public string RootHash { get; } = DefaultRootHash; public bool Invoked { get; private set; } public EvidenceBundleId? OverrideBundleId { get; set; } public Task BuildAsync(EvidenceBundleBuildRequest request, CancellationToken cancellationToken) { Invoked = true; var effectiveBundleId = OverrideBundleId ?? request.BundleId; var manifest = new EvidenceBundleManifest( effectiveBundleId, request.TenantId, request.Kind, request.CreatedAt, request.Metadata, request.Materials.Select(m => new EvidenceManifestEntry( m.Section, $"{m.Section}/{m.Path}", m.Sha256, m.SizeBytes, m.MediaType, m.Attributes ?? new Dictionary())).ToList()); return Task.FromResult(new EvidenceBundleBuildResult(RootHash, manifest)); } } private sealed class FakeSignatureService : IEvidenceSignatureService { public EvidenceBundleSignature? NextSignature { get; set; } public Task SignManifestAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleManifest manifest, CancellationToken cancellationToken) { if (NextSignature is null) { return Task.FromResult(null); } return Task.FromResult(NextSignature with { BundleId = bundleId, TenantId = tenantId }); } } private sealed class FakeTimelinePublisher : IEvidenceTimelinePublisher { public bool BundleSealedPublished { get; private set; } public bool HoldPublished { get; private set; } public string? LastBundleRoot { get; private set; } public List IncidentEvents { get; } = new(); public Task PublishBundleSealedAsync( EvidenceBundleSignature signature, EvidenceBundleManifest manifest, string rootHash, CancellationToken cancellationToken) { BundleSealedPublished = true; LastBundleRoot = rootHash; return Task.CompletedTask; } public Task PublishHoldCreatedAsync(EvidenceHold hold, CancellationToken cancellationToken) { HoldPublished = true; return Task.CompletedTask; } public Task PublishIncidentModeChangedAsync(IncidentModeChange change, CancellationToken cancellationToken) { IncidentEvents.Add(change.IsActive ? "enabled" : "disabled"); return Task.CompletedTask; } } private sealed class TestIncidentState : IIncidentModeState { private IncidentModeSnapshot _snapshot = new(false, DateTimeOffset.UtcNow, 0, false); public IncidentModeSnapshot Current => _snapshot; public bool IsActive => _snapshot.IsActive; public void SetState(bool isActive, int retentionExtensionDays, bool captureSnapshot) { _snapshot = new IncidentModeSnapshot( isActive, DateTimeOffset.UtcNow, retentionExtensionDays, captureSnapshot); } } private sealed class TestObjectStore : IEvidenceObjectStore { private readonly Dictionary _objects = new(StringComparer.Ordinal); public List StoredArtifacts { get; } = new(); public Task StoreAsync( Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken) { using var memory = new MemoryStream(); content.CopyTo(memory); var bytes = memory.ToArray(); var storageKey = $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/{options.ArtifactName}"; var sha = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); var metadata = new EvidenceObjectMetadata( storageKey, options.ContentType, bytes.LongLength, sha, ETag: null, CreatedAt: DateTimeOffset.UtcNow); _objects[storageKey] = bytes; StoredArtifacts.Add(metadata); return Task.FromResult(metadata); } public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken) { if (!_objects.TryGetValue(storageKey, out var bytes)) { throw new FileNotFoundException(storageKey); } return Task.FromResult(new MemoryStream(bytes, writable: false)); } public Task ExistsAsync(string storageKey, CancellationToken cancellationToken) => Task.FromResult(_objects.ContainsKey(storageKey)); } }