Add tests for SBOM generation determinism across multiple formats

- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -20,7 +20,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = SHA256.HashData(Sample);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Sha256);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -29,7 +29,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = SHA512.HashData(Sample);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Sha512);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -38,7 +38,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = ComputeGostDigest(use256: true);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Gost3411_2012_256);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -47,7 +47,7 @@ public sealed class DefaultCryptoHashTests
var hash = CryptoHashFactory.CreateDefault();
var expected = ComputeGostDigest(use256: false);
var actual = hash.ComputeHash(Sample, HashAlgorithms.Gost3411_2012_512);
Assert.Equal(Convert.ToHexString(expected).ToLowerInvariant(), Convert.ToHexString(actual).ToLowerInvariant());
Assert.Equal(Convert.ToHexStringLower(expected), Convert.ToHexStringLower(actual));
}
[Fact]
@@ -60,6 +60,25 @@ public sealed class DefaultCryptoHashTests
Assert.Equal(Convert.ToHexString(bufferDigest), Convert.ToHexString(streamDigest));
}
[Fact]
public void ComputeHashHex_Sha256_MatchesBclLowerHex()
{
var hash = CryptoHashFactory.CreateDefault();
var expected = Convert.ToHexStringLower(SHA256.HashData(Sample));
var actual = hash.ComputeHashHex(Sample, HashAlgorithms.Sha256);
Assert.Equal(expected, actual);
}
[Fact]
public async Task ComputeHashHexAsync_Sha256_MatchesBclLowerHex()
{
var hash = CryptoHashFactory.CreateDefault();
var expected = Convert.ToHexStringLower(SHA256.HashData(Sample));
await using var stream = new MemoryStream(Sample);
var actual = await hash.ComputeHashHexAsync(stream, HashAlgorithms.Sha256);
Assert.Equal(expected, actual);
}
private static byte[] ComputeGostDigest(bool use256)
{
Org.BouncyCastle.Crypto.IDigest digest = use256

View File

@@ -0,0 +1,34 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using StellaOps.Cryptography;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class DefaultCryptoHmacTests
{
private static readonly byte[] Sample = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog");
private static readonly byte[] Key = Encoding.UTF8.GetBytes("test-key");
[Fact]
public void ComputeHmacHexForPurpose_WebhookInterop_MatchesBclLowerHex()
{
var hmac = DefaultCryptoHmac.CreateForTests();
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(Key, Sample));
var actual = hmac.ComputeHmacHexForPurpose(Key, Sample, HmacPurpose.WebhookInterop);
Assert.Equal(expected, actual);
}
[Fact]
public async Task ComputeHmacHexForPurposeAsync_WebhookInterop_MatchesBclLowerHex()
{
var hmac = DefaultCryptoHmac.CreateForTests();
var expected = Convert.ToHexStringLower(HMACSHA256.HashData(Key, Sample));
await using var stream = new MemoryStream(Sample);
var actual = await hmac.ComputeHmacHexForPurposeAsync(Key, stream, HmacPurpose.WebhookInterop);
Assert.Equal(expected, actual);
}
}

View File

@@ -0,0 +1,52 @@
using System.Security.Cryptography;
using System.Text;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Digests;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class Sha256DigestTests
{
[Fact]
public void Normalize_AllowsBareHex_WhenPrefixNotRequired()
{
var hex = new string('a', Sha256Digest.HexLength);
Assert.Equal($"sha256:{hex}", Sha256Digest.Normalize(hex));
}
[Fact]
public void Normalize_NormalizesPrefixAndHexToLower()
{
var hexUpper = new string('A', Sha256Digest.HexLength);
Assert.Equal(
$"sha256:{new string('a', Sha256Digest.HexLength)}",
Sha256Digest.Normalize($"SHA256:{hexUpper}"));
}
[Fact]
public void Normalize_RequiresPrefix_WhenConfigured()
{
var hex = new string('a', Sha256Digest.HexLength);
var ex = Assert.Throws<FormatException>(() => Sha256Digest.Normalize(hex, requirePrefix: true, parameterName: "sbomDigest"));
Assert.Contains("sbomDigest", ex.Message, StringComparison.Ordinal);
Assert.Contains("sha256:", ex.Message, StringComparison.Ordinal);
}
[Fact]
public void ExtractHex_ReturnsLowercaseHex()
{
var hexUpper = new string('A', Sha256Digest.HexLength);
Assert.Equal(new string('a', Sha256Digest.HexLength), Sha256Digest.ExtractHex($"sha256:{hexUpper}"));
}
[Fact]
public void Compute_UsesCryptoHashStack()
{
var hash = CryptoHashFactory.CreateDefault();
var content = Encoding.UTF8.GetBytes("hello");
var expectedHex = Convert.ToHexStringLower(SHA256.HashData(content));
Assert.Equal($"sha256:{expectedHex}", Sha256Digest.Compute(hash, content));
}
}

View File

