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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user