using System.Buffers.Binary; using System.Formats.Tar; using System.IO.Compression; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Core.Repositories; using StellaOps.EvidenceLocker.Core.Storage; using StellaOps.EvidenceLocker.Infrastructure.Services; namespace StellaOps.EvidenceLocker.Tests; public sealed class EvidenceBundlePackagingServiceTests { private static readonly TenantId TenantId = TenantId.FromGuid(Guid.NewGuid()); private static readonly EvidenceBundleId BundleId = EvidenceBundleId.FromGuid(Guid.NewGuid()); private static readonly DateTimeOffset CreatedAt = new(2025, 11, 3, 12, 30, 0, TimeSpan.Zero); [Fact] public async Task EnsurePackageAsync_ReturnsCached_WhenPackageExists() { var repository = new FakeRepository(CreateSealedBundle(), CreateSignature()); var objectStore = new FakeObjectStore(exists: true); var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger.Instance); var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None); Assert.False(result.Created); Assert.Equal(repository.Bundle.StorageKey, result.StorageKey); Assert.Equal(repository.Bundle.RootHash, result.RootHash); Assert.False(objectStore.Stored); } [Fact] public async Task EnsurePackageAsync_Throws_WhenSignatureMissing() { var repository = new FakeRepository(CreateSealedBundle(), signature: null); var objectStore = new FakeObjectStore(exists: false); var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger.Instance); await Assert.ThrowsAsync(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None)); } [Fact] public async Task EnsurePackageAsync_CreatesPackageWithExpectedEntries() { var repository = new FakeRepository( CreateSealedBundle(storageKey: $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle-old.tgz"), CreateSignature(includeTimestamp: true)); var objectStore = new FakeObjectStore(exists: false); var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger.Instance); var result = await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None); Assert.True(result.Created); Assert.True(objectStore.Stored); var entries = ReadArchiveEntries(objectStore.StoredBytes!); Assert.Contains(entries.Keys, key => key == "manifest.json"); Assert.Contains(entries.Keys, key => key == "signature.json"); Assert.Contains(entries.Keys, key => key == "bundle.json"); Assert.Contains(entries.Keys, key => key == "checksums.txt"); Assert.Contains(entries.Keys, key => key == "instructions.txt"); var manifestJson = entries["manifest.json"]; using var manifestDoc = JsonDocument.Parse(manifestJson); Assert.Equal(BundleId.Value.ToString("D"), manifestDoc.RootElement.GetProperty("bundleId").GetString()); var signatureJson = entries["signature.json"]; using var signatureDoc = JsonDocument.Parse(signatureJson); Assert.Equal("application/vnd.stella.evidence.manifest+json", signatureDoc.RootElement.GetProperty("payloadType").GetString()); Assert.Equal("tsa.default", signatureDoc.RootElement.GetProperty("timestampAuthority").GetString()); Assert.False(string.IsNullOrEmpty(signatureDoc.RootElement.GetProperty("timestampToken").GetString())); var checksums = entries["checksums.txt"].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); Assert.Contains(checksums, line => line.Contains(repository.Bundle.RootHash, StringComparison.Ordinal)); var instructions = entries["instructions.txt"]; Assert.Contains("Timestamped At:", instructions, StringComparison.Ordinal); Assert.Contains("Validate the RFC3161 timestamp token", instructions, StringComparison.Ordinal); Assert.True(repository.StorageKeyUpdated); } [Fact] public async Task EnsurePackageAsync_ProducesDeterministicGzipHeader() { var repository = new FakeRepository(CreateSealedBundle(), CreateSignature()); var objectStore = new FakeObjectStore(exists: false); var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger.Instance); await service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None); Assert.True(objectStore.Stored); var archiveBytes = objectStore.StoredBytes!; Assert.True(archiveBytes.Length > 10); Assert.Equal(0x1f, archiveBytes[0]); Assert.Equal(0x8b, archiveBytes[1]); var mtime = BinaryPrimitives.ReadInt32LittleEndian(archiveBytes.AsSpan(4, 4)); var expectedSeconds = (int)(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) - DateTimeOffset.UnixEpoch).TotalSeconds; Assert.Equal(expectedSeconds, mtime); } [Fact] public async Task EnsurePackageAsync_Throws_WhenManifestPayloadInvalid() { var signature = CreateSignature() with { Payload = "not-base64" }; var repository = new FakeRepository(CreateSealedBundle(), signature); var objectStore = new FakeObjectStore(exists: false); var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger.Instance); var exception = await Assert.ThrowsAsync(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None)); Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task EnsurePackageAsync_Throws_WhenManifestPayloadNotJson() { var rawPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("not-json")); var signature = CreateSignature() with { Payload = rawPayload }; var repository = new FakeRepository(CreateSealedBundle(), signature); var objectStore = new FakeObjectStore(exists: false); var service = new EvidenceBundlePackagingService(repository, objectStore, NullLogger.Instance); var exception = await Assert.ThrowsAsync(() => service.EnsurePackageAsync(TenantId, BundleId, CancellationToken.None)); Assert.Contains("manifest payload", exception.Message, StringComparison.OrdinalIgnoreCase); } private static EvidenceBundle CreateSealedBundle(string? storageKey = null) => new( BundleId, TenantId, EvidenceBundleKind.Job, EvidenceBundleStatus.Sealed, new string('a', 64), storageKey ?? $"tenants/{TenantId.Value:N}/bundles/{BundleId.Value:N}/bundle.tgz", CreatedAt, CreatedAt, Description: "test bundle", SealedAt: CreatedAt.AddMinutes(1), ExpiresAt: null); private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false) { var manifest = new { bundleId = BundleId.Value.ToString("D"), tenantId = TenantId.Value.ToString("D"), kind = (int)EvidenceBundleKind.Job, createdAt = CreatedAt.ToString("O"), metadata = new Dictionary { ["run"] = "nightly" }, entries = new[] { new { section = "inputs", canonicalPath = "inputs/config.json", sha256 = new string('b', 64), sizeBytes = 128, mediaType = "application/json", attributes = new Dictionary() } } }; var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web)); var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson)); return new EvidenceBundleSignature( BundleId, TenantId, "application/vnd.stella.evidence.manifest+json", payload, Convert.ToBase64String(Encoding.UTF8.GetBytes("signature")), "key-1", "ES256", "default", CreatedAt.AddMinutes(1), TimestampedAt: includeTimestamp ? CreatedAt.AddMinutes(2) : null, TimestampAuthority: includeTimestamp ? "tsa.default" : null, TimestampToken: includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null); } private static Dictionary ReadArchiveEntries(byte[] archiveBytes) { using var memory = new MemoryStream(archiveBytes); using var gzip = new GZipStream(memory, CompressionMode.Decompress, leaveOpen: true); using var reader = new TarReader(gzip); var entries = new Dictionary(StringComparer.Ordinal); TarEntry? entry; while ((entry = reader.GetNextEntry()) is not null) { if (entry.EntryType != TarEntryType.RegularFile) { continue; } using var entryStream = new MemoryStream(); entry.DataStream!.CopyTo(entryStream); var content = Encoding.UTF8.GetString(entryStream.ToArray()); entries[entry.Name] = content; } return entries; } private sealed class FakeRepository : IEvidenceBundleRepository { private EvidenceBundle _bundle; public FakeRepository(EvidenceBundle bundle, EvidenceBundleSignature? signature) { _bundle = bundle; Signature = signature; } public EvidenceBundle Bundle => _bundle; public EvidenceBundleSignature? Signature { get; } public bool StorageKeyUpdated { get; private set; } public bool PortableStorageKeyUpdated { get; private set; } public Task CreateBundleAsync(EvidenceBundle bundle, CancellationToken cancellationToken) => Task.CompletedTask; public Task SetBundleAssemblyAsync(EvidenceBundleId bundleId, TenantId tenantId, EvidenceBundleStatus status, string rootHash, DateTimeOffset updatedAt, CancellationToken cancellationToken) => 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(new EvidenceBundleDetails(_bundle, Signature)); public Task ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken) => Task.FromResult(true); 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) { StorageKeyUpdated = true; return Task.CompletedTask; } public Task UpdatePortableStorageKeyAsync(EvidenceBundleId bundleId, TenantId tenantId, string storageKey, DateTimeOffset generatedAt, CancellationToken cancellationToken) { PortableStorageKeyUpdated = true; _bundle = _bundle with { PortableStorageKey = storageKey, PortableGeneratedAt = generatedAt }; return Task.CompletedTask; } } private sealed class FakeObjectStore : IEvidenceObjectStore { private readonly bool _exists; private readonly string? _fixedStorageKey; public FakeObjectStore(bool exists, string? fixedStorageKey = null) { _exists = exists; _fixedStorageKey = fixedStorageKey; } public bool Stored { get; private set; } public byte[]? StoredBytes { get; private set; } public Task StoreAsync(Stream content, EvidenceObjectWriteOptions options, CancellationToken cancellationToken) { Stored = true; using var memory = new MemoryStream(); content.CopyTo(memory); StoredBytes = memory.ToArray(); var storageKey = _fixedStorageKey ?? $"tenants/{options.TenantId.Value:N}/bundles/{options.BundleId.Value:N}/bundle.tgz"; return Task.FromResult(new EvidenceObjectMetadata( storageKey, options.ContentType, StoredBytes.Length, Convert.ToHexString(SHA256.HashData(StoredBytes)).ToLowerInvariant(), ETag: null, CreatedAt: DateTimeOffset.UtcNow)); } public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken) { if (StoredBytes is null) { throw new FileNotFoundException("Package not created."); } return Task.FromResult(new MemoryStream(StoredBytes, writable: false)); } public Task ExistsAsync(string storageKey, CancellationToken cancellationToken) => Task.FromResult(_exists); } }