@@ -16,6 +16,10 @@
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.BouncyCastle\StellaOps.Cryptography.Plugin.BouncyCastle.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.OfflineVerification\StellaOps.Cryptography.Plugin.OfflineVerification.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.OpenSslGost\StellaOps.Cryptography.Plugin.OpenSslGost.csproj" />
<ProjectReference Include="..\..\StellaOps.Cryptography.Plugin.SmSoft\StellaOps.Cryptography.Plugin.SmSoft.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,495 @@
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.TestKit.Determinism;
using Xunit;
namespace StellaOps.TestKit.Tests;
public sealed class DeterminismManifestTests
{
[Fact]
public void ToCanonicalBytes_WithValidManifest_ProducesDeterministicOutput()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var bytes1 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
var bytes2 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Assert
bytes1.Should().Equal(bytes2, "Same manifest should produce identical canonical bytes");
}
[Fact]
public void ToCanonicalString_WithValidManifest_ProducesDeterministicString()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var json1 = DeterminismManifestWriter.ToCanonicalString(manifest);
var json2 = DeterminismManifestWriter.ToCanonicalString(manifest);
// Assert
json1.Should().Be(json2, "Same manifest should produce identical canonical JSON string");
json1.Should().NotContain("\n", "Canonical JSON should have no newlines");
json1.Should().NotContain(" ", "Canonical JSON should have no indentation");
}
[Fact]
public void WriteToFile_AndReadFromFile_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
DeterminismManifestWriter.WriteToFile(manifest, tempFile);
// Act - Read
var readManifest = DeterminismManifestReader.ReadFromFile(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public async Task WriteToFileAsync_AndReadFromFileAsync_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
await DeterminismManifestWriter.WriteToFileAsync(manifest, tempFile);
// Act - Read
var readManifest = await DeterminismManifestReader.ReadFromFileAsync(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public void FromBytes_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var bytes = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Act
var deserialized = DeterminismManifestReader.FromBytes(bytes);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void FromString_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
// Act
var deserialized = DeterminismManifestReader.FromString(json);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void FromBytes_WithInvalidSchemaVersion_ThrowsInvalidOperationException()
{
// Arrange
var manifest = CreateSampleManifest() with { SchemaVersion = "2.0" };
var bytes = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Act
Action act = () => DeterminismManifestReader.FromBytes(bytes);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*schema version*2.0*");
}
[Fact]
public void TryReadFromFileAsync_WithNonExistentFile_ReturnsNull()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
var result = DeterminismManifestReader.TryReadFromFileAsync(nonExistentPath).GetAwaiter().GetResult();
// Assert
result.Should().BeNull();
}
[Fact]
public void ReadFromFile_WithNonExistentFile_ThrowsFileNotFoundException()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
Action act = () => DeterminismManifestReader.ReadFromFile(nonExistentPath);
// Assert
act.Should().Throw<FileNotFoundException>();
}
[Fact]
public void ComputeCanonicalHash_ProducesDeterministicHash()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var hash1 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
var hash2 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
// Assert
hash1.Should().Be(hash2, "Same manifest should produce same hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$", "Hash should be 64-character hex string");
}
[Fact]
public void CreateManifest_WithValidInputs_CreatesManifestWithCorrectHash()
{
// Arrange
var artifactBytes = "Test artifact content"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
manifest.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// Verify hash is correct
var expectedHash = CanonJson.Sha256Hex(artifactBytes);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifestForJsonArtifact_WithValidInputs_CreatesManifestWithCanonicalHash()
{
// Arrange
var artifact = new { Name = "test", Value = 123, Items = new[] { "a", "b", "c" } };
var artifactInfo = new ArtifactInfo
{
Type = "verdict",
Name = "test-verdict",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Policy.Engine", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifestForJsonArtifact(
artifact,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
// Verify hash is correct (should use canonical JSON)
var expectedHash = CanonJson.Hash(artifact);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifest_WithInputStamps_IncludesInputStamps()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var inputs = new InputStamps
{
FeedSnapshotHash = "abc123",
PolicyManifestHash = "def456",
SourceCodeHash = "789abc"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
inputs: inputs);
// Assert
manifest.Inputs.Should().NotBeNull();
manifest.Inputs!.FeedSnapshotHash.Should().Be("abc123");
manifest.Inputs.PolicyManifestHash.Should().Be("def456");
manifest.Inputs.SourceCodeHash.Should().Be("789abc");
}
[Fact]
public void CreateManifest_WithReproducibilityMetadata_IncludesMetadata()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 42,
ClockFixed = true,
OrderingGuarantee = "sorted",
NormalizationRules = new[] { "UTF-8", "LF line endings", "sorted JSON keys" }
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
reproducibility: reproducibility);
// Assert
manifest.Reproducibility.Should().NotBeNull();
manifest.Reproducibility!.DeterministicSeed.Should().Be(42);
manifest.Reproducibility.ClockFixed.Should().BeTrue();
manifest.Reproducibility.OrderingGuarantee.Should().Be("sorted");
manifest.Reproducibility.NormalizationRules.Should().ContainInOrder("UTF-8", "LF line endings", "sorted JSON keys");
}
[Fact]
public void CreateManifest_WithVerificationInfo_IncludesVerification()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var verification = new VerificationInfo
{
Command = "dotnet run --project Scanner -- scan container alpine:3.18",
ExpectedHash = "abc123def456",
Baseline = "tests/baselines/sbom-alpine-3.18.determinism.json"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
verification: verification);
// Assert
manifest.Verification.Should().NotBeNull();
manifest.Verification!.Command.Should().Be("dotnet run --project Scanner -- scan container alpine:3.18");
manifest.Verification.ExpectedHash.Should().Be("abc123def456");
manifest.Verification.Baseline.Should().Be("tests/baselines/sbom-alpine-3.18.determinism.json");
}
[Fact]
public void ManifestSerialization_WithComplexMetadata_PreservesAllFields()
{
// Arrange
var manifest = CreateComplexManifest();
// Act
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
var deserialized = DeterminismManifestReader.FromString(json);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
private static DeterminismManifest CreateSampleManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "abc123def456789012345678901234567890123456789012345678901234",
Encoding = "hex"
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo
{
Name = "StellaOps.Scanner",
Version = "1.0.0",
Hash = "def456abc789012345678901234567890123456789012345678901234567"
}
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 17, 45, 0, TimeSpan.Zero)
};
}
private static DeterminismManifest CreateComplexManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "evidence-bundle",
Name = "test-bundle",
Version = "2.0.0",
Format = "DSSE Envelope",
Metadata = new Dictionary<string, object?>
{
["predicateType"] = "https://in-toto.io/attestation/v1",
["subject"] = "pkg:docker/alpine@3.18"
}
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
Encoding = "hex"
},
Inputs = new InputStamps
{
FeedSnapshotHash = "feed123abc",
PolicyManifestHash = "policy456def",
SourceCodeHash = "git789ghi",
VexDocumentHashes = new[] { "vex123", "vex456" },
Custom = new Dictionary<string, string>
{
["baselineVersion"] = "1.0.0",
["environment"] = "production"
}
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Attestor", Version = "2.0.0", Hash = "hash123" },
new ComponentInfo { Name = "StellaOps.Signer", Version = "2.1.0" }
},
Compiler = new CompilerInfo
{
Name = "Roslyn",
Version = "4.8.0"
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 18, 0, 0, TimeSpan.Zero),
Reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 12345,
ClockFixed = true,
OrderingGuarantee = "stable",
NormalizationRules = new[] { "UTF-8", "LF line endings", "no trailing whitespace" }
},
Verification = new VerificationInfo
{
Command = "dotnet test --verify-determinism",
ExpectedHash = "abc123def456",
Baseline = "baselines/test-bundle.json"
},
Signatures = new[]
{
new SignatureInfo
{
Algorithm = "ES256",
KeyId = "signing-key-1",
Signature = "base64encodedSig==",
Timestamp = new DateTimeOffset(2025, 12, 23, 18, 0, 30, TimeSpan.Zero)
}
}
};
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
<ProjectReference Include="..\..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,306 @@
// -----------------------------------------------------------------------------
// DeterminismBaselineStoreTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T9 - Determinism Baseline Storage
// Description: Tests for baseline storage and comparison functionality
// -----------------------------------------------------------------------------
using System.Text;
using FluentAssertions;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Testing.Determinism.Tests;
public sealed class DeterminismBaselineStoreTests : IDisposable
{
private readonly string _testDirectory;
private readonly DeterminismBaselineStore _store;
public DeterminismBaselineStoreTests()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"determinism-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
_store = new DeterminismBaselineStore(_testDirectory);
}
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
#region CreateBaseline Tests
[Fact]
public void CreateBaseline_WithValidInput_ReturnsCorrectHash()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var version = "1.0.0";
// Act
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, version);
// Assert
baseline.Should().NotBeNull();
baseline.CanonicalHash.Should().MatchRegex("^[0-9a-f]{64}$");
baseline.Algorithm.Should().Be("SHA-256");
baseline.Version.Should().Be("1.0.0");
baseline.UpdatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void CreateBaseline_WithSameInput_ProducesSameHash()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
// Act
var baseline1 = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
var baseline2 = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
// Assert
baseline1.CanonicalHash.Should().Be(baseline2.CanonicalHash);
}
[Fact]
public void CreateBaseline_WithDifferentInput_ProducesDifferentHash()
{
// Arrange
var artifactBytes1 = Encoding.UTF8.GetBytes("{\"test\":\"data1\"}");
var artifactBytes2 = Encoding.UTF8.GetBytes("{\"test\":\"data2\"}");
// Act
var baseline1 = DeterminismBaselineStore.CreateBaseline(artifactBytes1, "1.0.0");
var baseline2 = DeterminismBaselineStore.CreateBaseline(artifactBytes2, "1.0.0");
// Assert
baseline1.CanonicalHash.Should().NotBe(baseline2.CanonicalHash);
}
[Fact]
public void CreateBaseline_WithMetadata_IncludesMetadata()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var metadata = new Dictionary<string, string>
{
["format"] = "CycloneDX 1.6",
["source"] = "scanner-test"
};
// Act
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0", metadata);
// Assert
baseline.Metadata.Should().NotBeNull();
baseline.Metadata.Should().ContainKey("format").WhoseValue.Should().Be("CycloneDX 1.6");
baseline.Metadata.Should().ContainKey("source").WhoseValue.Should().Be("scanner-test");
}
#endregion
#region Store and Retrieve Tests
[Fact]
public async Task StoreBaseline_AndRetrieve_RoundTripsCorrectly()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"component\":\"test\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "2.0.0");
// Act
await _store.StoreBaselineAsync("sbom", "test-artifact", baseline);
var retrieved = await _store.GetBaselineAsync("sbom", "test-artifact");
// Assert
retrieved.Should().NotBeNull();
retrieved!.CanonicalHash.Should().Be(baseline.CanonicalHash);
retrieved.Version.Should().Be("2.0.0");
retrieved.Algorithm.Should().Be("SHA-256");
}
[Fact]
public async Task GetBaseline_WhenNotExists_ReturnsNull()
{
// Act
var result = await _store.GetBaselineAsync("sbom", "nonexistent");
// Assert
result.Should().BeNull();
}
[Fact]
public async Task StoreBaseline_CreatesCorrectDirectoryStructure()
{
// Arrange
var baseline = DeterminismBaselineStore.CreateBaseline(
Encoding.UTF8.GetBytes("test"),
"1.0.0");
// Act
await _store.StoreBaselineAsync("vex", "openevex-document", baseline);
// Assert
var expectedPath = Path.Combine(_testDirectory, "vex", "openevex-document.baseline.json");
File.Exists(expectedPath).Should().BeTrue();
}
[Fact]
public async Task StoreBaseline_OverwritesExistingBaseline()
{
// Arrange
var baseline1 = DeterminismBaselineStore.CreateBaseline(
Encoding.UTF8.GetBytes("original"),
"1.0.0");
var baseline2 = DeterminismBaselineStore.CreateBaseline(
Encoding.UTF8.GetBytes("updated"),
"2.0.0");
// Act
await _store.StoreBaselineAsync("sbom", "artifact", baseline1);
await _store.StoreBaselineAsync("sbom", "artifact", baseline2);
var retrieved = await _store.GetBaselineAsync("sbom", "artifact");
// Assert
retrieved.Should().NotBeNull();
retrieved!.CanonicalHash.Should().Be(baseline2.CanonicalHash);
retrieved.Version.Should().Be("2.0.0");
}
#endregion
#region Compare Tests
[Fact]
public async Task Compare_WhenMatches_ReturnsMatchStatus()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
await _store.StoreBaselineAsync("sbom", "test", baseline);
// Act
var result = await _store.CompareAsync("sbom", "test", baseline.CanonicalHash);
// Assert
result.Status.Should().Be(BaselineStatus.Match);
result.CurrentHash.Should().Be(baseline.CanonicalHash);
result.BaselineHash.Should().Be(baseline.CanonicalHash);
result.Message.Should().Contain("matches baseline");
}
[Fact]
public async Task Compare_WhenDrifted_ReturnsDriftStatus()
{
// Arrange
var originalBytes = Encoding.UTF8.GetBytes("{\"test\":\"original\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(originalBytes, "1.0.0");
await _store.StoreBaselineAsync("sbom", "test", baseline);
var newBytes = Encoding.UTF8.GetBytes("{\"test\":\"changed\"}");
var newBaseline = DeterminismBaselineStore.CreateBaseline(newBytes, "1.0.0");
// Act
var result = await _store.CompareAsync("sbom", "test", newBaseline.CanonicalHash);
// Assert
result.Status.Should().Be(BaselineStatus.Drift);
result.CurrentHash.Should().Be(newBaseline.CanonicalHash);
result.BaselineHash.Should().Be(baseline.CanonicalHash);
result.Message.Should().Contain("DRIFT DETECTED");
}
[Fact]
public async Task Compare_WhenMissing_ReturnsMissingStatus()
{
// Arrange
var artifactBytes = Encoding.UTF8.GetBytes("{\"test\":\"data\"}");
var baseline = DeterminismBaselineStore.CreateBaseline(artifactBytes, "1.0.0");
// Act
var result = await _store.CompareAsync("sbom", "nonexistent", baseline.CanonicalHash);
// Assert
result.Status.Should().Be(BaselineStatus.Missing);
result.CurrentHash.Should().Be(baseline.CanonicalHash);
result.BaselineHash.Should().BeNull();
result.Message.Should().Contain("No baseline found");
}
#endregion
#region ListBaselines Tests
[Fact]
public async Task ListBaselines_WhenEmpty_ReturnsEmptyList()
{
// Act
var baselines = await _store.ListBaselinesAsync();
// Assert
baselines.Should().BeEmpty();
}
[Fact]
public async Task ListBaselines_ReturnsAllStoredBaselines()
{
// Arrange
await _store.StoreBaselineAsync("sbom", "artifact1",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("1"), "1.0.0"));
await _store.StoreBaselineAsync("sbom", "artifact2",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("2"), "1.0.0"));
await _store.StoreBaselineAsync("vex", "document1",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("3"), "1.0.0"));
// Act
var baselines = await _store.ListBaselinesAsync();
// Assert
baselines.Should().HaveCount(3);
baselines.Should().Contain(e => e.ArtifactType == "sbom" && e.ArtifactName == "artifact1");
baselines.Should().Contain(e => e.ArtifactType == "sbom" && e.ArtifactName == "artifact2");
baselines.Should().Contain(e => e.ArtifactType == "vex" && e.ArtifactName == "document1");
}
[Fact]
public async Task ListBaselines_ReturnsOrderedResults()
{
// Arrange
await _store.StoreBaselineAsync("vex", "z-document",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("1"), "1.0.0"));
await _store.StoreBaselineAsync("sbom", "a-artifact",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("2"), "1.0.0"));
await _store.StoreBaselineAsync("sbom", "b-artifact",
DeterminismBaselineStore.CreateBaseline(Encoding.UTF8.GetBytes("3"), "1.0.0"));
// Act
var baselines = await _store.ListBaselinesAsync();
// Assert
baselines[0].ArtifactType.Should().Be("sbom");
baselines[0].ArtifactName.Should().Be("a-artifact");
baselines[1].ArtifactType.Should().Be("sbom");
baselines[1].ArtifactName.Should().Be("b-artifact");
baselines[2].ArtifactType.Should().Be("vex");
}
#endregion
#region CreateDefault Tests
[Fact]
public void CreateDefault_CreatesStoreWithCorrectPath()
{
// Act
var store = DeterminismBaselineStore.CreateDefault(_testDirectory);
// Assert
store.BaselineDirectory.Should().Be(Path.Combine(_testDirectory, "tests", "baselines", "determinism"));
}
#endregion
}

