using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.EvidenceLocker.Core.Configuration; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Core.Repositories; using StellaOps.EvidenceLocker.Core.Storage; using StellaOps.EvidenceLocker.Infrastructure.Services; using StellaOps.TestKit; using System.Formats.Tar; using System.IO.Compression; using System.Text; using System.Text.Json; namespace StellaOps.EvidenceLocker.Tests; public sealed class EvidencePortableBundleServiceTests { 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, 4, 10, 30, 0, TimeSpan.Zero); [Trait("Category", TestCategories.Unit)] [Fact] public async Task EnsurePortablePackageAsync_ReturnsCached_WhenObjectExists() { var bundle = CreateSealedBundle( portableStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz", portableGeneratedAt: CreatedAt); var repository = new FakeRepository(bundle, CreateSignature()); var objectStore = new FakeObjectStore(exists: true); var service = CreateService(repository, objectStore); var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None); Assert.False(result.Created); Assert.Equal(bundle.RootHash, result.RootHash); Assert.Equal(bundle.PortableStorageKey, result.StorageKey); Assert.False(objectStore.Stored); Assert.False(repository.PortableStorageKeyUpdated); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task EnsurePortablePackageAsync_CreatesPortableArchiveWithRedactedMetadata() { var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true)); var objectStore = new FakeObjectStore(exists: false, fixedStorageKey: "tenants/foo/bundles/bar/portable-bundle-v1.tgz"); var service = CreateService(repository, objectStore); var result = await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None); Assert.True(result.Created); Assert.True(objectStore.Stored); Assert.NotNull(objectStore.StoredBytes); Assert.True(repository.PortableStorageKeyUpdated); Assert.NotNull(repository.Bundle.PortableStorageKey); Assert.NotNull(repository.Bundle.PortableGeneratedAt); var entries = ReadArchiveEntries(objectStore.StoredBytes!); Assert.Contains("manifest.json", entries.Keys); Assert.Contains("signature.json", entries.Keys); Assert.Contains("bundle.json", entries.Keys); Assert.Contains("instructions-portable.txt", entries.Keys); Assert.Contains("verify-offline.sh", entries.Keys); using var bundleJson = JsonDocument.Parse(entries["bundle.json"]); var root = bundleJson.RootElement; Assert.False(root.TryGetProperty("tenantId", out _)); Assert.False(root.TryGetProperty("storageKey", out _)); Assert.False(root.TryGetProperty("description", out _)); Assert.Equal(repository.Bundle.Id.Value.ToString("D"), root.GetProperty("bundleId").GetString()); Assert.Equal(repository.Bundle.RootHash, root.GetProperty("rootHash").GetString()); Assert.True(root.TryGetProperty("portableGeneratedAt", out var generatedAtProperty)); Assert.True(DateTimeOffset.TryParse(generatedAtProperty.GetString(), out _)); var incidentMetadata = root.GetProperty("incidentMetadata"); Assert.Equal(JsonValueKind.Object, incidentMetadata.ValueKind); Assert.Contains(incidentMetadata.EnumerateObject(), p => p.Name.StartsWith("incident.", StringComparison.Ordinal)); var instructions = entries["instructions-portable.txt"]; Assert.Contains("Portable Evidence Bundle Instructions", instructions, StringComparison.Ordinal); Assert.Contains("verify-offline.sh", instructions, StringComparison.Ordinal); var script = entries["verify-offline.sh"]; Assert.StartsWith("#!/usr/bin/env sh", script, StringComparison.Ordinal); Assert.Contains("sha256sum", script, StringComparison.Ordinal); Assert.Contains("stella evidence verify", script, StringComparison.Ordinal); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task EnsurePortablePackageAsync_Throws_WhenSignatureMissing() { var repository = new FakeRepository(CreateSealedBundle(), signature: null); var objectStore = new FakeObjectStore(exists: false); var service = CreateService(repository, objectStore); await Assert.ThrowsAsync(() => service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None)); } [Trait("Category", TestCategories.Unit)] [Fact] public async Task EnsurePortablePackageAsync_ProducesDeterministicTarEntryMetadata() { var repository = new FakeRepository(CreateSealedBundle(), CreateSignature(includeTimestamp: true)); var objectStore = new FakeObjectStore(exists: false); var service = CreateService(repository, objectStore); await service.EnsurePortablePackageAsync(TenantId, BundleId, CancellationToken.None); Assert.True(objectStore.Stored); var entryMetadata = ReadArchiveEntryMetadata(objectStore.StoredBytes!); // Verify all entries have deterministic uid/gid/username/groupname per bundle-packaging.md foreach (var (name, meta) in entryMetadata) { Assert.Equal(0, meta.Uid); Assert.Equal(0, meta.Gid); Assert.True( string.IsNullOrEmpty(meta.UserName), $"Entry '{name}' should have empty username but was '{meta.UserName}'"); Assert.True( string.IsNullOrEmpty(meta.GroupName), $"Entry '{name}' should have empty groupname but was '{meta.GroupName}'"); Assert.Equal(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), meta.ModificationTime); } } private static EvidencePortableBundleService CreateService(FakeRepository repository, IEvidenceObjectStore objectStore) { 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(), Signing = new SigningOptions(), Portable = new PortableOptions() }); return new EvidencePortableBundleService( repository, objectStore, options, TimeProvider.System, NullLogger.Instance); } private static EvidenceBundle CreateSealedBundle( string? portableStorageKey = null, DateTimeOffset? portableGeneratedAt = null) => new EvidenceBundle( BundleId, TenantId, EvidenceBundleKind.Evaluation, EvidenceBundleStatus.Sealed, new string('f', 64), "tenants/foo/bundles/bar/bundle.tgz", CreatedAt, CreatedAt, Description: "sensitive", SealedAt: CreatedAt.AddMinutes(5), ExpiresAt: CreatedAt.AddDays(30), PortableStorageKey: portableStorageKey, PortableGeneratedAt: portableGeneratedAt); private static EvidenceBundleSignature CreateSignature(bool includeTimestamp = false) { var manifest = new { bundleId = BundleId.Value, tenantId = TenantId.Value, kind = (int)EvidenceBundleKind.Evaluation, createdAt = CreatedAt, metadata = new Dictionary { ["pipeline"] = "ops", ["incident.mode"] = "enabled", ["incident.changedAt"] = CreatedAt.ToString("O"), ["incident.retentionExtensionDays"] = "60" }, entries = new[] { new { section = "inputs", canonicalPath = "inputs/config.json", sha256 = new string('a', 64), sizeBytes = 128L, mediaType = "application/json", attributes = new Dictionary() } } }; var payload = JsonSerializer.Serialize(manifest); return new EvidenceBundleSignature( BundleId, TenantId, "application/vnd.stella.evidence.manifest+json", Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)), "sig-payload", "key-id", "ES256", "provider", CreatedAt, includeTimestamp ? CreatedAt.AddMinutes(1) : null, includeTimestamp ? "tsa.default" : null, includeTimestamp ? Encoding.UTF8.GetBytes("tsa-token") : null); } private static IReadOnlyDictionary ReadArchiveEntries(byte[] archive) { using var memory = new MemoryStream(archive); using var gzip = new GZipStream(memory, CompressionMode.Decompress); using var tarReader = new TarReader(gzip); var entries = new Dictionary(StringComparer.Ordinal); TarEntry? entry; while ((entry = tarReader.GetNextEntry()) is not null) { if (entry.EntryType != TarEntryType.RegularFile) { continue; } using var entryStream = new MemoryStream(); entry.DataStream!.CopyTo(entryStream); entries[entry.Name] = Encoding.UTF8.GetString(entryStream.ToArray()); } return entries; } private static Dictionary ReadArchiveEntryMetadata(byte[] archive) { using var memory = new MemoryStream(archive); using var gzip = new GZipStream(memory, CompressionMode.Decompress); using var tarReader = new TarReader(gzip); var entries = new Dictionary(StringComparer.Ordinal); TarEntry? entry; while ((entry = tarReader.GetNextEntry()) is not null) { if (entry.EntryType != TarEntryType.RegularFile) { continue; } var posixEntry = entry as PosixTarEntry; entries[entry.Name] = new TarEntryMetadata( entry.Uid, entry.Gid, posixEntry?.UserName ?? string.Empty, posixEntry?.GroupName ?? string.Empty, entry.ModificationTime); } return entries; } private sealed record TarEntryMetadata( int Uid, int Gid, string UserName, string GroupName, DateTimeOffset ModificationTime); 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 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> 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(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) => 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}/portable-bundle-v1.tgz"; return Task.FromResult(new EvidenceObjectMetadata( storageKey, options.ContentType, StoredBytes.Length, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(StoredBytes)).ToLowerInvariant(), ETag: null, CreatedAt: DateTimeOffset.UtcNow)); } public Task OpenReadAsync(string storageKey, CancellationToken cancellationToken) => Task.FromResult(new MemoryStream(StoredBytes ?? Array.Empty())); public Task ExistsAsync(string storageKey, CancellationToken cancellationToken) => Task.FromResult(_exists); } }