Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- 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.
317 lines
14 KiB
C#
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);
|
|
}
|
|
}
|