View File

@@ -0,0 +1,501 @@
using FluentAssertions;
using StellaOps.Canonical.Json;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Testing.Determinism.Tests;
public sealed class DeterminismManifestTests
{
[Fact]
public void ToCanonicalBytes_WithValidManifest_ProducesDeterministicOutput()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var bytes1 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
var bytes2 = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Assert
bytes1.Should().Equal(bytes2, "Same manifest should produce identical canonical bytes");
}
[Fact]
public void ToCanonicalString_WithValidManifest_ProducesDeterministicString()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var json1 = DeterminismManifestWriter.ToCanonicalString(manifest);
var json2 = DeterminismManifestWriter.ToCanonicalString(manifest);
// Assert
json1.Should().Be(json2, "Same manifest should produce identical canonical JSON string");
json1.Should().NotContain("\n", "Canonical JSON should have no newlines");
json1.Should().NotContain(" ", "Canonical JSON should have no indentation");
}
[Fact]
public void WriteToFile_AndReadFromFile_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
DeterminismManifestWriter.WriteToFile(manifest, tempFile);
// Act - Read
var readManifest = DeterminismManifestReader.ReadFromFile(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public async Task WriteToFileAsync_AndReadFromFileAsync_RoundTripsSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var tempFile = Path.GetTempFileName();
try
{
// Act - Write
await DeterminismManifestWriter.WriteToFileAsync(manifest, tempFile);
// Act - Read
var readManifest = await DeterminismManifestReader.ReadFromFileAsync(tempFile);
// Assert
readManifest.Should().BeEquivalentTo(manifest);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
}
}
}
[Fact]
public void FromBytes_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var bytes = DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Act
var deserialized = DeterminismManifestReader.FromBytes(bytes);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void FromString_WithValidJson_DeserializesSuccessfully()
{
// Arrange
var manifest = CreateSampleManifest();
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
// Act
var deserialized = DeterminismManifestReader.FromString(json);
// Assert
deserialized.Should().BeEquivalentTo(manifest);
}
[Fact]
public void ToCanonicalBytes_WithInvalidSchemaVersion_ThrowsInvalidOperationException()
{
// Arrange
var manifest = CreateSampleManifest() with { SchemaVersion = "2.0" };
// Act
Action act = () => DeterminismManifestWriter.ToCanonicalBytes(manifest);
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*schema version*2.0*");
}
[Fact]
public void TryReadFromFileAsync_WithNonExistentFile_ReturnsNull()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
var result = DeterminismManifestReader.TryReadFromFileAsync(nonExistentPath).GetAwaiter().GetResult();
// Assert
result.Should().BeNull();
}
[Fact]
public void ReadFromFile_WithNonExistentFile_ThrowsFileNotFoundException()
{
// Arrange
var nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
// Act
Action act = () => DeterminismManifestReader.ReadFromFile(nonExistentPath);
// Assert
act.Should().Throw<FileNotFoundException>();
}
[Fact]
public void ComputeCanonicalHash_ProducesDeterministicHash()
{
// Arrange
var manifest = CreateSampleManifest();
// Act
var hash1 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
var hash2 = DeterminismManifestWriter.ComputeCanonicalHash(manifest);
// Assert
hash1.Should().Be(hash2, "Same manifest should produce same hash");
hash1.Should().MatchRegex("^[0-9a-f]{64}$", "Hash should be 64-character hex string");
}
[Fact]
public void CreateManifest_WithValidInputs_CreatesManifestWithCorrectHash()
{
// Arrange
var artifactBytes = "Test artifact content"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Scanner", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
manifest.GeneratedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// Verify hash is correct
var expectedHash = CanonJson.Sha256Hex(artifactBytes);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifestForJsonArtifact_WithValidInputs_CreatesManifestWithCanonicalHash()
{
// Arrange
var artifact = new { Name = "test", Value = 123, Items = new[] { "a", "b", "c" } };
var artifactInfo = new ArtifactInfo
{
Type = "verdict",
Name = "test-verdict",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Policy.Engine", Version = "1.0.0" }
}
};
// Act
var manifest = DeterminismManifestWriter.CreateManifestForJsonArtifact(
artifact,
artifactInfo,
toolchain);
// Assert
manifest.SchemaVersion.Should().Be("1.0");
manifest.Artifact.Should().Be(artifactInfo);
manifest.Toolchain.Should().Be(toolchain);
manifest.CanonicalHash.Algorithm.Should().Be("SHA-256");
manifest.CanonicalHash.Encoding.Should().Be("hex");
manifest.CanonicalHash.Value.Should().MatchRegex("^[0-9a-f]{64}$");
// Verify hash is correct (should use canonical JSON)
var expectedHash = CanonJson.Hash(artifact);
manifest.CanonicalHash.Value.Should().Be(expectedHash);
}
[Fact]
public void CreateManifest_WithInputStamps_IncludesInputStamps()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var inputs = new InputStamps
{
FeedSnapshotHash = "abc123",
PolicyManifestHash = "def456",
SourceCodeHash = "789abc"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
inputs: inputs);
// Assert
manifest.Inputs.Should().NotBeNull();
manifest.Inputs!.FeedSnapshotHash.Should().Be("abc123");
manifest.Inputs.PolicyManifestHash.Should().Be("def456");
manifest.Inputs.SourceCodeHash.Should().Be("789abc");
}
[Fact]
public void CreateManifest_WithReproducibilityMetadata_IncludesMetadata()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 42,
ClockFixed = true,
OrderingGuarantee = "sorted",
NormalizationRules = new[] { "UTF-8", "LF line endings", "sorted JSON keys" }
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
reproducibility: reproducibility);
// Assert
manifest.Reproducibility.Should().NotBeNull();
manifest.Reproducibility!.DeterministicSeed.Should().Be(42);
manifest.Reproducibility.ClockFixed.Should().BeTrue();
manifest.Reproducibility.OrderingGuarantee.Should().Be("sorted");
manifest.Reproducibility.NormalizationRules.Should().ContainInOrder("UTF-8", "LF line endings", "sorted JSON keys");
}
[Fact]
public void CreateManifest_WithVerificationInfo_IncludesVerification()
{
// Arrange
var artifactBytes = "Test artifact"u8.ToArray();
var artifactInfo = new ArtifactInfo
{
Type = "sbom",
Name = "test",
Version = "1.0.0"
};
var toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[] { new ComponentInfo { Name = "Scanner", Version = "1.0.0" } }
};
var verification = new VerificationInfo
{
Command = "dotnet run --project Scanner -- scan container alpine:3.18",
ExpectedHash = "abc123def456",
Baseline = "tests/baselines/sbom-alpine-3.18.determinism.json"
};
// Act
var manifest = DeterminismManifestWriter.CreateManifest(
artifactBytes,
artifactInfo,
toolchain,
verification: verification);
// Assert
manifest.Verification.Should().NotBeNull();
manifest.Verification!.Command.Should().Be("dotnet run --project Scanner -- scan container alpine:3.18");
manifest.Verification.ExpectedHash.Should().Be("abc123def456");
manifest.Verification.Baseline.Should().Be("tests/baselines/sbom-alpine-3.18.determinism.json");
}
[Fact]
public void ManifestSerialization_WithComplexMetadata_PreservesAllFields()
{
// Arrange
var manifest = CreateComplexManifest();
// Act
var json = DeterminismManifestWriter.ToCanonicalString(manifest);
var deserialized = DeterminismManifestReader.FromString(json);
// Assert - Use custom comparison to handle JsonElement values in metadata
deserialized.Should().BeEquivalentTo(manifest, options => options
.Excluding(m => m.Artifact.Metadata));
// Verify metadata separately (JSON deserialization converts values to JsonElement)
deserialized.Artifact.Metadata.Should().NotBeNull();
deserialized.Artifact.Metadata.Should().HaveCount(2);
deserialized.Artifact.Metadata.Should().ContainKey("predicateType");
deserialized.Artifact.Metadata.Should().ContainKey("subject");
}
private static DeterminismManifest CreateSampleManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "sbom",
Name = "test-sbom",
Version = "1.0.0",
Format = "SPDX 3.0.1"
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "abc123def456789012345678901234567890123456789012345678901234",
Encoding = "hex"
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo
{
Name = "StellaOps.Scanner",
Version = "1.0.0",
Hash = "def456abc789012345678901234567890123456789012345678901234567"
}
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 17, 45, 0, TimeSpan.Zero)
};
}
private static DeterminismManifest CreateComplexManifest()
{
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = new ArtifactInfo
{
Type = "evidence-bundle",
Name = "test-bundle",
Version = "2.0.0",
Format = "DSSE Envelope",
Metadata = new Dictionary<string, object?>
{
["predicateType"] = "https://in-toto.io/attestation/v1",
["subject"] = "pkg:docker/alpine@3.18"
}
},
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
Encoding = "hex"
},
Inputs = new InputStamps
{
FeedSnapshotHash = "feed123abc",
PolicyManifestHash = "policy456def",
SourceCodeHash = "git789ghi",
VexDocumentHashes = new[] { "vex123", "vex456" },
Custom = new Dictionary<string, string>
{
["baselineVersion"] = "1.0.0",
["environment"] = "production"
}
},
Toolchain = new ToolchainInfo
{
Platform = ".NET 10.0",
Components = new[]
{
new ComponentInfo { Name = "StellaOps.Attestor", Version = "2.0.0", Hash = "hash123" },
new ComponentInfo { Name = "StellaOps.Signer", Version = "2.1.0" }
},
Compiler = new CompilerInfo
{
Name = "Roslyn",
Version = "4.8.0"
}
},
GeneratedAt = new DateTimeOffset(2025, 12, 23, 18, 0, 0, TimeSpan.Zero),
Reproducibility = new ReproducibilityMetadata
{
DeterministicSeed = 12345,
ClockFixed = true,
OrderingGuarantee = "stable",
NormalizationRules = new[] { "UTF-8", "LF line endings", "no trailing whitespace" }
},
Verification = new VerificationInfo
{
Command = "dotnet test --verify-determinism",
ExpectedHash = "abc123def456",
Baseline = "baselines/test-bundle.json"
},
Signatures = new[]
{
new SignatureInfo
{
Algorithm = "ES256",
KeyId = "signing-key-1",
Signature = "base64encodedSig==",
Timestamp = new DateTimeOffset(2025, 12, 23, 18, 0, 30, TimeSpan.Zero)
}
}
};
}
}

