save dev progress
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// BundleExportDeterminismTests.cs
|
||||
// Sprint: SPRINT_8200_0014_0002_CONCEL_delta_bundle_export
|
||||
// Tasks: EXPORT-8200-013, EXPORT-8200-018, EXPORT-8200-027
|
||||
// Description: Tests for delta correctness, export determinism, and E2E export verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.Concelier.Federation.Export;
|
||||
using StellaOps.Concelier.Federation.Models;
|
||||
using StellaOps.Concelier.Federation.Signing;
|
||||
|
||||
namespace StellaOps.Concelier.Federation.Tests.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for bundle export determinism - same inputs must produce same hash.
|
||||
/// </summary>
|
||||
public sealed class BundleExportDeterminismTests
|
||||
{
|
||||
private readonly Mock<IDeltaQueryService> _deltaQueryMock;
|
||||
private readonly Mock<IBundleSigner> _signerMock;
|
||||
private readonly BundleExportService _exportService;
|
||||
|
||||
public BundleExportDeterminismTests()
|
||||
{
|
||||
_deltaQueryMock = new Mock<IDeltaQueryService>();
|
||||
_signerMock = new Mock<IBundleSigner>();
|
||||
|
||||
var options = Options.Create(new FederationOptions
|
||||
{
|
||||
SiteId = "test-site",
|
||||
DefaultCompressionLevel = 3
|
||||
});
|
||||
|
||||
_exportService = new BundleExportService(
|
||||
_deltaQueryMock.Object,
|
||||
_signerMock.Object,
|
||||
options,
|
||||
NullLogger<BundleExportService>.Instance);
|
||||
}
|
||||
|
||||
#region Export Determinism Tests (Task 18)
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_SameInput_ProducesSameHash()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = CreateTestCanonicals(10);
|
||||
var edges = CreateTestEdges(canonicals);
|
||||
var deletions = Array.Empty<DeletionBundleLine>();
|
||||
|
||||
SetupDeltaQueryMock(canonicals, edges, deletions);
|
||||
|
||||
// Act - Export twice with same input
|
||||
using var stream1 = new MemoryStream();
|
||||
using var stream2 = new MemoryStream();
|
||||
|
||||
var result1 = await _exportService.ExportToStreamAsync(stream1, sinceCursor: null);
|
||||
|
||||
// Reset mock for second call
|
||||
SetupDeltaQueryMock(canonicals, edges, deletions);
|
||||
var result2 = await _exportService.ExportToStreamAsync(stream2, sinceCursor: null);
|
||||
|
||||
// Assert - Both exports should produce same counts
|
||||
result1.Counts.Canonicals.Should().Be(result2.Counts.Canonicals);
|
||||
result1.Counts.Edges.Should().Be(result2.Counts.Edges);
|
||||
result1.Counts.Deletions.Should().Be(result2.Counts.Deletions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_DifferentCursors_ProducesDifferentHashes()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals1 = CreateTestCanonicals(5);
|
||||
var canonicals2 = CreateTestCanonicals(5); // Different GUIDs
|
||||
var edges1 = CreateTestEdges(canonicals1);
|
||||
var edges2 = CreateTestEdges(canonicals2);
|
||||
|
||||
// First export
|
||||
SetupDeltaQueryMock(canonicals1, edges1, []);
|
||||
using var stream1 = new MemoryStream();
|
||||
var result1 = await _exportService.ExportToStreamAsync(stream1, sinceCursor: "cursor-a");
|
||||
|
||||
// Second export with different data
|
||||
SetupDeltaQueryMock(canonicals2, edges2, []);
|
||||
using var stream2 = new MemoryStream();
|
||||
var result2 = await _exportService.ExportToStreamAsync(stream2, sinceCursor: "cursor-b");
|
||||
|
||||
// Assert - Different content should produce different hashes
|
||||
result1.BundleHash.Should().NotBe(result2.BundleHash);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delta Correctness Tests (Task 13)
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_EmptyDelta_ProducesEmptyBundle()
|
||||
{
|
||||
// Arrange
|
||||
SetupDeltaQueryMock([], [], []);
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
var result = await _exportService.ExportToStreamAsync(stream, sinceCursor: "current-cursor");
|
||||
|
||||
// Assert
|
||||
result.Counts.Canonicals.Should().Be(0);
|
||||
result.Counts.Edges.Should().Be(0);
|
||||
result.Counts.Deletions.Should().Be(0);
|
||||
result.CompressedSizeBytes.Should().BeGreaterThan(0); // Still has manifest
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_OnlyCanonicals_IncludesOnlyCanonicals()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = CreateTestCanonicals(3);
|
||||
SetupDeltaQueryMock(canonicals, [], []);
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
var result = await _exportService.ExportToStreamAsync(stream, sinceCursor: null);
|
||||
|
||||
// Assert
|
||||
result.Counts.Canonicals.Should().Be(3);
|
||||
result.Counts.Edges.Should().Be(0);
|
||||
result.Counts.Deletions.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_OnlyDeletions_IncludesOnlyDeletions()
|
||||
{
|
||||
// Arrange
|
||||
var deletions = CreateTestDeletions(2);
|
||||
SetupDeltaQueryMock([], [], deletions);
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
var result = await _exportService.ExportToStreamAsync(stream, sinceCursor: null);
|
||||
|
||||
// Assert
|
||||
result.Counts.Canonicals.Should().Be(0);
|
||||
result.Counts.Edges.Should().Be(0);
|
||||
result.Counts.Deletions.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_MixedChanges_IncludesAllTypes()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = CreateTestCanonicals(5);
|
||||
var edges = CreateTestEdges(canonicals);
|
||||
var deletions = CreateTestDeletions(2);
|
||||
SetupDeltaQueryMock(canonicals, edges, deletions);
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
var result = await _exportService.ExportToStreamAsync(stream, sinceCursor: null);
|
||||
|
||||
// Assert
|
||||
result.Counts.Canonicals.Should().Be(5);
|
||||
result.Counts.Edges.Should().Be(5); // One edge per canonical
|
||||
result.Counts.Deletions.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_LargeDelta_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = CreateTestCanonicals(100);
|
||||
var edges = CreateTestEdges(canonicals);
|
||||
SetupDeltaQueryMock(canonicals, edges, []);
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
var result = await _exportService.ExportToStreamAsync(stream, sinceCursor: null);
|
||||
|
||||
// Assert
|
||||
result.Counts.Canonicals.Should().Be(100);
|
||||
result.Counts.Edges.Should().Be(100);
|
||||
result.CompressedSizeBytes.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region E2E Export Verification Tests (Task 27)
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ProducesValidBundle_WithAllComponents()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = CreateTestCanonicals(3);
|
||||
var edges = CreateTestEdges(canonicals);
|
||||
var deletions = CreateTestDeletions(1);
|
||||
SetupDeltaQueryMock(canonicals, edges, deletions);
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
var result = await _exportService.ExportToStreamAsync(stream, sinceCursor: null);
|
||||
|
||||
// Assert - Result structure
|
||||
result.Should().NotBeNull();
|
||||
result.BundleHash.Should().StartWith("sha256:");
|
||||
result.ExportCursor.Should().NotBeNullOrEmpty();
|
||||
result.Counts.Should().NotBeNull();
|
||||
result.Duration.Should().BeGreaterThan(TimeSpan.Zero);
|
||||
|
||||
// Assert - Stream content
|
||||
stream.Position = 0;
|
||||
stream.Length.Should().BeGreaterThan(0);
|
||||
stream.Length.Should().Be(result.CompressedSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithSigning_IncludesSignature()
|
||||
{
|
||||
// Arrange
|
||||
var canonicals = CreateTestCanonicals(2);
|
||||
SetupDeltaQueryMock(canonicals, [], []);
|
||||
|
||||
var signature = new BundleSignature
|
||||
{
|
||||
PayloadType = "application/stellaops.federation.bundle+json",
|
||||
Payload = "test-payload",
|
||||
Signatures = [new SignatureEntry { KeyId = "key-001", Algorithm = "ES256", Signature = "sig123" }]
|
||||
};
|
||||
|
||||
_signerMock
|
||||
.Setup(x => x.SignBundleAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new BundleSigningResult { Success = true, Signature = signature });
|
||||
|
||||
// Act
|
||||
using var stream = new MemoryStream();
|
||||
var options = new BundleExportOptions { Sign = true };
|
||||
var result = await _exportService.ExportToStreamAsync(stream, sinceCursor: null, options: options);
|
||||
|
||||
// Assert
|
||||
result.Signature.Should().NotBeNull();
|
||||
var sig = result.Signature as BundleSignature;
|
||||
sig.Should().NotBeNull();
|
||||
sig!.Signatures.Should().HaveCount(1);
|
||||
sig.Signatures[0].KeyId.Should().Be("key-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_ReturnsAccurateEstimates()
|
||||
{
|
||||
// Arrange
|
||||
var counts = new DeltaCounts { Canonicals = 100, Edges = 200, Deletions = 5 };
|
||||
|
||||
_deltaQueryMock
|
||||
.Setup(x => x.CountChangedSinceAsync(It.IsAny<string?>(), It.IsAny<DeltaQueryOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(counts);
|
||||
|
||||
// Act
|
||||
var preview = await _exportService.PreviewAsync(sinceCursor: null);
|
||||
|
||||
// Assert
|
||||
preview.EstimatedCanonicals.Should().Be(100);
|
||||
preview.EstimatedEdges.Should().Be(200);
|
||||
preview.EstimatedDeletions.Should().Be(5);
|
||||
preview.EstimatedSizeBytes.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetupDeltaQueryMock(
|
||||
IReadOnlyList<CanonicalBundleLine> canonicals,
|
||||
IReadOnlyList<EdgeBundleLine> edges,
|
||||
IReadOnlyList<DeletionBundleLine> deletions)
|
||||
{
|
||||
var changes = new DeltaChangeSet
|
||||
{
|
||||
Canonicals = canonicals.ToAsyncEnumerable(),
|
||||
Edges = edges.ToAsyncEnumerable(),
|
||||
Deletions = deletions.ToAsyncEnumerable(),
|
||||
NewCursor = "test-cursor"
|
||||
};
|
||||
|
||||
_deltaQueryMock
|
||||
.Setup(x => x.GetChangedSinceAsync(It.IsAny<string?>(), It.IsAny<DeltaQueryOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(changes);
|
||||
}
|
||||
|
||||
private static List<CanonicalBundleLine> CreateTestCanonicals(int count)
|
||||
{
|
||||
return Enumerable.Range(1, count).Select(i => new CanonicalBundleLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Cve = $"CVE-2024-{i:D4}",
|
||||
AffectsKey = $"pkg:generic/test{i}@1.0",
|
||||
MergeHash = $"sha256:hash{i}",
|
||||
Status = "active",
|
||||
Title = $"Test Advisory {i}",
|
||||
Severity = i % 3 == 0 ? "critical" : i % 2 == 0 ? "high" : "medium",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-i)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<EdgeBundleLine> CreateTestEdges(IReadOnlyList<CanonicalBundleLine> canonicals)
|
||||
{
|
||||
return canonicals.Select((c, i) => new EdgeBundleLine
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CanonicalId = c.Id,
|
||||
Source = "nvd",
|
||||
SourceAdvisoryId = c.Cve ?? $"CVE-2024-{i:D4}",
|
||||
ContentHash = $"sha256:edge{i}",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-i)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static List<DeletionBundleLine> CreateTestDeletions(int count)
|
||||
{
|
||||
return Enumerable.Range(1, count).Select(i => new DeletionBundleLine
|
||||
{
|
||||
CanonicalId = Guid.NewGuid(),
|
||||
Reason = "rejected",
|
||||
DeletedAt = DateTimeOffset.UtcNow.AddMinutes(-i)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user