// ----------------------------------------------------------------------------- // DeltaSigEndToEndTests.cs // Sprint: SPRINT_20260117_003_BINDEX_delta_sig_predicate // Task: DSP-009 - Integration tests for delta-sig predicate E2E flow // Description: End-to-end tests for delta-sig generation, signing, submission, and verification // ----------------------------------------------------------------------------- using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using StellaOps.TestKit; using Xunit; namespace StellaOps.BinaryIndex.DeltaSig.Tests.Integration; [Trait("Category", TestCategories.Integration)] public sealed class DeltaSigEndToEndTests { private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 16, 12, 0, 0, TimeSpan.Zero); private readonly FakeTimeProvider _timeProvider; private readonly MockRekorClient _rekorClient; private readonly MockSigningService _signingService; public DeltaSigEndToEndTests() { _timeProvider = new FakeTimeProvider(FixedTimestamp); _rekorClient = new MockRekorClient(); _signingService = new MockSigningService(); } [Fact] public async Task FullFlow_GenerateSignSubmitVerify_Succeeds() { // Arrange var service = CreateService(); var beforeBinary = CreateTestBinary("libtest-1.0.so", 10); var afterBinary = CreateTestBinary("libtest-1.1.so", 12); // 2 new functions // Act - Step 1: Generate delta-sig predicate var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); // Assert - predicate created correctly predicate.Should().NotBeNull(); predicate.PredicateType.Should().Contain("delta-sig"); predicate.Summary.FunctionsAdded.Should().Be(2); predicate.Summary.FunctionsModified.Should().Be(0); // Act - Step 2: Sign the predicate var envelope = await service.SignAsync(predicate, CancellationToken.None); // Assert - envelope created envelope.Should().NotBeNull(); envelope.PayloadType.Should().Be("application/vnd.in-toto+json"); envelope.Signatures.Should().NotBeEmpty(); // Act - Step 3: Submit to Rekor var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None); // Assert - submission successful submission.Success.Should().BeTrue(); submission.EntryId.Should().NotBeNullOrEmpty(); submission.LogIndex.Should().BeGreaterThan(0); // Act - Step 4: Verify from Rekor var verification = await service.VerifyFromRekorAsync(submission.EntryId!, CancellationToken.None); // Assert - verification successful verification.IsValid.Should().BeTrue(); verification.PredicateType.Should().Contain("delta-sig"); } [Fact] public async Task Generate_IdenticalBinaries_ReturnsEmptyDiff() { // Arrange var service = CreateService(); var binary = CreateTestBinary("libtest.so", 5); // Act var predicate = await service.GenerateAsync(binary, binary, CancellationToken.None); // Assert predicate.Summary.FunctionsAdded.Should().Be(0); predicate.Summary.FunctionsModified.Should().Be(0); predicate.Summary.FunctionsRemoved.Should().Be(0); predicate.Diff.Should().BeEmpty(); } [Fact] public async Task Generate_RemovedFunctions_TracksRemovals() { // Arrange var service = CreateService(); var beforeBinary = CreateTestBinary("libtest-1.0.so", 10); var afterBinary = CreateTestBinary("libtest-1.1.so", 7); // 3 removed // Act var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); // Assert predicate.Summary.FunctionsRemoved.Should().Be(3); } [Fact] public async Task Generate_ModifiedFunctions_TracksModifications() { // Arrange var service = CreateService(); var beforeBinary = CreateTestBinaryWithModifications("libtest-1.0.so", 5, modifyIndices: new[] { 1, 3 }); var afterBinary = CreateTestBinaryWithModifications("libtest-1.1.so", 5, modifyIndices: new[] { 1, 3 }, modified: true); // Act var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); // Assert predicate.Summary.FunctionsModified.Should().Be(2); } [Fact] public async Task Verify_TamperedPredicate_FailsVerification() { // Arrange var service = CreateService(); var beforeBinary = CreateTestBinary("libtest-1.0.so", 5); var afterBinary = CreateTestBinary("libtest-1.1.so", 6); var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); var envelope = await service.SignAsync(predicate, CancellationToken.None); // Tamper with the envelope var tamperedEnvelope = envelope with { Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes("tampered content")) }; // Act var verification = await service.VerifyEnvelopeAsync(tamperedEnvelope, CancellationToken.None); // Assert verification.IsValid.Should().BeFalse(); verification.FailureReason.Should().Contain("signature"); } [Fact] public async Task PolicyGate_WithinLimits_Passes() { // Arrange var service = CreateService(); var beforeBinary = CreateTestBinary("libtest-1.0.so", 10); var afterBinary = CreateTestBinary("libtest-1.1.so", 12); // 2 added var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); var policyOptions = new DeltaScopePolicyOptions { MaxAddedFunctions = 5, MaxRemovedFunctions = 5, MaxModifiedFunctions = 10, MaxBytesChanged = 10000 }; // Act var gateResult = await service.EvaluatePolicyAsync(predicate, policyOptions, CancellationToken.None); // Assert gateResult.Passed.Should().BeTrue(); gateResult.Violations.Should().BeEmpty(); } [Fact] public async Task PolicyGate_ExceedsLimits_FailsWithViolations() { // Arrange var service = CreateService(); var beforeBinary = CreateTestBinary("libtest-1.0.so", 10); var afterBinary = CreateTestBinary("libtest-1.1.so", 20); // 10 added var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); var policyOptions = new DeltaScopePolicyOptions { MaxAddedFunctions = 5, // Exceeded MaxRemovedFunctions = 5, MaxModifiedFunctions = 10, MaxBytesChanged = 10000 }; // Act var gateResult = await service.EvaluatePolicyAsync(predicate, policyOptions, CancellationToken.None); // Assert gateResult.Passed.Should().BeFalse(); gateResult.Violations.Should().ContainSingle(); gateResult.Violations.First().Should().Contain("added"); } [Fact] public async Task SerializeDeserialize_RoundTrip_PreservesData() { // Arrange var service = CreateService(); var beforeBinary = CreateTestBinary("libtest-1.0.so", 5); var afterBinary = CreateTestBinary("libtest-1.1.so", 7); var originalPredicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); // Act var json = service.SerializePredicate(originalPredicate); var deserialized = service.DeserializePredicate(json); // Assert deserialized.PredicateType.Should().Be(originalPredicate.PredicateType); deserialized.Summary.FunctionsAdded.Should().Be(originalPredicate.Summary.FunctionsAdded); deserialized.Subject.Should().HaveCount(originalPredicate.Subject.Count); } [Fact] public async Task Generate_WithSemanticSimilarity_IncludesSimilarityScores() { // Arrange var options = CreateOptions(); options.Value.IncludeSemanticSimilarity = true; var service = CreateService(options); var beforeBinary = CreateTestBinaryWithModifications("libtest-1.0.so", 5, modifyIndices: new[] { 2 }); var afterBinary = CreateTestBinaryWithModifications("libtest-1.1.so", 5, modifyIndices: new[] { 2 }, modified: true); // Act var predicate = await service.GenerateAsync(beforeBinary, afterBinary, CancellationToken.None); // Assert var modifiedFunc = predicate.Diff.FirstOrDefault(d => d.ChangeType == "modified"); modifiedFunc.Should().NotBeNull(); modifiedFunc!.SemanticSimilarity.Should().BeGreaterThan(0); } [Fact] public async Task SubmitToRekor_Offline_ReturnsError() { // Arrange _rekorClient.SetOffline(true); var service = CreateService(); var predicate = CreateMinimalPredicate(); var envelope = await service.SignAsync(predicate, CancellationToken.None); // Act var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None); // Assert submission.Success.Should().BeFalse(); submission.Error.Should().Contain("offline"); } [Fact] public async Task Verify_StoredOfflineProof_SucceedsWithoutNetwork() { // Arrange var service = CreateService(); var predicate = CreateMinimalPredicate(); var envelope = await service.SignAsync(predicate, CancellationToken.None); // Submit and get proof var submission = await service.SubmitToRekorAsync(envelope, CancellationToken.None); var proof = await service.GetInclusionProofAsync(submission.EntryId!, CancellationToken.None); // Go offline _rekorClient.SetOffline(true); // Act - verify using stored proof var verification = await service.VerifyWithStoredProofAsync(envelope, proof, CancellationToken.None); // Assert verification.IsValid.Should().BeTrue(); verification.VerificationMode.Should().Be("offline"); } // Helper methods private IDeltaSigService CreateService(IOptions? options = null) { return new DeltaSigService( options ?? CreateOptions(), _rekorClient, _signingService, _timeProvider, NullLogger.Instance); } private static IOptions CreateOptions() { return Options.Create(new DeltaSigServiceOptions { PredicateType = "https://stellaops.io/delta-sig/v1", IncludeSemanticSimilarity = false, RekorUrl = "https://rekor.sigstore.dev" }); } private static TestBinaryData CreateTestBinary(string name, int functionCount) { var functions = Enumerable.Range(0, functionCount) .Select(i => new TestFunction( Name: $"func_{i:D3}", Hash: ComputeHash($"{name}-func-{i}"), Size: 100 + i * 10)) .ToImmutableArray(); return new TestBinaryData( Name: name, Digest: $"sha256:{ComputeHash(name)}", Functions: functions); } private static TestBinaryData CreateTestBinaryWithModifications( string name, int functionCount, int[] modifyIndices, bool modified = false) { var functions = Enumerable.Range(0, functionCount) .Select(i => { var suffix = modified && modifyIndices.Contains(i) ? "-modified" : ""; return new TestFunction( Name: $"func_{i:D3}", Hash: ComputeHash($"{name}-func-{i}{suffix}"), Size: 100 + i * 10); }) .ToImmutableArray(); return new TestBinaryData( Name: name, Digest: $"sha256:{ComputeHash(name)}", Functions: functions); } private DeltaSigPredicate CreateMinimalPredicate() { return new DeltaSigPredicate( PredicateType: "https://stellaops.io/delta-sig/v1", Subject: ImmutableArray.Create(new InTotoSubject( Name: "test.so", Digest: ImmutableDictionary.Empty.Add("sha256", "abc123"))), Diff: ImmutableArray.Empty, Summary: new DeltaSigSummary(0, 0, 0, 0), Timestamp: FixedTimestamp, BeforeDigest: "sha256:before", AfterDigest: "sha256:after"); } private static string ComputeHash(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hash = SHA256.HashData(bytes); return Convert.ToHexString(hash).ToLowerInvariant(); } } // Supporting types for tests public record TestBinaryData( string Name, string Digest, ImmutableArray Functions); public record TestFunction( string Name, string Hash, int Size); public record DeltaSigPredicate( string PredicateType, ImmutableArray Subject, ImmutableArray Diff, DeltaSigSummary Summary, DateTimeOffset Timestamp, string BeforeDigest, string AfterDigest); public record InTotoSubject( string Name, ImmutableDictionary Digest); public record DeltaSigDiffEntry( string FunctionName, string ChangeType, string? BeforeHash, string? AfterHash, int BytesDelta, double? SemanticSimilarity); public record DeltaSigSummary( int FunctionsAdded, int FunctionsRemoved, int FunctionsModified, int TotalBytesChanged); public record DsseEnvelope( string PayloadType, string Payload, ImmutableArray Signatures); public record DsseSignature( string KeyId, string Sig); public record RekorSubmissionResult( bool Success, string? EntryId, long LogIndex, string? Error); public record VerificationResult( bool IsValid, string? PredicateType, string? FailureReason, string? VerificationMode); public record PolicyGateResult( bool Passed, ImmutableArray Violations); public record InclusionProof( long TreeSize, string RootHash, ImmutableArray Hashes); public record DeltaScopePolicyOptions { public int MaxAddedFunctions { get; init; } public int MaxRemovedFunctions { get; init; } public int MaxModifiedFunctions { get; init; } public int MaxBytesChanged { get; init; } } public record DeltaSigServiceOptions { public string PredicateType { get; init; } = "https://stellaops.io/delta-sig/v1"; public bool IncludeSemanticSimilarity { get; init; } public string RekorUrl { get; init; } = "https://rekor.sigstore.dev"; } public interface IDeltaSigService { Task GenerateAsync(TestBinaryData before, TestBinaryData after, CancellationToken ct); Task SignAsync(DeltaSigPredicate predicate, CancellationToken ct); Task SubmitToRekorAsync(DsseEnvelope envelope, CancellationToken ct); Task VerifyFromRekorAsync(string entryId, CancellationToken ct); Task VerifyEnvelopeAsync(DsseEnvelope envelope, CancellationToken ct); Task EvaluatePolicyAsync(DeltaSigPredicate predicate, DeltaScopePolicyOptions options, CancellationToken ct); string SerializePredicate(DeltaSigPredicate predicate); DeltaSigPredicate DeserializePredicate(string json); Task GetInclusionProofAsync(string entryId, CancellationToken ct); Task VerifyWithStoredProofAsync(DsseEnvelope envelope, InclusionProof proof, CancellationToken ct); } public sealed class MockRekorClient { private bool _offline; private long _nextLogIndex = 10000; private readonly Dictionary _proofs = new(); public void SetOffline(bool offline) => _offline = offline; public Task SubmitAsync(byte[] payload, CancellationToken ct) { if (_offline) return Task.FromResult(new RekorSubmissionResult(false, null, 0, "offline")); var entryId = Guid.NewGuid().ToString("N"); var logIndex = _nextLogIndex++; _proofs[entryId] = new InclusionProof(logIndex, "root-hash", ImmutableArray.Create("h1", "h2")); return Task.FromResult(new RekorSubmissionResult(true, entryId, logIndex, null)); } public Task GetProofAsync(string entryId, CancellationToken ct) { if (_offline) return Task.FromResult(null); _proofs.TryGetValue(entryId, out var proof); return Task.FromResult(proof); } } public sealed class MockSigningService { public Task SignAsync(string payload, CancellationToken ct) { var signature = Convert.ToBase64String( SHA256.HashData(Encoding.UTF8.GetBytes(payload))); return Task.FromResult(new DsseEnvelope( PayloadType: "application/vnd.in-toto+json", Payload: Convert.ToBase64String(Encoding.UTF8.GetBytes(payload)), Signatures: ImmutableArray.Create(new DsseSignature("key-1", signature)))); } }