Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
246 lines
8.2 KiB
C#
246 lines
8.2 KiB
C#
// <copyright file="ReplayableVerdictE2ETests.cs" company="Stella Operations">
|
|
// Copyright (c) Stella Operations. Licensed under AGPL-3.0-or-later.
|
|
// </copyright>
|
|
|
|
using System;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.E2E.ReplayableVerdict;
|
|
|
|
/// <summary>
|
|
/// E2E tests for reproducible verdict generation and replay
|
|
/// Sprint: SPRINT_20251229_004_005_E2E
|
|
/// </summary>
|
|
[Trait("Category", "E2E")]
|
|
[Trait("Category", "Determinism")]
|
|
public sealed class ReplayableVerdictE2ETests : IAsyncLifetime
|
|
{
|
|
private const string BundlePath = "../../../fixtures/e2e/bundle-0001";
|
|
private GoldenBundle? _bundle;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_bundle = await GoldenBundle.LoadAsync(BundlePath);
|
|
}
|
|
|
|
public Task DisposeAsync()
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
[Fact(Skip = "E2E-002: Requires full pipeline integration")]
|
|
public async Task FullPipeline_ProducesConsistentVerdict()
|
|
{
|
|
// Arrange
|
|
_bundle.Should().NotBeNull();
|
|
|
|
// This test requires:
|
|
// - Scanner service to process SBOM
|
|
// - VexLens to compute consensus
|
|
// - Verdict builder to generate final verdict
|
|
// Currently skipped until services are integrated
|
|
|
|
// Act
|
|
// var scanResult = await Scanner.ScanAsync(_bundle.ImageDigest);
|
|
// var vexConsensus = await VexLens.ComputeConsensusAsync(scanResult.SbomDigest, _bundle.FeedSnapshot);
|
|
// var verdict = await VerdictBuilder.BuildAsync(evidencePack, _bundle.PolicyLock);
|
|
|
|
// Assert
|
|
// verdict.CgsHash.Should().Be(_bundle.ExpectedVerdictHash);
|
|
}
|
|
|
|
[Fact(Skip = "E2E-003: Requires verdict builder service")]
|
|
public async Task ReplayFromBundle_ProducesIdenticalVerdict()
|
|
{
|
|
// Arrange
|
|
_bundle.Should().NotBeNull();
|
|
var originalVerdictHash = _bundle!.Manifest.ExpectedOutputs.VerdictHash;
|
|
|
|
// Act
|
|
// var replayedVerdict = await VerdictBuilder.ReplayAsync(_bundle.Manifest);
|
|
|
|
// Assert
|
|
// replayedVerdict.CgsHash.Should().Be(originalVerdictHash);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bundle_LoadsSuccessfully()
|
|
{
|
|
// Assert
|
|
_bundle.Should().NotBeNull();
|
|
_bundle!.Manifest.Should().NotBeNull();
|
|
_bundle.Manifest.SchemaVersion.Should().Be("2.0");
|
|
_bundle.Manifest.BundleId.Should().Be("bundle-0001");
|
|
}
|
|
|
|
[Fact]
|
|
public void Bundle_HasValidInputs()
|
|
{
|
|
// Assert
|
|
_bundle.Should().NotBeNull();
|
|
_bundle!.Manifest.Inputs.Should().NotBeNull();
|
|
_bundle.Manifest.Inputs.Sbom.Should().NotBeNull();
|
|
_bundle.Manifest.Inputs.Feeds.Should().NotBeNull();
|
|
_bundle.Manifest.Inputs.Vex.Should().NotBeNull();
|
|
_bundle.Manifest.Inputs.Policy.Should().NotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Bundle_SbomFile_Exists()
|
|
{
|
|
// Arrange
|
|
var sbomPath = Path.Combine(BundlePath, _bundle!.Manifest.Inputs.Sbom.Path);
|
|
|
|
// Assert
|
|
File.Exists(sbomPath).Should().BeTrue($"SBOM file should exist at {sbomPath}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bundle_SbomFile_IsValidJson()
|
|
{
|
|
// Arrange
|
|
var sbomPath = Path.Combine(BundlePath, _bundle!.Manifest.Inputs.Sbom.Path);
|
|
var json = await File.ReadAllTextAsync(sbomPath);
|
|
|
|
// Act
|
|
var doc = JsonDocument.Parse(json);
|
|
|
|
// Assert
|
|
doc.RootElement.TryGetProperty("bomFormat", out var bomFormat).Should().BeTrue();
|
|
bomFormat.GetString().Should().Be("CycloneDX");
|
|
doc.RootElement.TryGetProperty("components", out var components).Should().BeTrue();
|
|
components.GetArrayLength().Should().BeGreaterThan(0);
|
|
}
|
|
|
|
[Fact(Skip = "E2E-004: Requires verdict builder with delta support")]
|
|
public async Task DeltaVerdict_ShowsExpectedChanges()
|
|
{
|
|
// This test requires two bundles (v1 and v2) to compare
|
|
// var bundleV1 = await GoldenBundle.LoadAsync("../../../fixtures/e2e/bundle-0001");
|
|
// var bundleV2 = await GoldenBundle.LoadAsync("../../../fixtures/e2e/bundle-0002");
|
|
|
|
// var verdictV1 = await VerdictBuilder.BuildAsync(bundleV1.ToEvidencePack(), bundleV1.PolicyLock);
|
|
// var verdictV2 = await VerdictBuilder.BuildAsync(bundleV2.ToEvidencePack(), bundleV2.PolicyLock);
|
|
|
|
// var delta = await VerdictBuilder.DiffAsync(verdictV1.CgsHash, verdictV2.CgsHash);
|
|
|
|
// delta.AddedVulns.Should().Contain("CVE-2024-NEW");
|
|
// delta.RemovedVulns.Should().Contain("CVE-2024-FIXED");
|
|
}
|
|
|
|
[Fact(Skip = "E2E-005: Requires DSSE signing service")]
|
|
public async Task Verdict_HasValidDsseSignature()
|
|
{
|
|
// var verdict = await VerdictBuilder.BuildAsync(_bundle.ToEvidencePack(), _bundle.PolicyLock);
|
|
// var dsseEnvelope = await Signer.SignAsync(verdict);
|
|
|
|
// var verificationResult = await Signer.VerifyAsync(dsseEnvelope, _bundle.PublicKey);
|
|
|
|
// verificationResult.IsValid.Should().BeTrue();
|
|
// verificationResult.SignedBy.Should().Be("test-keypair");
|
|
}
|
|
|
|
[Fact(Skip = "E2E-006: Requires network isolation support")]
|
|
public async Task OfflineReplay_ProducesIdenticalVerdict()
|
|
{
|
|
// This test should run with network disabled
|
|
// AssertNoNetworkCalls();
|
|
|
|
// var verdict = await VerdictBuilder.ReplayAsync(_bundle.Manifest);
|
|
|
|
// verdict.CgsHash.Should().Be(_bundle.ExpectedVerdictHash);
|
|
}
|
|
|
|
[Fact(Skip = "E2E-008: Requires cross-platform CI")]
|
|
public async Task CrossPlatformReplay_ProducesIdenticalHash()
|
|
{
|
|
// This test runs on multiple CI runners (Ubuntu, Alpine, Debian)
|
|
// var platform = Environment.OSVersion;
|
|
|
|
// var verdict = await VerdictBuilder.BuildAsync(_bundle.ToEvidencePack(), _bundle.PolicyLock);
|
|
|
|
// verdict.CgsHash.Should().Be(_bundle.ExpectedVerdictHash,
|
|
// $"verdict on {platform} should match golden hash");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Golden bundle loader
|
|
/// </summary>
|
|
internal sealed class GoldenBundle
|
|
{
|
|
public required BundleManifest Manifest { get; init; }
|
|
public required string BasePath { get; init; }
|
|
|
|
public static async Task<GoldenBundle> LoadAsync(string path)
|
|
{
|
|
var manifestPath = Path.Combine(path, "manifest.json");
|
|
if (!File.Exists(manifestPath))
|
|
{
|
|
throw new FileNotFoundException($"Bundle manifest not found: {manifestPath}");
|
|
}
|
|
|
|
var json = await File.ReadAllTextAsync(manifestPath);
|
|
var manifest = JsonSerializer.Deserialize<BundleManifest>(json,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
|
?? throw new InvalidOperationException("Failed to deserialize manifest");
|
|
|
|
return new GoldenBundle
|
|
{
|
|
Manifest = manifest,
|
|
BasePath = path
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bundle manifest schema (ReplayManifest v2)
|
|
/// </summary>
|
|
internal sealed record BundleManifest
|
|
{
|
|
public required string SchemaVersion { get; init; }
|
|
public required string BundleId { get; init; }
|
|
public string? Description { get; init; }
|
|
public required string CreatedAt { get; init; }
|
|
public required ScanInfo Scan { get; init; }
|
|
public required BundleInputs Inputs { get; init; }
|
|
public required BundleOutputs ExpectedOutputs { get; init; }
|
|
public string? Notes { get; init; }
|
|
}
|
|
|
|
internal sealed record ScanInfo
|
|
{
|
|
public required string Id { get; init; }
|
|
public required string ImageDigest { get; init; }
|
|
public required string PolicyDigest { get; init; }
|
|
public required string ScorePolicyDigest { get; init; }
|
|
public required string FeedSnapshotDigest { get; init; }
|
|
public required string Toolchain { get; init; }
|
|
public required string AnalyzerSetDigest { get; init; }
|
|
}
|
|
|
|
internal sealed record BundleInputs
|
|
{
|
|
public required InputFile Sbom { get; init; }
|
|
public required InputFile Feeds { get; init; }
|
|
public required InputFile Vex { get; init; }
|
|
public required InputFile Policy { get; init; }
|
|
}
|
|
|
|
internal sealed record InputFile
|
|
{
|
|
public required string Path { get; init; }
|
|
public required string Sha256 { get; init; }
|
|
}
|
|
|
|
internal sealed record BundleOutputs
|
|
{
|
|
public required InputFile Verdict { get; init; }
|
|
public required string VerdictHash { get; init; }
|
|
}
|