part #2
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Export;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Export;
|
||||
|
||||
public sealed partial class ReplayManifestExporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAsync_IsDeterministic_SameInputsProduceSameDigestAsync()
|
||||
{
|
||||
var timestamp = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var manifest1 = CreateTestManifest("fixed-scan-id", timestamp);
|
||||
var manifest2 = CreateTestManifest("fixed-scan-id", timestamp);
|
||||
|
||||
var outputPath1 = Path.Combine(_tempDir, "replay1.json");
|
||||
var outputPath2 = Path.Combine(_tempDir, "replay2.json");
|
||||
|
||||
var options1 = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath1,
|
||||
IncludeCiEnvironment = false,
|
||||
GenerateVerificationCommand = false
|
||||
};
|
||||
var options2 = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath2,
|
||||
IncludeCiEnvironment = false,
|
||||
GenerateVerificationCommand = false
|
||||
};
|
||||
|
||||
var result1 = await _exporter.ExportAsync(manifest1, options1);
|
||||
var result2 = await _exporter.ExportAsync(manifest2, options2);
|
||||
|
||||
result1.Success.Should().BeTrue();
|
||||
result2.Success.Should().BeTrue();
|
||||
result1.ManifestDigest.Should().Be(result2.ManifestDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_CompactJson_OmitsIndentationAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-compact.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
PrettyPrint = false
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
json.Should().NotContain("\n ");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_RoundTrip_PreservesAllFieldsAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Scan.PolicyDigest = "sha256:policy123";
|
||||
manifest.Scan.ScorePolicyDigest = "sha256:score456";
|
||||
manifest.Scan.AnalyzerSetDigest = "sha256:analyzer789";
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-roundtrip.json");
|
||||
var options = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
var reloaded = JsonSerializer.Deserialize<ReplayExportManifest>(json);
|
||||
|
||||
reloaded.Should().NotBeNull();
|
||||
reloaded!.Outputs.VerdictDigest.Should().Be("sha256:analyzer789");
|
||||
reloaded.Verification.ExpectedVerdictHash.Should().Be("sha256:analyzer789");
|
||||
reloaded.Verification.ExpectedSbomHash.Should().Be("sha256:score456");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Export;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Export;
|
||||
|
||||
public sealed partial class ReplayManifestExporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithValidManifest_CreatesExportFileAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
PrettyPrint = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ManifestPath.Should().Be(outputPath);
|
||||
result.ManifestDigest.Should().StartWith("sha256:");
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ProducesValidJsonSchemaAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-schema.json");
|
||||
var options = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
var exportManifest = JsonSerializer.Deserialize<ReplayExportManifest>(json);
|
||||
|
||||
exportManifest.Should().NotBeNull();
|
||||
exportManifest!.Version.Should().Be("1.0.0");
|
||||
exportManifest.Snapshot.Should().NotBeNull();
|
||||
exportManifest.Toolchain.Should().NotBeNull();
|
||||
exportManifest.Inputs.Should().NotBeNull();
|
||||
exportManifest.Outputs.Should().NotBeNull();
|
||||
exportManifest.Verification.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesToolchainVersions_WhenEnabledAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Reachability.Graphs.Add(new ReplayReachabilityGraphReference
|
||||
{
|
||||
Analyzer = "java-callgraph",
|
||||
Version = "1.2.3",
|
||||
Hash = "sha256:abc123",
|
||||
CasUri = "cas://graphs/java"
|
||||
});
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-toolchain.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeToolchainVersions = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Toolchain.AnalyzerVersions.Should().ContainKey("java-callgraph");
|
||||
result.Manifest.Toolchain.AnalyzerVersions!["java-callgraph"].Should().Be("1.2.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesFeedSnapshots_WhenEnabledAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Scan.FeedSnapshot = "sha256:feedhash123456";
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-feeds.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeFeedSnapshots = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Inputs.Feeds.Should().NotBeEmpty();
|
||||
result.Manifest.Inputs.Feeds[0].Digest.Should().Contain("feedhash123456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ExcludesFeedSnapshots_WhenDisabledAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-no-feeds.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeFeedSnapshots = false
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Inputs.Feeds.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Export;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Export;
|
||||
|
||||
public sealed partial class ReplayManifestExporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesReachability_WhenEnabledAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Reachability.Graphs.Add(new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
CasUri = "cas://graphs/main",
|
||||
Hash = "sha256:graphhash",
|
||||
CallgraphId = "main-entry",
|
||||
Analyzer = "dotnet-callgraph",
|
||||
Version = "2.0.0"
|
||||
});
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-reach.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeReachability = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Inputs.Reachability.Should().NotBeNullOrEmpty();
|
||||
result.Manifest.Inputs.Reachability![0].EntryPoint.Should().Be("main-entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_GeneratesVerificationCommand_WhenEnabledAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-verify.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
GenerateVerificationCommand = true
|
||||
};
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Verification.Command.Should().Contain("stella replay verify");
|
||||
result.Manifest.Verification.Command.Should().Contain("--manifest");
|
||||
result.Manifest.Verification.Command.Should().Contain("--fail-on-drift");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_SetsCorrectExitCodesAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-exit.json");
|
||||
var options = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Verification.ExitCodes.Should().NotBeNull();
|
||||
result.Manifest.Verification.ExitCodes!.Success.Should().Be(0);
|
||||
result.Manifest.Verification.ExitCodes.Drift.Should().Be(1);
|
||||
result.Manifest.Verification.ExitCodes.Error.Should().Be(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Export;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Export;
|
||||
|
||||
public sealed partial class ReplayManifestExporterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithNonExistentFile_ReturnsErrorAsync()
|
||||
{
|
||||
var options = new ReplayVerifyOptions();
|
||||
|
||||
var result = await _exporter.VerifyAsync("/nonexistent/path.json", options);
|
||||
|
||||
result.Success.Should().BeFalse();
|
||||
result.ExitCode.Should().Be(2);
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidManifest_ReturnsSuccessAsync()
|
||||
{
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-to-verify.json");
|
||||
var exportOptions = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
await _exporter.ExportAsync(manifest, exportOptions);
|
||||
|
||||
var verifyOptions = new ReplayVerifyOptions
|
||||
{
|
||||
FailOnSbomDrift = true,
|
||||
FailOnVerdictDrift = true
|
||||
};
|
||||
|
||||
var result = await _exporter.VerifyAsync(outputPath, verifyOptions);
|
||||
|
||||
result.Success.Should().BeTrue();
|
||||
result.ExitCode.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// ReplayManifestExporterTests.cs
|
||||
// Sprint: SPRINT_20251228_001_BE_replay_manifest_ci (T7)
|
||||
// Description: Integration tests for replay manifest export and verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.Replay.Core.Export;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Export;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ReplayManifestExporter"/>.
|
||||
/// </summary>
|
||||
public sealed class ReplayManifestExporterTests : IDisposable
|
||||
public sealed partial class ReplayManifestExporterTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly ReplayManifestExporter _exporter;
|
||||
@@ -33,318 +28,6 @@ public sealed class ReplayManifestExporterTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_WithValidManifest_CreatesExportFile()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
PrettyPrint = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ManifestPath.Should().Be(outputPath);
|
||||
result.ManifestDigest.Should().StartWith("sha256:");
|
||||
File.Exists(outputPath).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ProducesValidJsonSchema()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-schema.json");
|
||||
var options = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
var exportManifest = JsonSerializer.Deserialize<ReplayExportManifest>(json);
|
||||
|
||||
exportManifest.Should().NotBeNull();
|
||||
exportManifest!.Version.Should().Be("1.0.0");
|
||||
exportManifest.Snapshot.Should().NotBeNull();
|
||||
exportManifest.Toolchain.Should().NotBeNull();
|
||||
exportManifest.Inputs.Should().NotBeNull();
|
||||
exportManifest.Outputs.Should().NotBeNull();
|
||||
exportManifest.Verification.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesToolchainVersions_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Reachability.Graphs.Add(new ReplayReachabilityGraphReference
|
||||
{
|
||||
Analyzer = "java-callgraph",
|
||||
Version = "1.2.3",
|
||||
Hash = "sha256:abc123",
|
||||
CasUri = "cas://graphs/java"
|
||||
});
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-toolchain.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeToolchainVersions = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Toolchain.AnalyzerVersions.Should().ContainKey("java-callgraph");
|
||||
result.Manifest.Toolchain.AnalyzerVersions!["java-callgraph"].Should().Be("1.2.3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesFeedSnapshots_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Scan.FeedSnapshot = "sha256:feedhash123456";
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-feeds.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeFeedSnapshots = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Inputs.Feeds.Should().NotBeEmpty();
|
||||
result.Manifest.Inputs.Feeds[0].Digest.Should().Contain("feedhash123456");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_ExcludesFeedSnapshots_WhenDisabled()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-no-feeds.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeFeedSnapshots = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Inputs.Feeds.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_IncludesReachability_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Reachability.Graphs.Add(new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
CasUri = "cas://graphs/main",
|
||||
Hash = "sha256:graphhash",
|
||||
CallgraphId = "main-entry",
|
||||
Analyzer = "dotnet-callgraph",
|
||||
Version = "2.0.0"
|
||||
});
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-reach.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
IncludeReachability = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Inputs.Reachability.Should().NotBeNullOrEmpty();
|
||||
result.Manifest.Inputs.Reachability![0].EntryPoint.Should().Be("main-entry");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_GeneratesVerificationCommand_WhenEnabled()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-verify.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
GenerateVerificationCommand = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Verification.Command.Should().Contain("stella replay verify");
|
||||
result.Manifest.Verification.Command.Should().Contain("--manifest");
|
||||
result.Manifest.Verification.Command.Should().Contain("--fail-on-drift");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_SetsCorrectExitCodes()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-exit.json");
|
||||
var options = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.Manifest!.Verification.ExitCodes.Should().NotBeNull();
|
||||
result.Manifest.Verification.ExitCodes!.Success.Should().Be(0);
|
||||
result.Manifest.Verification.ExitCodes.Drift.Should().Be(1);
|
||||
result.Manifest.Verification.ExitCodes.Error.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_IsDeterministic_SameInputsProduceSameDigest()
|
||||
{
|
||||
// Arrange
|
||||
var manifest1 = CreateTestManifest("fixed-scan-id", new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
var manifest2 = CreateTestManifest("fixed-scan-id", new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var outputPath1 = Path.Combine(_tempDir, "replay1.json");
|
||||
var outputPath2 = Path.Combine(_tempDir, "replay2.json");
|
||||
|
||||
var options1 = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath1,
|
||||
IncludeCiEnvironment = false, // Disable CI env to ensure determinism
|
||||
GenerateVerificationCommand = false // Disable to avoid path-specific commands
|
||||
};
|
||||
var options2 = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath2,
|
||||
IncludeCiEnvironment = false,
|
||||
GenerateVerificationCommand = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = await _exporter.ExportAsync(manifest1, options1);
|
||||
var result2 = await _exporter.ExportAsync(manifest2, options2);
|
||||
|
||||
// Assert
|
||||
result1.Success.Should().BeTrue();
|
||||
result2.Success.Should().BeTrue();
|
||||
result1.ManifestDigest.Should().Be(result2.ManifestDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_CompactJson_OmitsIndentation()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-compact.json");
|
||||
var options = new ReplayExportOptions
|
||||
{
|
||||
OutputPath = outputPath,
|
||||
PrettyPrint = false
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
json.Should().NotContain("\n "); // No indented newlines
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithNonExistentFile_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var options = new ReplayVerifyOptions();
|
||||
|
||||
// Act
|
||||
var result = await _exporter.VerifyAsync("/nonexistent/path.json", options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeFalse();
|
||||
result.ExitCode.Should().Be(2); // Error exit code
|
||||
result.Error.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_WithValidManifest_ReturnsSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
var outputPath = Path.Combine(_tempDir, "replay-to-verify.json");
|
||||
var exportOptions = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
await _exporter.ExportAsync(manifest, exportOptions);
|
||||
|
||||
var verifyOptions = new ReplayVerifyOptions
|
||||
{
|
||||
FailOnSbomDrift = true,
|
||||
FailOnVerdictDrift = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _exporter.VerifyAsync(outputPath, verifyOptions);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
result.ExitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExportAsync_RoundTrip_PreservesAllFields()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateTestManifest();
|
||||
manifest.Scan.PolicyDigest = "sha256:policy123";
|
||||
manifest.Scan.ScorePolicyDigest = "sha256:score456";
|
||||
manifest.Scan.AnalyzerSetDigest = "sha256:analyzer789";
|
||||
|
||||
var outputPath = Path.Combine(_tempDir, "replay-roundtrip.json");
|
||||
var options = new ReplayExportOptions { OutputPath = outputPath };
|
||||
|
||||
// Act
|
||||
var result = await _exporter.ExportAsync(manifest, options);
|
||||
|
||||
// Assert
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
var json = await File.ReadAllTextAsync(outputPath);
|
||||
var reloaded = JsonSerializer.Deserialize<ReplayExportManifest>(json);
|
||||
|
||||
reloaded.Should().NotBeNull();
|
||||
reloaded!.Outputs.VerdictDigest.Should().Be("sha256:analyzer789");
|
||||
reloaded.Verification.ExpectedVerdictHash.Should().Be("sha256:analyzer789");
|
||||
reloaded.Verification.ExpectedSbomHash.Should().Be("sha256:score456");
|
||||
}
|
||||
|
||||
private static ReplayManifest CreateTestManifest(
|
||||
string? scanId = null,
|
||||
DateTimeOffset? time = null)
|
||||
@@ -354,8 +37,8 @@ public sealed class ReplayManifestExporterTests : IDisposable
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = scanId ?? $"scan-{Guid.NewGuid():N}",
|
||||
Time = time ?? DateTimeOffset.UtcNow,
|
||||
Id = scanId ?? "scan-fixed",
|
||||
Time = time ?? new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Toolchain = "stellaops-scanner/1.0.0"
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core.FeedSnapshot;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.FeedSnapshot;
|
||||
|
||||
internal sealed class FakeSourceProvider : IFeedSourceProvider
|
||||
{
|
||||
private readonly string _version;
|
||||
private readonly string _digest;
|
||||
private readonly long _recordCount;
|
||||
|
||||
public FakeSourceProvider(string sourceId, string version, string digest, long recordCount)
|
||||
{
|
||||
SourceId = sourceId;
|
||||
_version = version;
|
||||
_digest = digest;
|
||||
_recordCount = recordCount;
|
||||
}
|
||||
|
||||
public string SourceId { get; }
|
||||
public string DisplayName => $"Fake {SourceId}";
|
||||
public int Priority => 0;
|
||||
|
||||
public Task<SourceSnapshot> CreateSnapshotAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new SourceSnapshot
|
||||
{
|
||||
SourceId = SourceId,
|
||||
Version = _version,
|
||||
Digest = _digest,
|
||||
RecordCount = _recordCount
|
||||
});
|
||||
}
|
||||
|
||||
public Task<string> GetCurrentDigestAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_digest);
|
||||
|
||||
public Task<long> GetRecordCountAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_recordCount);
|
||||
|
||||
public Task ExportAsync(SourceSnapshot snapshot, Stream outputStream, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<SourceSnapshot> ImportAsync(Stream inputStream, CancellationToken cancellationToken = default)
|
||||
=> CreateSnapshotAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.FeedSnapshot;
|
||||
|
||||
public sealed partial class FeedSnapshotCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_WithMultipleSources_ProducesConsistentDigestAsync()
|
||||
{
|
||||
var coordinator = CreateCoordinator(
|
||||
Provider("nvd", "v1", DigestNvd, 100),
|
||||
Provider("ghsa", "v2", DigestGhsa, 200),
|
||||
Provider("osv", "v3", DigestOsv, 150));
|
||||
|
||||
var snapshot1 = await coordinator.CreateSnapshotAsync("test-label");
|
||||
var snapshot2 = await coordinator.CreateSnapshotAsync("test-label");
|
||||
|
||||
Assert.Equal(snapshot1.CompositeDigest, snapshot2.CompositeDigest);
|
||||
Assert.Equal(3, snapshot1.Sources.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_SourcesAreSortedAlphabeticallyAsync()
|
||||
{
|
||||
var coordinator = CreateCoordinator(
|
||||
Provider("zebra", "v1", DigestZebra, 10),
|
||||
Provider("alpha", "v2", DigestAlpha, 20),
|
||||
Provider("middle", "v3", DigestMiddle, 30));
|
||||
|
||||
var snapshot = await coordinator.CreateSnapshotAsync();
|
||||
|
||||
Assert.Equal("alpha", snapshot.Sources[0].SourceId);
|
||||
Assert.Equal("middle", snapshot.Sources[1].SourceId);
|
||||
Assert.Equal("zebra", snapshot.Sources[2].SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_WithSubsetOfSources_IncludesOnlyRequestedAsync()
|
||||
{
|
||||
var coordinator = CreateCoordinator(
|
||||
Provider("nvd", "v1", DigestNvd, 100),
|
||||
Provider("ghsa", "v2", DigestGhsa, 200),
|
||||
Provider("osv", "v3", DigestOsv, 150));
|
||||
|
||||
var snapshot = await coordinator.CreateSnapshotAsync(["nvd", "osv"]);
|
||||
|
||||
Assert.Equal(2, snapshot.Sources.Count);
|
||||
Assert.Contains(snapshot.Sources, s => s.SourceId == "nvd");
|
||||
Assert.Contains(snapshot.Sources, s => s.SourceId == "osv");
|
||||
Assert.DoesNotContain(snapshot.Sources, s => s.SourceId == "ghsa");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_WithUnknownSource_ThrowsAsync()
|
||||
{
|
||||
var coordinator = CreateCoordinator(
|
||||
Provider("nvd", "v1", DigestNvd, 100));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
coordinator.CreateSnapshotAsync(["nvd", "unknown-source"]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.FeedSnapshot;
|
||||
|
||||
public sealed partial class FeedSnapshotCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetSnapshot_ReturnsStoredBundleAsync()
|
||||
{
|
||||
var coordinator = CreateCoordinator(
|
||||
Provider("nvd", "v1", DigestNvd, 100));
|
||||
|
||||
var created = await coordinator.CreateSnapshotAsync("test");
|
||||
var retrieved = await coordinator.GetSnapshotAsync(created.CompositeDigest);
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.SnapshotId, retrieved.SnapshotId);
|
||||
Assert.Equal(created.CompositeDigest, retrieved.CompositeDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSnapshot_WhenNoChanges_ReturnsValidAsync()
|
||||
{
|
||||
var coordinator = CreateCoordinator(
|
||||
Provider("nvd", "v1", DigestNvd, 100));
|
||||
|
||||
var snapshot = await coordinator.CreateSnapshotAsync();
|
||||
var result = await coordinator.ValidateSnapshotAsync(snapshot.CompositeDigest);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsValid);
|
||||
Assert.True(result.MissingSources is null or { Count: 0 });
|
||||
Assert.True(result.DriftedSources is null or { Count: 0 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.FeedSnapshot;
|
||||
|
||||
public sealed partial class FeedSnapshotCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisteredSources_ReturnsSortedList()
|
||||
{
|
||||
var coordinator = CreateCoordinator(
|
||||
Provider("zebra", "v1", DigestA1, 10),
|
||||
Provider("alpha", "v2", DigestB2, 20));
|
||||
|
||||
var registered = coordinator.RegisteredSources;
|
||||
|
||||
Assert.Equal(2, registered.Count);
|
||||
Assert.Equal("alpha", registered[0]);
|
||||
Assert.Equal("zebra", registered[1]);
|
||||
}
|
||||
}
|
||||
@@ -1,257 +1,36 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FeedSnapshotCoordinatorTests.cs
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-02
|
||||
// Description: Tests for feed snapshot coordinator determinism
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Replay.Core.FeedSnapshot;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.FeedSnapshot;
|
||||
|
||||
public sealed class FeedSnapshotCoordinatorTests
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed partial class FeedSnapshotCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_WithMultipleSources_ProducesConsistentDigest()
|
||||
private const string DigestNvd =
|
||||
"sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1";
|
||||
private const string DigestGhsa =
|
||||
"sha256:def456def456def456def456def456def456def456def456def456def456def4";
|
||||
private const string DigestOsv =
|
||||
"sha256:789012789012789012789012789012789012789012789012789012789012789a";
|
||||
private const string DigestZebra =
|
||||
"sha256:aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1";
|
||||
private const string DigestAlpha =
|
||||
"sha256:bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2";
|
||||
private const string DigestMiddle =
|
||||
"sha256:ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3";
|
||||
private const string DigestA1 =
|
||||
"sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
|
||||
private const string DigestB2 =
|
||||
"sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
|
||||
|
||||
private static FeedSnapshotCoordinatorService CreateCoordinator(params FakeSourceProvider[] providers)
|
||||
{
|
||||
// Arrange
|
||||
var providers = new IFeedSourceProvider[]
|
||||
{
|
||||
new FakeSourceProvider("nvd", "v1", "sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1", 100),
|
||||
new FakeSourceProvider("ghsa", "v2", "sha256:def456def456def456def456def456def456def456def456def456def456def4", 200),
|
||||
new FakeSourceProvider("osv", "v3", "sha256:789012789012789012789012789012789012789012789012789012789012789a", 150)
|
||||
};
|
||||
var store = new InMemorySnapshotStore();
|
||||
var coordinator = new FeedSnapshotCoordinatorService(providers, store);
|
||||
|
||||
// Act
|
||||
var snapshot1 = await coordinator.CreateSnapshotAsync("test-label");
|
||||
var snapshot2 = await coordinator.CreateSnapshotAsync("test-label");
|
||||
|
||||
// Assert - same providers should produce same composite digest
|
||||
Assert.Equal(snapshot1.CompositeDigest, snapshot2.CompositeDigest);
|
||||
Assert.Equal(3, snapshot1.Sources.Count);
|
||||
return new FeedSnapshotCoordinatorService(providers, new InMemorySnapshotStore());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_SourcesAreSortedAlphabetically()
|
||||
private static FakeSourceProvider Provider(string sourceId, string version, string digest, long recordCount)
|
||||
{
|
||||
// Arrange - providers added in non-alphabetical order
|
||||
var providers = new IFeedSourceProvider[]
|
||||
{
|
||||
new FakeSourceProvider("zebra", "v1", "sha256:aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1aaa1", 10),
|
||||
new FakeSourceProvider("alpha", "v2", "sha256:bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2bbb2", 20),
|
||||
new FakeSourceProvider("middle", "v3", "sha256:ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3ccc3", 30)
|
||||
};
|
||||
var store = new InMemorySnapshotStore();
|
||||
var coordinator = new FeedSnapshotCoordinatorService(providers, store);
|
||||
|
||||
// Act
|
||||
var snapshot = await coordinator.CreateSnapshotAsync();
|
||||
|
||||
// Assert - sources should be sorted alphabetically
|
||||
Assert.Equal("alpha", snapshot.Sources[0].SourceId);
|
||||
Assert.Equal("middle", snapshot.Sources[1].SourceId);
|
||||
Assert.Equal("zebra", snapshot.Sources[2].SourceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_WithSubsetOfSources_IncludesOnlyRequested()
|
||||
{
|
||||
// Arrange
|
||||
var providers = new IFeedSourceProvider[]
|
||||
{
|
||||
new FakeSourceProvider("nvd", "v1", "sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1", 100),
|
||||
new FakeSourceProvider("ghsa", "v2", "sha256:def456def456def456def456def456def456def456def456def456def456def4", 200),
|
||||
new FakeSourceProvider("osv", "v3", "sha256:789012789012789012789012789012789012789012789012789012789012789a", 150)
|
||||
};
|
||||
var store = new InMemorySnapshotStore();
|
||||
var coordinator = new FeedSnapshotCoordinatorService(providers, store);
|
||||
|
||||
// Act
|
||||
var snapshot = await coordinator.CreateSnapshotAsync(["nvd", "osv"]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, snapshot.Sources.Count);
|
||||
Assert.Contains(snapshot.Sources, s => s.SourceId == "nvd");
|
||||
Assert.Contains(snapshot.Sources, s => s.SourceId == "osv");
|
||||
Assert.DoesNotContain(snapshot.Sources, s => s.SourceId == "ghsa");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisteredSources_ReturnsSortedList()
|
||||
{
|
||||
// Arrange
|
||||
var providers = new IFeedSourceProvider[]
|
||||
{
|
||||
new FakeSourceProvider("zebra", "v1", "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", 10),
|
||||
new FakeSourceProvider("alpha", "v2", "sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", 20)
|
||||
};
|
||||
var store = new InMemorySnapshotStore();
|
||||
var coordinator = new FeedSnapshotCoordinatorService(providers, store);
|
||||
|
||||
// Act
|
||||
var registered = coordinator.RegisteredSources;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, registered.Count);
|
||||
Assert.Equal("alpha", registered[0]);
|
||||
Assert.Equal("zebra", registered[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshot_ReturnsStoredBundle()
|
||||
{
|
||||
// Arrange
|
||||
var providers = new IFeedSourceProvider[]
|
||||
{
|
||||
new FakeSourceProvider("nvd", "v1", "sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1", 100)
|
||||
};
|
||||
var store = new InMemorySnapshotStore();
|
||||
var coordinator = new FeedSnapshotCoordinatorService(providers, store);
|
||||
|
||||
var created = await coordinator.CreateSnapshotAsync("test");
|
||||
|
||||
// Act
|
||||
var retrieved = await coordinator.GetSnapshotAsync(created.CompositeDigest);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.SnapshotId, retrieved.SnapshotId);
|
||||
Assert.Equal(created.CompositeDigest, retrieved.CompositeDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateSnapshot_WhenNoChanges_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var providers = new IFeedSourceProvider[]
|
||||
{
|
||||
new FakeSourceProvider("nvd", "v1", "sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1", 100)
|
||||
};
|
||||
var store = new InMemorySnapshotStore();
|
||||
var coordinator = new FeedSnapshotCoordinatorService(providers, store);
|
||||
|
||||
var snapshot = await coordinator.CreateSnapshotAsync();
|
||||
|
||||
// Act
|
||||
var result = await coordinator.ValidateSnapshotAsync(snapshot.CompositeDigest);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsValid);
|
||||
// No missing or drifted sources (either null or empty is acceptable)
|
||||
Assert.True(result.MissingSources is null or { Count: 0 });
|
||||
Assert.True(result.DriftedSources is null or { Count: 0 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSnapshot_WithUnknownSource_Throws()
|
||||
{
|
||||
// Arrange
|
||||
var providers = new IFeedSourceProvider[]
|
||||
{
|
||||
new FakeSourceProvider("nvd", "v1", "sha256:abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1", 100)
|
||||
};
|
||||
var store = new InMemorySnapshotStore();
|
||||
var coordinator = new FeedSnapshotCoordinatorService(providers, store);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
coordinator.CreateSnapshotAsync(["nvd", "unknown-source"]));
|
||||
}
|
||||
|
||||
private sealed class FakeSourceProvider : IFeedSourceProvider
|
||||
{
|
||||
private readonly string _version;
|
||||
private readonly string _digest;
|
||||
private readonly long _recordCount;
|
||||
|
||||
public FakeSourceProvider(string sourceId, string version, string digest, long recordCount)
|
||||
{
|
||||
SourceId = sourceId;
|
||||
_version = version;
|
||||
_digest = digest;
|
||||
_recordCount = recordCount;
|
||||
}
|
||||
|
||||
public string SourceId { get; }
|
||||
public string DisplayName => $"Fake {SourceId}";
|
||||
public int Priority => 0;
|
||||
|
||||
public Task<SourceSnapshot> CreateSnapshotAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new SourceSnapshot
|
||||
{
|
||||
SourceId = SourceId,
|
||||
Version = _version,
|
||||
Digest = _digest,
|
||||
RecordCount = _recordCount
|
||||
});
|
||||
}
|
||||
|
||||
public Task<string> GetCurrentDigestAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_digest);
|
||||
|
||||
public Task<long> GetRecordCountAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_recordCount);
|
||||
|
||||
public Task ExportAsync(SourceSnapshot snapshot, Stream outputStream, CancellationToken cancellationToken = default) =>
|
||||
Task.CompletedTask;
|
||||
|
||||
public Task<SourceSnapshot> ImportAsync(Stream inputStream, CancellationToken cancellationToken = default) =>
|
||||
CreateSnapshotAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class InMemorySnapshotStore : IFeedSnapshotStore
|
||||
{
|
||||
private readonly Dictionary<string, FeedSnapshotBundle> _byDigest = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FeedSnapshotBundle> _byId = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task SaveAsync(FeedSnapshotBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_byDigest[bundle.CompositeDigest] = bundle;
|
||||
_byId[bundle.SnapshotId] = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotBundle?> GetByDigestAsync(string compositeDigest, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_byDigest.GetValueOrDefault(compositeDigest));
|
||||
|
||||
public Task<FeedSnapshotBundle?> GetByIdAsync(string snapshotId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_byId.GetValueOrDefault(snapshotId));
|
||||
|
||||
public async IAsyncEnumerable<FeedSnapshotSummary> ListAsync(
|
||||
DateTimeOffset? from = null,
|
||||
DateTimeOffset? to = null,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var bundle in _byDigest.Values.OrderByDescending(b => b.CreatedAt))
|
||||
{
|
||||
if (from.HasValue && bundle.CreatedAt < from.Value) continue;
|
||||
if (to.HasValue && bundle.CreatedAt > to.Value) continue;
|
||||
|
||||
yield return new FeedSnapshotSummary
|
||||
{
|
||||
SnapshotId = bundle.SnapshotId,
|
||||
CompositeDigest = bundle.CompositeDigest,
|
||||
Label = bundle.Label,
|
||||
CreatedAt = bundle.CreatedAt,
|
||||
SourceCount = bundle.Sources.Count,
|
||||
TotalRecordCount = bundle.Sources.Sum(s => s.RecordCount)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string compositeDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existed = _byDigest.Remove(compositeDigest, out var bundle);
|
||||
if (existed && bundle is not null)
|
||||
{
|
||||
_byId.Remove(bundle.SnapshotId);
|
||||
}
|
||||
return Task.FromResult(existed);
|
||||
}
|
||||
return new FakeSourceProvider(sourceId, version, digest, recordCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core.FeedSnapshot;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.FeedSnapshot;
|
||||
|
||||
internal sealed class InMemorySnapshotStore : IFeedSnapshotStore
|
||||
{
|
||||
private readonly Dictionary<string, FeedSnapshotBundle> _byDigest =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, FeedSnapshotBundle> _byId =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task SaveAsync(FeedSnapshotBundle bundle, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_byDigest[bundle.CompositeDigest] = bundle;
|
||||
_byId[bundle.SnapshotId] = bundle;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<FeedSnapshotBundle?> GetByDigestAsync(
|
||||
string compositeDigest,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_byDigest.GetValueOrDefault(compositeDigest));
|
||||
|
||||
public Task<FeedSnapshotBundle?> GetByIdAsync(
|
||||
string snapshotId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_byId.GetValueOrDefault(snapshotId));
|
||||
|
||||
public async IAsyncEnumerable<FeedSnapshotSummary> ListAsync(
|
||||
DateTimeOffset? from = null,
|
||||
DateTimeOffset? to = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var bundle in _byDigest.Values.OrderByDescending(b => b.CreatedAt))
|
||||
{
|
||||
if (from.HasValue && bundle.CreatedAt < from.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (to.HasValue && bundle.CreatedAt > to.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return new FeedSnapshotSummary
|
||||
{
|
||||
SnapshotId = bundle.SnapshotId,
|
||||
CompositeDigest = bundle.CompositeDigest,
|
||||
Label = bundle.Label,
|
||||
CreatedAt = bundle.CreatedAt,
|
||||
SourceCount = bundle.Sources.Count,
|
||||
TotalRecordCount = bundle.Sources.Sum(s => s.RecordCount)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string compositeDigest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existed = _byDigest.Remove(compositeDigest, out var bundle);
|
||||
if (existed && bundle is not null)
|
||||
{
|
||||
_byId.Remove(bundle.SnapshotId);
|
||||
}
|
||||
|
||||
return Task.FromResult(existed);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
public class ReplayManifestTests
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed class ReplayManifestTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SerializesWithNamespacesAndAnalysis_V1()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
@@ -24,7 +27,7 @@ public class ReplayManifestTests
|
||||
CasUri = "cas://reachability_graphs/aa/aagraph.tar.zst",
|
||||
Hash = "sha256:aa",
|
||||
HashAlgorithm = "sha256",
|
||||
Sha256 = "aa", // Legacy field for v1 compat
|
||||
Sha256 = "aa",
|
||||
Namespace = "reachability_graphs",
|
||||
CallgraphId = "cg-1",
|
||||
Analyzer = "scanner",
|
||||
@@ -37,9 +40,9 @@ public class ReplayManifestTests
|
||||
CasUri = "cas://runtime_traces/bb/bbtrace.tar.zst",
|
||||
Hash = "sha256:bb",
|
||||
HashAlgorithm = "sha256",
|
||||
Sha256 = "bb", // Legacy field for v1 compat
|
||||
Sha256 = "bb",
|
||||
Namespace = "runtime_traces",
|
||||
RecordedAt = System.DateTimeOffset.Parse("2025-11-26T00:00:00Z")
|
||||
RecordedAt = DateTimeOffset.Parse("2025-11-26T00:00:00Z")
|
||||
});
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
@@ -51,7 +54,7 @@ public class ReplayManifestTests
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
[Fact]
|
||||
public void SerializesWithV2HashFields()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
@@ -67,7 +70,7 @@ public class ReplayManifestTests
|
||||
{
|
||||
Kind = "static",
|
||||
CasUri = "cas://reachability/graphs/blake3:abc123",
|
||||
Hash = "blake3:abc123def456789012345678901234567890123456789012345678901234",
|
||||
Hash = "blake3:abc123def4567890123456789012345678901234567890123456778901234",
|
||||
HashAlgorithm = "blake3-256",
|
||||
Namespace = "reachability_graphs",
|
||||
Analyzer = "scanner.java@10.0.0",
|
||||
@@ -79,7 +82,6 @@ public class ReplayManifestTests
|
||||
Assert.Contains("\"schemaVersion\":\"2.0\"", json);
|
||||
Assert.Contains("\"hash\":\"blake3:", json);
|
||||
Assert.Contains("\"hashAlg\":\"blake3-256\"", json);
|
||||
// v2 manifests should not emit legacy sha256 field (JsonIgnore when null)
|
||||
Assert.DoesNotContain("\"sha256\":", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CodeIdCoverage_SerializesWithSnakeCaseKeys()
|
||||
{
|
||||
var coverage = new CodeIdCoverage
|
||||
{
|
||||
TotalNodes = 1247,
|
||||
NodesWithSymbolId = 1189,
|
||||
NodesWithCodeId = 58,
|
||||
CoveragePercent = 100.0
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(coverage, _jsonOptions);
|
||||
|
||||
Assert.Contains("\"total_nodes\":1247", json);
|
||||
Assert.Contains("\"nodes_with_symbol_id\":1189", json);
|
||||
Assert.Contains("\"nodes_with_code_id\":58", json);
|
||||
Assert.Contains("\"coverage_percent\":100", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidManifest_MissingHashAlg_InV2_FailsValidationAsync()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = null!,
|
||||
CasUri = "cas://reachability/graphs/blake3:..."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingHashAlg);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidManifest_MissingCasReference_FailsValidationAsync()
|
||||
{
|
||||
var casValidator = new InMemoryCasValidator();
|
||||
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(casValidator);
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.CasNotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidManifest_HashMismatch_FailsValidationAsync()
|
||||
{
|
||||
var casValidator = new InMemoryCasValidator();
|
||||
casValidator.Register(
|
||||
"cas://reachability/graphs/blake3:actual",
|
||||
"blake3:differenthash");
|
||||
casValidator.Register(
|
||||
"cas://reachability/graphs/blake3:actual.dsse",
|
||||
"blake3:differenthash.dsse");
|
||||
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:expected",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:actual"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(casValidator);
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.HashMismatch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidManifest_MissingSchemaVersion_FailsValidationAsync()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = null!
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingVersion);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidManifest_VersionMismatch_WhenV2Required_FailsValidationAsync()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(requireV2: true);
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.VersionMismatch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MinimalValidManifestV2_SerializesCorrectly()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-test-001",
|
||||
Time = DateTimeOffset.Parse("2025-12-13T10:00:00Z")
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
Analyzer = "scanner.java@10.2.0",
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
|
||||
}
|
||||
],
|
||||
RuntimeTraces = new List<ReplayReachabilityTraceReference>(),
|
||||
CodeIdCoverage = new CodeIdCoverage
|
||||
{
|
||||
TotalNodes = 100,
|
||||
NodesWithSymbolId = 100,
|
||||
NodesWithCodeId = 0,
|
||||
CoveragePercent = 100.0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, _jsonOptions);
|
||||
|
||||
Assert.Contains("\"schemaVersion\":\"2.0\"", json);
|
||||
Assert.Contains("\"hash\":\"blake3:", json);
|
||||
Assert.Contains("\"hashAlg\":\"blake3-256\"", json);
|
||||
Assert.Contains("\"code_id_coverage\"", json);
|
||||
Assert.Contains("\"total_nodes\":100", json);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ManifestWithRuntimeTraces_SerializesCorrectly()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-test-002",
|
||||
Time = DateTimeOffset.Parse("2025-12-13T11:00:00Z")
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
Analyzer = "scanner.java@10.2.0",
|
||||
Hash = "blake3:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
}
|
||||
],
|
||||
RuntimeTraces =
|
||||
[
|
||||
new ReplayReachabilityTraceReference
|
||||
{
|
||||
Source = "eventpipe",
|
||||
Hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
HashAlgorithm = "sha256",
|
||||
CasUri = "cas://reachability/runtime/sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
RecordedAt = DateTimeOffset.Parse("2025-12-13T10:30:00Z")
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, _jsonOptions);
|
||||
|
||||
Assert.Contains("\"source\":\"eventpipe\"", json);
|
||||
Assert.Contains("\"hash\":\"sha256:", json);
|
||||
Assert.Contains("\"hashAlg\":\"sha256\"", json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_WithValidGraphs_CreatesSortedManifest()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:zzzz",
|
||||
CasUri = "cas://graphs/zzzz"
|
||||
},
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:aaaa",
|
||||
CasUri = "cas://graphs/aaaa"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal(ReplayManifestVersions.V2, manifest.SchemaVersion);
|
||||
Assert.Equal("cas://graphs/aaaa", manifest.Reachability.Graphs[0].CasUri);
|
||||
Assert.Equal("cas://graphs/zzzz", manifest.Reachability.Graphs[1].CasUri);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_WithLegacySha256_MigratesHashField()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Sha256 = "abc123",
|
||||
CasUri = "cas://graphs/abc123"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal("sha256:abc123", manifest.Reachability.Graphs[0].Hash);
|
||||
Assert.Equal("sha256", manifest.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_InfersHashAlgorithmFromPrefix()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4",
|
||||
CasUri = "cas://graphs/a1b2c3d4"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal("blake3-256", manifest.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_RequiresAtLeastOneGraph()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
Array.Empty<ReplayReachabilityGraphReference>(),
|
||||
Array.Empty<ReplayReachabilityTraceReference>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SortingValidation_UnsortedGraphs_FailsValidationAsync()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "framework",
|
||||
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:zzzz..."
|
||||
},
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:aaaa..."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.UnsortedEntries);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SortingValidation_SortedGraphs_PassesValidationAsync()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:aaaa..."
|
||||
},
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "framework",
|
||||
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:zzzz..."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UpgradeToV2_ConvertsV1ManifestCorrectly()
|
||||
{
|
||||
var v1 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-legacy"
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Kind = "static",
|
||||
Sha256 = "abc123",
|
||||
CasUri = "cas://reachability/graphs/abc123"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
|
||||
|
||||
Assert.Equal(ReplayManifestVersions.V2, v2.SchemaVersion);
|
||||
Assert.Single(v2.Reachability.Graphs);
|
||||
Assert.Equal("sha256:abc123", v2.Reachability.Graphs[0].Hash);
|
||||
Assert.Equal("sha256", v2.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UpgradeToV2_SortsGraphsByUri()
|
||||
{
|
||||
var v1 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs =
|
||||
[
|
||||
new ReplayReachabilityGraphReference { Sha256 = "zzz", CasUri = "cas://graphs/zzz" },
|
||||
new ReplayReachabilityGraphReference { Sha256 = "aaa", CasUri = "cas://graphs/aaa" }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
|
||||
|
||||
Assert.Equal("cas://graphs/aaa", v2.Reachability.Graphs[0].CasUri);
|
||||
Assert.Equal("cas://graphs/zzz", v2.Reachability.Graphs[1].CasUri);
|
||||
}
|
||||
}
|
||||
@@ -1,500 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Replay.Core;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
using StellaOps.TestKit;
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test vectors from replay-manifest-v2-acceptance.md
|
||||
/// </summary>
|
||||
public class ReplayManifestV2Tests
|
||||
public sealed partial class ReplayManifestV2Tests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
#region Section 4.1: Minimal Valid Manifest v2
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void MinimalValidManifestV2_SerializesCorrectly()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-test-001",
|
||||
Time = DateTimeOffset.Parse("2025-12-13T10:00:00Z")
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Analyzer = "scanner.java@10.2.0",
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
|
||||
}
|
||||
},
|
||||
RuntimeTraces = new List<ReplayReachabilityTraceReference>(),
|
||||
CodeIdCoverage = new CodeIdCoverage
|
||||
{
|
||||
TotalNodes = 100,
|
||||
NodesWithSymbolId = 100,
|
||||
NodesWithCodeId = 0,
|
||||
CoveragePercent = 100.0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
|
||||
Assert.Contains("\"schemaVersion\":\"2.0\"", json);
|
||||
Assert.Contains("\"hash\":\"blake3:", json);
|
||||
Assert.Contains("\"hashAlg\":\"blake3-256\"", json);
|
||||
Assert.Contains("\"code_id_coverage\"", json);
|
||||
Assert.Contains("\"total_nodes\":100", json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 4.2: Manifest with Runtime Traces
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ManifestWithRuntimeTraces_SerializesCorrectly()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-test-002",
|
||||
Time = DateTimeOffset.Parse("2025-12-13T11:00:00Z")
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Analyzer = "scanner.java@10.2.0",
|
||||
Hash = "blake3:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
}
|
||||
},
|
||||
RuntimeTraces = new List<ReplayReachabilityTraceReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Source = "eventpipe",
|
||||
Hash = "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
HashAlgorithm = "sha256",
|
||||
CasUri = "cas://reachability/runtime/sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
RecordedAt = DateTimeOffset.Parse("2025-12-13T10:30:00Z")
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(manifest, JsonOptions);
|
||||
|
||||
Assert.Contains("\"source\":\"eventpipe\"", json);
|
||||
Assert.Contains("\"hash\":\"sha256:", json);
|
||||
Assert.Contains("\"hashAlg\":\"sha256\"", json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 4.3: Sorting Validation
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SortingValidation_UnsortedGraphs_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "framework",
|
||||
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:zzzz..."
|
||||
},
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:aaaa..."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.UnsortedEntries);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SortingValidation_SortedGraphs_PassesValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Hash = "blake3:aaaa1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:aaaa..."
|
||||
},
|
||||
new()
|
||||
{
|
||||
Kind = "framework",
|
||||
Hash = "blake3:zzzz1111111111111111111111111111111111111111111111111111111111",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:zzzz..."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 4.4: Invalid Manifest Vectors
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InvalidManifest_MissingSchemaVersion_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = null!
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingVersion);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InvalidManifest_VersionMismatch_WhenV2Required_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(requireV2: true);
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.VersionMismatch);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void InvalidManifest_MissingHashAlg_InV2_FailsValidation()
|
||||
{
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = null!, // Missing
|
||||
CasUri = "cas://reachability/graphs/blake3:..."
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator();
|
||||
var result = validator.ValidateAsync(manifest).GetAwaiter().GetResult();
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.MissingHashAlg);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidManifest_MissingCasReference_FailsValidation()
|
||||
{
|
||||
var casValidator = new InMemoryCasValidator();
|
||||
// Don't register any objects
|
||||
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:missing"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(casValidator);
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.CasNotFound);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task InvalidManifest_HashMismatch_FailsValidation()
|
||||
{
|
||||
var casValidator = new InMemoryCasValidator();
|
||||
casValidator.Register(
|
||||
"cas://reachability/graphs/blake3:actual",
|
||||
"blake3:differenthash");
|
||||
casValidator.Register(
|
||||
"cas://reachability/graphs/blake3:actual.dsse",
|
||||
"blake3:differenthash.dsse");
|
||||
|
||||
var manifest = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V2,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Hash = "blake3:expected",
|
||||
HashAlgorithm = "blake3-256",
|
||||
CasUri = "cas://reachability/graphs/blake3:actual"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var validator = new ReplayManifestValidator(casValidator);
|
||||
var result = await validator.ValidateAsync(manifest);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.ErrorCode == ReplayManifestErrorCodes.HashMismatch);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Section 5: Migration Path
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UpgradeToV2_ConvertsV1ManifestCorrectly()
|
||||
{
|
||||
var v1 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Scan = new ReplayScanMetadata
|
||||
{
|
||||
Id = "scan-legacy"
|
||||
},
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Kind = "static",
|
||||
Sha256 = "abc123",
|
||||
CasUri = "cas://reachability/graphs/abc123"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
|
||||
|
||||
Assert.Equal(ReplayManifestVersions.V2, v2.SchemaVersion);
|
||||
Assert.Single(v2.Reachability.Graphs);
|
||||
Assert.Equal("sha256:abc123", v2.Reachability.Graphs[0].Hash);
|
||||
Assert.Equal("sha256", v2.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void UpgradeToV2_SortsGraphsByUri()
|
||||
{
|
||||
var v1 = new ReplayManifest
|
||||
{
|
||||
SchemaVersion = ReplayManifestVersions.V1,
|
||||
Reachability = new ReplayReachabilitySection
|
||||
{
|
||||
Graphs = new List<ReplayReachabilityGraphReference>
|
||||
{
|
||||
new() { Sha256 = "zzz", CasUri = "cas://graphs/zzz" },
|
||||
new() { Sha256 = "aaa", CasUri = "cas://graphs/aaa" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var v2 = ReplayManifestValidator.UpgradeToV2(v1);
|
||||
|
||||
Assert.Equal("cas://graphs/aaa", v2.Reachability.Graphs[0].CasUri);
|
||||
Assert.Equal("cas://graphs/zzz", v2.Reachability.Graphs[1].CasUri);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ReachabilityReplayWriter Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_WithValidGraphs_CreatesSortedManifest()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:zzzz",
|
||||
CasUri = "cas://graphs/zzzz"
|
||||
},
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:aaaa",
|
||||
CasUri = "cas://graphs/aaaa"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal(ReplayManifestVersions.V2, manifest.SchemaVersion);
|
||||
Assert.Equal("cas://graphs/aaaa", manifest.Reachability.Graphs[0].CasUri);
|
||||
Assert.Equal("cas://graphs/zzzz", manifest.Reachability.Graphs[1].CasUri);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_WithLegacySha256_MigratesHashField()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Sha256 = "abc123",
|
||||
CasUri = "cas://graphs/abc123"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal("sha256:abc123", manifest.Reachability.Graphs[0].Hash);
|
||||
Assert.Equal("sha256", manifest.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_InfersHashAlgorithmFromPrefix()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
var graphs = new[]
|
||||
{
|
||||
new ReplayReachabilityGraphReference
|
||||
{
|
||||
Hash = "blake3:a1b2c3d4",
|
||||
CasUri = "cas://graphs/a1b2c3d4"
|
||||
}
|
||||
};
|
||||
|
||||
var manifest = ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
graphs,
|
||||
Array.Empty<ReplayReachabilityTraceReference>());
|
||||
|
||||
Assert.Equal("blake3-256", manifest.Reachability.Graphs[0].HashAlgorithm);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void BuildManifestV2_RequiresAtLeastOneGraph()
|
||||
{
|
||||
var scan = new ReplayScanMetadata { Id = "test-scan" };
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() =>
|
||||
ReachabilityReplayWriter.BuildManifestV2(
|
||||
scan,
|
||||
Array.Empty<ReplayReachabilityGraphReference>(),
|
||||
Array.Empty<ReplayReachabilityTraceReference>()));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CodeIdCoverage Tests
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void CodeIdCoverage_SerializesWithSnakeCaseKeys()
|
||||
{
|
||||
var coverage = new CodeIdCoverage
|
||||
{
|
||||
TotalNodes = 1247,
|
||||
NodesWithSymbolId = 1189,
|
||||
NodesWithCodeId = 58,
|
||||
CoveragePercent = 100.0
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(coverage, JsonOptions);
|
||||
|
||||
Assert.Contains("\"total_nodes\":1247", json);
|
||||
Assert.Contains("\"nodes_with_symbol_id\":1189", json);
|
||||
Assert.Contains("\"nodes_with_code_id\":58", json);
|
||||
Assert.Contains("\"coverage_percent\":100", json);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayProofTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToCanonicalJson_SortsKeysDeterministically()
|
||||
{
|
||||
var proof = CreateTestProof();
|
||||
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
var keys = ExtractJsonKeys(json);
|
||||
keys.Should().BeInAscendingOrder(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_ExcludesNullValues()
|
||||
{
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
json.Should().NotContain("null");
|
||||
json.Should().NotContain("artifactDigest");
|
||||
json.Should().NotContain("signatureVerified");
|
||||
json.Should().NotContain("signatureKeyId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_FormatsTimestampCorrectly()
|
||||
{
|
||||
var proof = CreateTestProof();
|
||||
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
json.Should().Contain("2026-01-05T12:00:00.000Z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_IncludesMetadataWhenPresent()
|
||||
{
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0",
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("tenant", "acme-corp")
|
||||
.Add("project", "web-app"));
|
||||
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
json.Should().Contain("metadata");
|
||||
json.Should().Contain("tenant");
|
||||
json.Should().Contain("acme-corp");
|
||||
json.Should().Contain("project");
|
||||
json.Should().Contain("web-app");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_SortsMetadataKeys()
|
||||
{
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0",
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("zebra", "z-value")
|
||||
.Add("alpha", "a-value")
|
||||
.Add("mike", "m-value"));
|
||||
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
var alphaPos = json.IndexOf("alpha", StringComparison.Ordinal);
|
||||
var mikePos = json.IndexOf("mike", StringComparison.Ordinal);
|
||||
var zebraPos = json.IndexOf("zebra", StringComparison.Ordinal);
|
||||
|
||||
alphaPos.Should().BeLessThan(mikePos);
|
||||
mikePos.Should().BeLessThan(zebraPos);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayProofTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToCompactString_GeneratesCorrectFormat()
|
||||
{
|
||||
var proof = CreateTestProof();
|
||||
|
||||
var compact = proof.ToCompactString();
|
||||
|
||||
compact.Should().StartWith("replay-proof:");
|
||||
compact.Should().HaveLength("replay-proof:".Length + 64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCompactString_IsDeterministic()
|
||||
{
|
||||
var proof1 = CreateTestProof();
|
||||
var proof2 = CreateTestProof();
|
||||
|
||||
var compact1 = proof1.ToCompactString();
|
||||
var compact2 = proof2.ToCompactString();
|
||||
|
||||
compact1.Should().Be(compact2, "same inputs should produce same compact proof");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsTrueForValidProof()
|
||||
{
|
||||
var proof = CreateTestProof();
|
||||
var compact = proof.ToCompactString();
|
||||
var canonicalJson = proof.ToCanonicalJson();
|
||||
|
||||
var isValid = ReplayProof.ValidateCompactString(compact, canonicalJson);
|
||||
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsFalseForTamperedJson()
|
||||
{
|
||||
var proof = CreateTestProof();
|
||||
var compact = proof.ToCompactString();
|
||||
var tamperedJson = proof.ToCanonicalJson().Replace("1.0.0", "2.0.0");
|
||||
|
||||
var isValid = ReplayProof.ValidateCompactString(compact, tamperedJson);
|
||||
|
||||
isValid.Should().BeFalse("tampered JSON should not validate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsFalseForInvalidPrefix()
|
||||
{
|
||||
var canonicalJson = CreateTestProof().ToCanonicalJson();
|
||||
|
||||
var isValid = ReplayProof.ValidateCompactString("invalid-proof:abc123", canonicalJson);
|
||||
|
||||
isValid.Should().BeFalse("invalid prefix should not validate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsFalseForEmptyInputs()
|
||||
{
|
||||
ReplayProof.ValidateCompactString("", "{}").Should().BeFalse();
|
||||
ReplayProof.ValidateCompactString("replay-proof:abc", "").Should().BeFalse();
|
||||
ReplayProof.ValidateCompactString(null!, "{}").Should().BeFalse();
|
||||
ReplayProof.ValidateCompactString("replay-proof:abc", null!).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
public sealed partial class ReplayProofTests
|
||||
{
|
||||
[Fact]
|
||||
public void FromExecutionResult_CreatesValidProof()
|
||||
{
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0",
|
||||
artifactDigest: "sha256:image123",
|
||||
signatureVerified: true,
|
||||
signatureKeyId: "key-001");
|
||||
|
||||
proof.BundleHash.Should().Be("sha256:abc123");
|
||||
proof.PolicyVersion.Should().Be("1.0.0");
|
||||
proof.VerdictRoot.Should().Be("sha256:def456");
|
||||
proof.VerdictMatches.Should().BeTrue();
|
||||
proof.DurationMs.Should().Be(150);
|
||||
proof.ReplayedAt.Should().Be(_fixedTimestamp);
|
||||
proof.EngineVersion.Should().Be("1.0.0");
|
||||
proof.ArtifactDigest.Should().Be("sha256:image123");
|
||||
proof.SignatureVerified.Should().BeTrue();
|
||||
proof.SignatureKeyId.Should().Be("key-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromExecutionResult_ThrowsOnNullRequiredParams()
|
||||
{
|
||||
var act1 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: null!,
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
act1.Should().Throw<ArgumentNullException>().WithParameterName("bundleHash");
|
||||
|
||||
var act2 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: null!,
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
act2.Should().Throw<ArgumentNullException>().WithParameterName("policyVersion");
|
||||
|
||||
var act3 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: null!,
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
act3.Should().Throw<ArgumentNullException>().WithParameterName("verdictRoot");
|
||||
|
||||
var act4 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: null!);
|
||||
act4.Should().Throw<ArgumentNullException>().WithParameterName("engineVersion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchemaVersion_DefaultsTo1_0_0()
|
||||
{
|
||||
var proof = CreateTestProof();
|
||||
|
||||
proof.SchemaVersion.Should().Be("1.0.0");
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,10 @@
|
||||
// <copyright file="ReplayProofTests.cs" company="Stella Operations">
|
||||
// Copyright (c) Stella Operations. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Replay.Core.Models;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
@@ -16,282 +13,10 @@ namespace StellaOps.Replay.Core.Tests;
|
||||
/// Unit tests for ReplayProof model and compact string generation.
|
||||
/// Sprint: SPRINT_20260105_002_001_REPLAY, Tasks RPL-011 through RPL-014.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class ReplayProofTests
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed partial class ReplayProofTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedTimestamp = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void FromExecutionResult_CreatesValidProof()
|
||||
{
|
||||
// Arrange & Act
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: "1.0.0",
|
||||
artifactDigest: "sha256:image123",
|
||||
signatureVerified: true,
|
||||
signatureKeyId: "key-001");
|
||||
|
||||
// Assert
|
||||
proof.BundleHash.Should().Be("sha256:abc123");
|
||||
proof.PolicyVersion.Should().Be("1.0.0");
|
||||
proof.VerdictRoot.Should().Be("sha256:def456");
|
||||
proof.VerdictMatches.Should().BeTrue();
|
||||
proof.DurationMs.Should().Be(150);
|
||||
proof.ReplayedAt.Should().Be(FixedTimestamp);
|
||||
proof.EngineVersion.Should().Be("1.0.0");
|
||||
proof.ArtifactDigest.Should().Be("sha256:image123");
|
||||
proof.SignatureVerified.Should().BeTrue();
|
||||
proof.SignatureKeyId.Should().Be("key-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCompactString_GeneratesCorrectFormat()
|
||||
{
|
||||
// Arrange
|
||||
var proof = CreateTestProof();
|
||||
|
||||
// Act
|
||||
var compact = proof.ToCompactString();
|
||||
|
||||
// Assert
|
||||
compact.Should().StartWith("replay-proof:");
|
||||
compact.Should().HaveLength("replay-proof:".Length + 64); // SHA-256 hex = 64 chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCompactString_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var proof1 = CreateTestProof();
|
||||
var proof2 = CreateTestProof();
|
||||
|
||||
// Act
|
||||
var compact1 = proof1.ToCompactString();
|
||||
var compact2 = proof2.ToCompactString();
|
||||
|
||||
// Assert
|
||||
compact1.Should().Be(compact2, "same inputs should produce same compact proof");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_SortsKeysDeterministically()
|
||||
{
|
||||
// Arrange
|
||||
var proof = CreateTestProof();
|
||||
|
||||
// Act
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
// Assert - Keys should appear in alphabetical order
|
||||
var keys = ExtractJsonKeys(json);
|
||||
keys.Should().BeInAscendingOrder(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_ExcludesNullValues()
|
||||
{
|
||||
// Arrange
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
|
||||
// Act
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
// Assert - Should not contain null values
|
||||
json.Should().NotContain("null");
|
||||
json.Should().NotContain("artifactDigest"); // Not set, so excluded
|
||||
json.Should().NotContain("signatureVerified"); // Not set, so excluded
|
||||
json.Should().NotContain("signatureKeyId"); // Not set, so excluded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_FormatsTimestampCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var proof = CreateTestProof();
|
||||
|
||||
// Act
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
// Assert - ISO 8601 UTC format
|
||||
json.Should().Contain("2026-01-05T12:00:00.000Z");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsTrueForValidProof()
|
||||
{
|
||||
// Arrange
|
||||
var proof = CreateTestProof();
|
||||
var compact = proof.ToCompactString();
|
||||
var canonicalJson = proof.ToCanonicalJson();
|
||||
|
||||
// Act
|
||||
var isValid = ReplayProof.ValidateCompactString(compact, canonicalJson);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsFalseForTamperedJson()
|
||||
{
|
||||
// Arrange
|
||||
var proof = CreateTestProof();
|
||||
var compact = proof.ToCompactString();
|
||||
var tamperedJson = proof.ToCanonicalJson().Replace("1.0.0", "2.0.0");
|
||||
|
||||
// Act
|
||||
var isValid = ReplayProof.ValidateCompactString(compact, tamperedJson);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("tampered JSON should not validate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsFalseForInvalidPrefix()
|
||||
{
|
||||
// Arrange
|
||||
var canonicalJson = CreateTestProof().ToCanonicalJson();
|
||||
|
||||
// Act
|
||||
var isValid = ReplayProof.ValidateCompactString("invalid-proof:abc123", canonicalJson);
|
||||
|
||||
// Assert
|
||||
isValid.Should().BeFalse("invalid prefix should not validate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCompactString_ReturnsFalseForEmptyInputs()
|
||||
{
|
||||
// Act & Assert
|
||||
ReplayProof.ValidateCompactString("", "{}").Should().BeFalse();
|
||||
ReplayProof.ValidateCompactString("replay-proof:abc", "").Should().BeFalse();
|
||||
ReplayProof.ValidateCompactString(null!, "{}").Should().BeFalse();
|
||||
ReplayProof.ValidateCompactString("replay-proof:abc", null!).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_IncludesMetadataWhenPresent()
|
||||
{
|
||||
// Arrange
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: "1.0.0",
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("tenant", "acme-corp")
|
||||
.Add("project", "web-app"));
|
||||
|
||||
// Act
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
// Assert
|
||||
json.Should().Contain("metadata");
|
||||
json.Should().Contain("tenant");
|
||||
json.Should().Contain("acme-corp");
|
||||
json.Should().Contain("project");
|
||||
json.Should().Contain("web-app");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToCanonicalJson_SortsMetadataKeys()
|
||||
{
|
||||
// Arrange
|
||||
var proof = ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: "1.0.0",
|
||||
metadata: ImmutableDictionary<string, string>.Empty
|
||||
.Add("zebra", "z-value")
|
||||
.Add("alpha", "a-value")
|
||||
.Add("mike", "m-value"));
|
||||
|
||||
// Act
|
||||
var json = proof.ToCanonicalJson();
|
||||
|
||||
// Assert - Metadata keys should be in alphabetical order
|
||||
var alphaPos = json.IndexOf("alpha", StringComparison.Ordinal);
|
||||
var mikePos = json.IndexOf("mike", StringComparison.Ordinal);
|
||||
var zebraPos = json.IndexOf("zebra", StringComparison.Ordinal);
|
||||
|
||||
alphaPos.Should().BeLessThan(mikePos);
|
||||
mikePos.Should().BeLessThan(zebraPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromExecutionResult_ThrowsOnNullRequiredParams()
|
||||
{
|
||||
// Act & Assert
|
||||
var act1 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: null!,
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
act1.Should().Throw<ArgumentNullException>().WithParameterName("bundleHash");
|
||||
|
||||
var act2 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: null!,
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
act2.Should().Throw<ArgumentNullException>().WithParameterName("policyVersion");
|
||||
|
||||
var act3 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: null!,
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: "1.0.0");
|
||||
act3.Should().Throw<ArgumentNullException>().WithParameterName("verdictRoot");
|
||||
|
||||
var act4 = () => ReplayProof.FromExecutionResult(
|
||||
bundleHash: "sha256:abc123",
|
||||
policyVersion: "1.0.0",
|
||||
verdictRoot: "sha256:def456",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
engineVersion: null!);
|
||||
act4.Should().Throw<ArgumentNullException>().WithParameterName("engineVersion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SchemaVersion_DefaultsTo1_0_0()
|
||||
{
|
||||
// Arrange & Act
|
||||
var proof = CreateTestProof();
|
||||
|
||||
// Assert
|
||||
proof.SchemaVersion.Should().Be("1.0.0");
|
||||
}
|
||||
private static readonly DateTimeOffset _fixedTimestamp = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static ReplayProof CreateTestProof()
|
||||
{
|
||||
@@ -301,7 +26,7 @@ public class ReplayProofTests
|
||||
verdictRoot: "sha256:verdict789",
|
||||
verdictMatches: true,
|
||||
durationMs: 150,
|
||||
replayedAt: FixedTimestamp,
|
||||
replayedAt: _fixedTimestamp,
|
||||
engineVersion: "1.0.0",
|
||||
artifactDigest: "sha256:image123",
|
||||
signatureVerified: true,
|
||||
|
||||
@@ -8,4 +8,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0106-M | DONE | Revalidated 2026-01-08; maintainability audit for Replay.Core.Tests. |
|
||||
| AUDIT-0106-T | DONE | Revalidated 2026-01-08; test coverage audit for Replay.Core.Tests. |
|
||||
| AUDIT-0106-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-06 | DONE | SOLID review notes refreshed 2026-02-04; tests split <=100 lines; dotnet test passed (64). |
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Validation;
|
||||
|
||||
public sealed partial class DeterminismManifestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_InvalidHashAlgorithm_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "MD5",
|
||||
"value": "abc123",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "canonicalHash.algorithm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidHashValue_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "canonicalHash.value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Validation;
|
||||
|
||||
public sealed partial class DeterminismManifestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_InvalidJson_ReturnsError()
|
||||
{
|
||||
var json = "{ invalid json }";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidBaseImageDigest_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "verdict",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [{"name": "test", "version": "1.0"}]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z",
|
||||
"inputs": {
|
||||
"baseImageDigest": "def456def456def456def456def456def456def456def456def456def456def4"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "inputs.baseImageDigest");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Validation;
|
||||
|
||||
public sealed partial class DeterminismManifestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_MissingRequiredField_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "artifact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidArtifactType_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "invalid-type",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "artifact.type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Validation;
|
||||
|
||||
public sealed partial class DeterminismManifestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_UnsupportedSchemaVersion_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "2.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "schemaVersion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidTimestamp_ReturnsError()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "not-a-timestamp"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "generatedAt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Validation;
|
||||
|
||||
public sealed partial class DeterminismManifestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_ValidManifest_ReturnsValid()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "alpine-3.18",
|
||||
"version": "2025-12-26T00:00:00Z",
|
||||
"format": "SPDX 3.0.1"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [
|
||||
{"name": "StellaOps.Scanner", "version": "1.0.0"}
|
||||
]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithInputs_ValidatesHashFormats()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "verdict",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [{"name": "test", "version": "1.0"}]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z",
|
||||
"inputs": {
|
||||
"feedSnapshotHash": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"baseImageDigest": "sha256:def456def456def456def456def456def456def456def456def456def456def4"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Validation;
|
||||
|
||||
public sealed partial class DeterminismManifestValidatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_EmptyComponentsArray_ReturnsWarning()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "verdict",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Path == "toolchain.components");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SbomWithoutFormat_ReturnsWarning()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [
|
||||
{"name": "test", "version": "1.0"}
|
||||
]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Path == "artifact.format");
|
||||
}
|
||||
}
|
||||
@@ -1,399 +1,11 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DeterminismManifestValidatorTests.cs
|
||||
// Sprint: SPRINT_20251226_007_BE_determinism_gaps
|
||||
// Task: DET-GAP-10
|
||||
// Description: Tests for determinism manifest validator
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Replay.Core.Validation;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Validation;
|
||||
|
||||
public sealed class DeterminismManifestValidatorTests
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed partial class DeterminismManifestValidatorTests
|
||||
{
|
||||
private readonly DeterminismManifestValidator _validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidManifest_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "alpine-3.18",
|
||||
"version": "2025-12-26T00:00:00Z",
|
||||
"format": "SPDX 3.0.1"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [
|
||||
{"name": "StellaOps.Scanner", "version": "1.0.0"}
|
||||
]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissingRequiredField_ReturnsError()
|
||||
{
|
||||
// Arrange - missing "artifact"
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "artifact");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidArtifactType_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "invalid-type",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "artifact.type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidHashAlgorithm_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "MD5",
|
||||
"value": "abc123",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "canonicalHash.algorithm");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidHashValue_ReturnsError()
|
||||
{
|
||||
// Arrange - hash value too short
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "canonicalHash.value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnsupportedSchemaVersion_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "2.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "schemaVersion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidTimestamp_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "not-a-timestamp"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "generatedAt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EmptyComponentsArray_ReturnsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "verdict",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": []
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Path == "toolchain.components");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SbomWithoutFormat_ReturnsWarning()
|
||||
{
|
||||
// Arrange - sbom without format specified
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "sbom",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [
|
||||
{"name": "test", "version": "1.0"}
|
||||
]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z"
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Contains(result.Warnings, w => w.Path == "artifact.format");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidJson_ReturnsError()
|
||||
{
|
||||
// Arrange
|
||||
var json = "{ invalid json }";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "$");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithInputs_ValidatesHashFormats()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "verdict",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [{"name": "test", "version": "1.0"}]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z",
|
||||
"inputs": {
|
||||
"feedSnapshotHash": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"baseImageDigest": "sha256:def456def456def456def456def456def456def456def456def456def456def4"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidBaseImageDigest_ReturnsError()
|
||||
{
|
||||
// Arrange - missing sha256: prefix
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"artifact": {
|
||||
"type": "verdict",
|
||||
"name": "test",
|
||||
"version": "1.0"
|
||||
},
|
||||
"canonicalHash": {
|
||||
"algorithm": "SHA-256",
|
||||
"value": "abc123abc123abc123abc123abc123abc123abc123abc123abc123abc123abc1",
|
||||
"encoding": "hex"
|
||||
},
|
||||
"toolchain": {
|
||||
"platform": ".NET 10.0.0",
|
||||
"components": [{"name": "test", "version": "1.0"}]
|
||||
},
|
||||
"generatedAt": "2025-12-26T12:00:00Z",
|
||||
"inputs": {
|
||||
"baseImageDigest": "def456def456def456def456def456def456def456def456def456def456def4"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = _validator.Validate(json);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.Path == "inputs.baseImageDigest");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user