View File

@@ -0,0 +1,338 @@
// -----------------------------------------------------------------------------
// DeterminismSummaryTests.cs
// Sprint: SPRINT_5100_0007_0003 - Epic B (Determinism Gate)
// Task: T9 - Determinism Baseline Storage
// Description: Tests for determinism summary and CI artifact generation
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Testing.Determinism;
using Xunit;
namespace StellaOps.Testing.Determinism.Tests;
public sealed class DeterminismSummaryTests : IDisposable
{
private readonly string _testDirectory;
public DeterminismSummaryTests()
{
_testDirectory = Path.Combine(Path.GetTempPath(), $"summary-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDirectory);
}
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
#region DeterminismSummaryBuilder Tests
[Fact]
public void Build_WithNoResults_ReturnsPassStatus()
{
// Act
var summary = new DeterminismSummaryBuilder().Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Pass);
summary.Statistics.Total.Should().Be(0);
summary.Statistics.Matched.Should().Be(0);
summary.Statistics.Drifted.Should().Be(0);
summary.Statistics.Missing.Should().Be(0);
}
[Fact]
public void Build_WithAllMatching_ReturnsPassStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMatchResult("sbom", "artifact2"))
.AddResult(CreateMatchResult("vex", "document1"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Pass);
summary.Statistics.Total.Should().Be(3);
summary.Statistics.Matched.Should().Be(3);
summary.Statistics.Drifted.Should().Be(0);
summary.Statistics.Missing.Should().Be(0);
summary.Drift.Should().BeNull();
summary.Missing.Should().BeNull();
}
[Fact]
public void Build_WithDrift_ReturnsFailStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateDriftResult("sbom", "artifact2"))
.AddResult(CreateMatchResult("vex", "document1"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Fail);
summary.Statistics.Total.Should().Be(3);
summary.Statistics.Matched.Should().Be(2);
summary.Statistics.Drifted.Should().Be(1);
summary.Statistics.Missing.Should().Be(0);
summary.Drift.Should().HaveCount(1);
summary.Drift![0].ArtifactName.Should().Be("artifact2");
}
[Fact]
public void Build_WithMissing_ReturnsWarningStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMissingResult("sbom", "artifact2"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Warning);
summary.Statistics.Total.Should().Be(2);
summary.Statistics.Matched.Should().Be(1);
summary.Statistics.Missing.Should().Be(1);
summary.Missing.Should().HaveCount(1);
summary.Missing![0].ArtifactName.Should().Be("artifact2");
}
[Fact]
public void Build_WithMissing_AndFailOnMissing_ReturnsFailStatus()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.FailOnMissingBaselines()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMissingResult("sbom", "artifact2"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Fail);
}
[Fact]
public void Build_DriftTakesPrecedenceOverMissing()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.AddResult(CreateDriftResult("sbom", "artifact1"))
.AddResult(CreateMissingResult("sbom", "artifact2"));
// Act
var summary = builder.Build();
// Assert
summary.Status.Should().Be(DeterminismCheckStatus.Fail);
summary.Statistics.Drifted.Should().Be(1);
summary.Statistics.Missing.Should().Be(1);
}
[Fact]
public void Build_WithSourceRef_IncludesSourceRef()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.WithSourceRef("abc123def456")
.AddResult(CreateMatchResult("sbom", "artifact"));
// Act
var summary = builder.Build();
// Assert
summary.SourceRef.Should().Be("abc123def456");
}
[Fact]
public void Build_WithCiRunId_IncludesCiRunId()
{
// Arrange
var builder = new DeterminismSummaryBuilder()
.WithCiRunId("run-12345")
.AddResult(CreateMatchResult("sbom", "artifact"));
// Act
var summary = builder.Build();
// Assert
summary.CiRunId.Should().Be("run-12345");
}
[Fact]
public void Build_SetsGeneratedAtToUtcNow()
{
// Arrange
var before = DateTimeOffset.UtcNow;
var builder = new DeterminismSummaryBuilder();
// Act
var summary = builder.Build();
var after = DateTimeOffset.UtcNow;
// Assert
summary.GeneratedAt.Should().BeOnOrAfter(before);
summary.GeneratedAt.Should().BeOnOrBefore(after);
}
#endregion
#region DeterminismSummaryWriter Tests
[Fact]
public async Task WriteToFileAsync_CreatesValidJsonFile()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.WithSourceRef("test-sha")
.AddResult(CreateMatchResult("sbom", "test-artifact"))
.Build();
var filePath = Path.Combine(_testDirectory, "determinism.json");
// Act
await DeterminismSummaryWriter.WriteToFileAsync(summary, filePath);
// Assert
File.Exists(filePath).Should().BeTrue();
var content = await File.ReadAllTextAsync(filePath);
content.Should().Contain("\"schemaVersion\": \"1.0\"");
content.Should().Contain("\"sourceRef\": \"test-sha\"");
content.Should().Contain("\"status\": \"pass\"");
}
[Fact]
public async Task WriteToFileAsync_CreatesDirectoryIfNeeded()
{
// Arrange
var summary = new DeterminismSummaryBuilder().Build();
var filePath = Path.Combine(_testDirectory, "nested", "dir", "determinism.json");
// Act
await DeterminismSummaryWriter.WriteToFileAsync(summary, filePath);
// Assert
File.Exists(filePath).Should().BeTrue();
}
[Fact]
public void ToJson_ReturnsValidJson()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact"))
.AddResult(CreateDriftResult("vex", "document"))
.Build();
// Act
var json = DeterminismSummaryWriter.ToJson(summary);
// Assert
json.Should().Contain("\"status\": \"fail\"");
json.Should().Contain("\"total\": 2");
json.Should().Contain("\"matched\": 1");
json.Should().Contain("\"drifted\": 1");
}
[Fact]
public async Task WriteHashFilesAsync_CreatesHashFilesForAllArtifacts()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.AddResult(CreateMatchResult("sbom", "artifact1"))
.AddResult(CreateMatchResult("vex", "document1"))
.Build();
var hashDir = Path.Combine(_testDirectory, "hashes");
// Act
await DeterminismSummaryWriter.WriteHashFilesAsync(summary, hashDir);
// Assert
File.Exists(Path.Combine(hashDir, "sbom_artifact1.sha256.txt")).Should().BeTrue();
File.Exists(Path.Combine(hashDir, "vex_document1.sha256.txt")).Should().BeTrue();
}
[Fact]
public async Task WriteHashFilesAsync_HashFileContainsCorrectFormat()
{
// Arrange
var summary = new DeterminismSummaryBuilder()
.AddResult(new BaselineComparisonResult
{
ArtifactType = "sbom",
ArtifactName = "test",
Status = BaselineStatus.Match,
CurrentHash = "abc123def456",
Message = "Test"
})
.Build();
var hashDir = Path.Combine(_testDirectory, "hashes");
// Act
await DeterminismSummaryWriter.WriteHashFilesAsync(summary, hashDir);
// Assert
var content = await File.ReadAllTextAsync(Path.Combine(hashDir, "sbom_test.sha256.txt"));
content.Should().Be("abc123def456 sbom/test");
}
#endregion
#region Helper Methods
private static BaselineComparisonResult CreateMatchResult(string artifactType, string artifactName)
{
var hash = $"hash-{artifactType}-{artifactName}";
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Match,
CurrentHash = hash,
BaselineHash = hash,
Message = $"Artifact {artifactType}/{artifactName} matches baseline."
};
}
private static BaselineComparisonResult CreateDriftResult(string artifactType, string artifactName)
{
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Drift,
CurrentHash = $"new-hash-{artifactType}-{artifactName}",
BaselineHash = $"old-hash-{artifactType}-{artifactName}",
Message = $"DRIFT DETECTED: {artifactType}/{artifactName}"
};
}
private static BaselineComparisonResult CreateMissingResult(string artifactType, string artifactName)
{
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Missing,
CurrentHash = $"hash-{artifactType}-{artifactName}",
Message = $"No baseline found for {artifactType}/{artifactName}"
};
}
#endregion
}

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.Testing.Determinism\StellaOps.Testing.Determinism.csproj" />
</ItemGroup>
</Project>