Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/EvidencePortableBundleServiceTests.cs
2026-02-01 21:37:40 +02:00

372 lines
16 KiB
C#

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<InvalidOperationException>(() => 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<EvidencePortableBundleService>.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<string, string>
{
["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<string, string>()
}
}
};
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<string, string> 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<string, string>(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<string, TarEntryMetadata> 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<string, TarEntryMetadata>(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<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
public Task<IReadOnlyList<EvidenceBundleDetails>> GetBundlesForReindexAsync(
TenantId tenantId,
DateTimeOffset? since,
DateTimeOffset? cursorUpdatedAt,
EvidenceBundleId? cursorBundleId,
int limit,
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<EvidenceBundleDetails>>(Array.Empty<EvidenceBundleDetails>());
public Task<bool> ExistsAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult(true);
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)
{
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<EvidenceObjectMetadata> 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<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult<Stream>(new MemoryStream(StoredBytes ?? Array.Empty<byte>()));
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_exists);
}
}