using System; using System.Collections.Generic; using System.Text; using System.Text.Json; using FluentAssertions; using StellaOps.Signer.Core; using Xunit; namespace StellaOps.Signer.Tests.Signing; public sealed class SignerStatementBuilderTests { [Fact] public void BuildStatementPayload_CreatesValidStatement() { // Arrange var request = CreateSigningRequest(); // Act var payload = SignerStatementBuilder.BuildStatementPayload(request); // Assert payload.Should().NotBeNullOrEmpty(); var json = Encoding.UTF8.GetString(payload); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; root.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v0.1"); root.GetProperty("predicateType").GetString().Should().Be("https://slsa.dev/provenance/v0.2"); root.GetProperty("subject").GetArrayLength().Should().Be(1); } [Fact] public void BuildStatementPayload_UsesDeterministicSerialization() { // Arrange var request = CreateSigningRequest(); // Act var payload1 = SignerStatementBuilder.BuildStatementPayload(request); var payload2 = SignerStatementBuilder.BuildStatementPayload(request); // Assert - Same input should produce identical output payload1.Should().BeEquivalentTo(payload2); } [Fact] public void BuildStatementPayload_SortsDigestKeys() { // Arrange - Use unsorted digest keys var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}"""); var request = new SigningRequest( Subjects: [ new SigningSubject("artifact.tar.gz", new Dictionary { ["SHA512"] = "xyz789", ["sha256"] = "abc123", ["MD5"] = "def456" }) ], PredicateType: PredicateTypes.SlsaProvenanceV02, Predicate: predicate, ScannerImageDigest: "sha256:scanner", ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"), Options: new SigningOptions(SigningMode.Keyless, null, "bundle")); // Act var payload = SignerStatementBuilder.BuildStatementPayload(request); var json = Encoding.UTF8.GetString(payload); // Assert - Digest keys should be lowercase and sorted alphabetically json.Should().Contain("\"md5\""); json.Should().Contain("\"sha256\""); json.Should().Contain("\"sha512\""); // Verify order: md5 < sha256 < sha512 var md5Index = json.IndexOf("\"md5\"", StringComparison.Ordinal); var sha256Index = json.IndexOf("\"sha256\"", StringComparison.Ordinal); var sha512Index = json.IndexOf("\"sha512\"", StringComparison.Ordinal); md5Index.Should().BeLessThan(sha256Index); sha256Index.Should().BeLessThan(sha512Index); } [Fact] public void BuildStatementPayload_WithExplicitStatementType_UsesProvided() { // Arrange var request = CreateSigningRequest(); // Act var payload = SignerStatementBuilder.BuildStatementPayload(request, "https://in-toto.io/Statement/v1"); var json = Encoding.UTF8.GetString(payload); // Assert using var doc = JsonDocument.Parse(json); doc.RootElement.GetProperty("_type").GetString().Should().Be("https://in-toto.io/Statement/v1"); } [Fact] public void BuildStatement_ReturnsInTotoStatement() { // Arrange var request = CreateSigningRequest(); // Act var statement = SignerStatementBuilder.BuildStatement(request); // Assert statement.Should().NotBeNull(); statement.Type.Should().Be("https://in-toto.io/Statement/v0.1"); statement.PredicateType.Should().Be(PredicateTypes.SlsaProvenanceV02); statement.Subject.Should().HaveCount(1); statement.Subject[0].Name.Should().Be("artifact.tar.gz"); statement.Predicate.ValueKind.Should().Be(JsonValueKind.Object); } [Fact] public void BuildStatementPayload_ThrowsArgumentNullException_WhenRequestIsNull() { // Act var act = () => SignerStatementBuilder.BuildStatementPayload(null!); // Assert act.Should().Throw() .WithParameterName("request"); } [Fact] public void BuildStatementPayload_WithStatementType_ThrowsWhenTypeIsEmpty() { // Arrange var request = CreateSigningRequest(); // Act var act = () => SignerStatementBuilder.BuildStatementPayload(request, ""); // Assert act.Should().Throw() .WithParameterName("statementType"); } [Theory] [InlineData(PredicateTypes.StellaOpsPromotion, true)] [InlineData(PredicateTypes.StellaOpsSbom, true)] [InlineData(PredicateTypes.StellaOpsVex, true)] [InlineData(PredicateTypes.StellaOpsReplay, true)] [InlineData(PredicateTypes.StellaOpsPolicy, true)] [InlineData(PredicateTypes.StellaOpsEvidence, true)] [InlineData(PredicateTypes.StellaOpsVexDecision, true)] [InlineData(PredicateTypes.StellaOpsGraph, true)] [InlineData(PredicateTypes.SlsaProvenanceV02, true)] [InlineData(PredicateTypes.SlsaProvenanceV1, true)] [InlineData(PredicateTypes.CycloneDxSbom, true)] [InlineData(PredicateTypes.SpdxSbom, true)] [InlineData(PredicateTypes.OpenVex, true)] [InlineData("custom/predicate@v1", false)] [InlineData("", false)] [InlineData(null, false)] public void IsWellKnownPredicateType_ReturnsExpected(string? predicateType, bool expected) { // Act var result = SignerStatementBuilder.IsWellKnownPredicateType(predicateType!); // Assert result.Should().Be(expected); } [Theory] [InlineData(PredicateTypes.SlsaProvenanceV1, "https://in-toto.io/Statement/v1")] [InlineData(PredicateTypes.StellaOpsPromotion, "https://in-toto.io/Statement/v1")] [InlineData(PredicateTypes.StellaOpsSbom, "https://in-toto.io/Statement/v1")] [InlineData(PredicateTypes.SlsaProvenanceV02, "https://in-toto.io/Statement/v0.1")] [InlineData(PredicateTypes.CycloneDxSbom, "https://in-toto.io/Statement/v0.1")] public void GetRecommendedStatementType_ReturnsCorrectVersion(string predicateType, string expectedStatementType) { // Act var result = SignerStatementBuilder.GetRecommendedStatementType(predicateType); // Assert result.Should().Be(expectedStatementType); } [Fact] public void PredicateTypes_IsStellaOpsType_IdentifiesStellaOpsTypes() { // Assert PredicateTypes.IsStellaOpsType("stella.ops/promotion@v1").Should().BeTrue(); PredicateTypes.IsStellaOpsType("stella.ops/custom@v2").Should().BeTrue(); PredicateTypes.IsStellaOpsType("https://slsa.dev/provenance/v1").Should().BeFalse(); PredicateTypes.IsStellaOpsType(null!).Should().BeFalse(); } [Fact] public void PredicateTypes_IsSlsaProvenance_IdentifiesSlsaTypes() { // Assert PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v0.2").Should().BeTrue(); PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v1").Should().BeTrue(); PredicateTypes.IsSlsaProvenance("https://slsa.dev/provenance/v2").Should().BeTrue(); PredicateTypes.IsSlsaProvenance("stella.ops/promotion@v1").Should().BeFalse(); PredicateTypes.IsSlsaProvenance(null!).Should().BeFalse(); } [Fact] public void PredicateTypes_IsVexRelatedType_IdentifiesVexTypes() { // Assert PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVex).Should().BeTrue(); PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsVexDecision).Should().BeTrue(); PredicateTypes.IsVexRelatedType(PredicateTypes.OpenVex).Should().BeTrue(); PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse(); PredicateTypes.IsVexRelatedType(PredicateTypes.StellaOpsGraph).Should().BeFalse(); PredicateTypes.IsVexRelatedType(null!).Should().BeFalse(); } [Fact] public void PredicateTypes_IsReachabilityRelatedType_IdentifiesReachabilityTypes() { // Assert PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsGraph).Should().BeTrue(); PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsReplay).Should().BeTrue(); PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsEvidence).Should().BeTrue(); PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsVex).Should().BeFalse(); PredicateTypes.IsReachabilityRelatedType(PredicateTypes.StellaOpsSbom).Should().BeFalse(); PredicateTypes.IsReachabilityRelatedType(null!).Should().BeFalse(); } [Fact] public void PredicateTypes_GetAllowedPredicateTypes_ReturnsAllKnownTypes() { // Act var allowedTypes = PredicateTypes.GetAllowedPredicateTypes(); // Assert allowedTypes.Should().Contain(PredicateTypes.StellaOpsPromotion); allowedTypes.Should().Contain(PredicateTypes.StellaOpsSbom); allowedTypes.Should().Contain(PredicateTypes.StellaOpsVex); allowedTypes.Should().Contain(PredicateTypes.StellaOpsReplay); allowedTypes.Should().Contain(PredicateTypes.StellaOpsPolicy); allowedTypes.Should().Contain(PredicateTypes.StellaOpsEvidence); allowedTypes.Should().Contain(PredicateTypes.StellaOpsVexDecision); allowedTypes.Should().Contain(PredicateTypes.StellaOpsGraph); allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV02); allowedTypes.Should().Contain(PredicateTypes.SlsaProvenanceV1); allowedTypes.Should().Contain(PredicateTypes.CycloneDxSbom); allowedTypes.Should().Contain(PredicateTypes.SpdxSbom); allowedTypes.Should().Contain(PredicateTypes.OpenVex); allowedTypes.Should().HaveCount(13); } [Theory] [InlineData(PredicateTypes.StellaOpsVexDecision, true)] [InlineData(PredicateTypes.StellaOpsGraph, true)] [InlineData(PredicateTypes.StellaOpsPromotion, true)] [InlineData("custom/predicate@v1", false)] [InlineData("", false)] public void PredicateTypes_IsAllowedPredicateType_ReturnsExpected(string predicateType, bool expected) { // Act var result = PredicateTypes.IsAllowedPredicateType(predicateType); // Assert result.Should().Be(expected); } [Fact] public void BuildStatementPayload_HandlesMultipleSubjects() { // Arrange var predicate = JsonDocument.Parse("""{"builder": {"id": "test"}}"""); var request = new SigningRequest( Subjects: [ new SigningSubject("artifact1.tar.gz", new Dictionary { ["sha256"] = "hash1" }), new SigningSubject("artifact2.tar.gz", new Dictionary { ["sha256"] = "hash2" }), new SigningSubject("artifact3.tar.gz", new Dictionary { ["sha256"] = "hash3" }) ], PredicateType: PredicateTypes.SlsaProvenanceV02, Predicate: predicate, ScannerImageDigest: "sha256:scanner", ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"), Options: new SigningOptions(SigningMode.Keyless, null, "bundle")); // Act var payload = SignerStatementBuilder.BuildStatementPayload(request); var json = Encoding.UTF8.GetString(payload); // Assert using var doc = JsonDocument.Parse(json); doc.RootElement.GetProperty("subject").GetArrayLength().Should().Be(3); } [Fact] public void BuildStatementPayload_PreservesPredicateContent() { // Arrange var predicateContent = """ { "builder": { "id": "https://github.com/actions" }, "buildType": "https://github.com/Attestations/GitHubActionsWorkflow@v1", "invocation": { "configSource": { "uri": "git+https://github.com/test/repo@refs/heads/main" } } } """; var predicate = JsonDocument.Parse(predicateContent); var request = new SigningRequest( Subjects: [ new SigningSubject("artifact.tar.gz", new Dictionary { ["sha256"] = "abc123" }) ], PredicateType: PredicateTypes.SlsaProvenanceV02, Predicate: predicate, ScannerImageDigest: "sha256:scanner", ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"), Options: new SigningOptions(SigningMode.Keyless, null, "bundle")); // Act var payload = SignerStatementBuilder.BuildStatementPayload(request); var json = Encoding.UTF8.GetString(payload); // Assert using var doc = JsonDocument.Parse(json); var resultPredicate = doc.RootElement.GetProperty("predicate"); resultPredicate.GetProperty("builder").GetProperty("id").GetString() .Should().Be("https://github.com/actions"); resultPredicate.GetProperty("buildType").GetString() .Should().Be("https://github.com/Attestations/GitHubActionsWorkflow@v1"); } private static SigningRequest CreateSigningRequest() { var predicate = JsonDocument.Parse("""{"builder": {"id": "test-builder"}, "invocation": {}}"""); return new SigningRequest( Subjects: [ new SigningSubject("artifact.tar.gz", new Dictionary { ["sha256"] = "abc123def456" }) ], PredicateType: PredicateTypes.SlsaProvenanceV02, Predicate: predicate, ScannerImageDigest: "sha256:scanner123", ProofOfEntitlement: new ProofOfEntitlement(SignerPoEFormat.Jwt, "token"), Options: new SigningOptions(SigningMode.Keyless, 3600, "bundle")); } }