Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Tests/EvidenceBundlePackagingServiceTests.cs
master 2eb6852d34
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Add unit tests for SBOM ingestion and transformation
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly.
- Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps.
- Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges.
- Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges.
- Set up project file for the test project with necessary dependencies and configurations.
- Include JSON fixture files for testing purposes.
2025-11-04 07:49:39 +02:00

317 lines
14 KiB
C#

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<EvidenceBundlePackagingService>.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<EvidenceBundlePackagingService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<EvidenceBundlePackagingService>.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<EvidenceBundlePackagingService>.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<EvidenceBundlePackagingService>.Instance);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => 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<EvidenceBundlePackagingService>.Instance);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => 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<string, string> { ["run"] = "nightly" },
entries = new[]
{
new
{
section = "inputs",
canonicalPath = "inputs/config.json",
sha256 = new string('b', 64),
sizeBytes = 128,
mediaType = "application/json",
attributes = new Dictionary<string, string>()
}
}
};
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<string, string> 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<string, string>(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<EvidenceBundleDetails?> GetBundleAsync(EvidenceBundleId bundleId, TenantId tenantId, CancellationToken cancellationToken)
=> Task.FromResult<EvidenceBundleDetails?>(new EvidenceBundleDetails(_bundle, Signature));
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)
{
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<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}/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<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken)
{
if (StoredBytes is null)
{
throw new FileNotFoundException("Package not created.");
}
return Task.FromResult<Stream>(new MemoryStream(StoredBytes, writable: false));
}
public Task<bool> ExistsAsync(string storageKey, CancellationToken cancellationToken)
=> Task.FromResult(_exists);
}
}