save progress
This commit is contained in:
@@ -12,10 +12,11 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Replay.Core/StellaOps.Replay.Core.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.AuditPack/StellaOps.AuditPack.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../StellaOps.Replay.WebService/StellaOps.Replay.WebService.csproj" />
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
// <copyright file="DeterminismVerifierTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DeterminismVerifier"/>.
|
||||
/// Sprint: SPRINT_20260107_006_005 Task RB-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DeterminismVerifierTests
|
||||
{
|
||||
private readonly Mock<ILogger<DeterminismVerifier>> _logger;
|
||||
private readonly DeterminismVerifier _sut;
|
||||
|
||||
public DeterminismVerifierTests()
|
||||
{
|
||||
_logger = new Mock<ILogger<DeterminismVerifier>>();
|
||||
_sut = new DeterminismVerifier(_logger.Object);
|
||||
}
|
||||
|
||||
#region Verify Tests
|
||||
|
||||
[Fact]
|
||||
public void Verify_IdenticalVerdicts_ReturnsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeTrue();
|
||||
result.Differences.Should().BeEmpty();
|
||||
result.DeterminismScore.Should().Be(1.0);
|
||||
result.OriginalDigest.Should().Be(result.ReplayDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_DifferentOutcome_ReturnsNonDeterministicWithCriticalDifference()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Fail, "medium", findings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeFalse();
|
||||
result.Differences.Should().ContainSingle(d =>
|
||||
d.Field == "Outcome" &&
|
||||
d.Severity == DifferenceSeverity.Critical);
|
||||
result.DeterminismScore.Should().BeLessThan(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_DifferentSeverity_ReturnsNonDeterministicWithHighDifference()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Pass, "high", findings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeFalse();
|
||||
result.Differences.Should().ContainSingle(d =>
|
||||
d.Field == "Severity" &&
|
||||
d.Severity == DifferenceSeverity.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_DifferentFindingCount_ReturnsNonDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var originalFindings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var replayFindings = ImmutableArray<FindingRecord>.Empty;
|
||||
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", originalFindings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", replayFindings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeFalse();
|
||||
result.Differences.Should().Contain(d => d.Field == "FindingCount");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_MissingFindingInReplay_ReturnsNonDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var originalFindings = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "high" },
|
||||
new FindingRecord { FindingId = "f2", VulnerabilityId = "CVE-2", Component = "pkg:b", Severity = "medium" });
|
||||
var replayFindings = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "high" });
|
||||
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", originalFindings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", replayFindings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeFalse();
|
||||
result.Differences.Should().Contain(d =>
|
||||
d.Field == "Finding:f2" &&
|
||||
d.OriginalValue == "Present" &&
|
||||
d.ReplayValue == "Missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_NewFindingInReplay_ReturnsNonDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var originalFindings = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "high" });
|
||||
var replayFindings = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "high" },
|
||||
new FindingRecord { FindingId = "f2", VulnerabilityId = "CVE-2", Component = "pkg:b", Severity = "medium" });
|
||||
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", originalFindings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", replayFindings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeFalse();
|
||||
result.Differences.Should().Contain(d =>
|
||||
d.Field == "Finding:f2" &&
|
||||
d.OriginalValue == "Missing" &&
|
||||
d.ReplayValue == "Present");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_FindingSeverityDiffers_ReturnsNonDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var originalFindings = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "high" });
|
||||
var replayFindings = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "critical" });
|
||||
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", originalFindings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", replayFindings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeFalse();
|
||||
result.Differences.Should().Contain(d =>
|
||||
d.Field == "Finding:f1:Severity" &&
|
||||
d.OriginalValue == "high" &&
|
||||
d.ReplayValue == "critical");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_DifferentRuleOrder_ReturnsNonDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var original = CreateVerdictWithRules("verdict-1", VerdictOutcome.Pass, "medium", findings,
|
||||
["rule-1", "rule-2", "rule-3"]);
|
||||
var replay = CreateVerdictWithRules("verdict-1", VerdictOutcome.Pass, "medium", findings,
|
||||
["rule-1", "rule-3", "rule-2"]);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.IsDeterministic.Should().BeFalse();
|
||||
result.Differences.Should().Contain(d =>
|
||||
d.Field == "RuleOrder" &&
|
||||
d.Severity == DifferenceSeverity.Low);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ComputeVerdictDigest Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeVerdictDigest_SameVerdict_ProducesSameDigest()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var verdict1 = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
var verdict2 = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
|
||||
// Act
|
||||
var digest1 = _sut.ComputeVerdictDigest(verdict1);
|
||||
var digest2 = _sut.ComputeVerdictDigest(verdict2);
|
||||
|
||||
// Assert
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeVerdictDigest_DifferentVerdict_ProducesDifferentDigest()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var verdict1 = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
var verdict2 = CreateVerdict("verdict-1", VerdictOutcome.Fail, "medium", findings);
|
||||
|
||||
// Act
|
||||
var digest1 = _sut.ComputeVerdictDigest(verdict1);
|
||||
var digest2 = _sut.ComputeVerdictDigest(verdict2);
|
||||
|
||||
// Assert
|
||||
digest1.Should().NotBe(digest2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeVerdictDigest_ReturnsSha256Format()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var verdict = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
|
||||
// Act
|
||||
var digest = _sut.ComputeVerdictDigest(verdict);
|
||||
|
||||
// Assert
|
||||
digest.Should().StartWith("sha256:");
|
||||
digest.Length.Should().Be(7 + 64); // "sha256:" + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeVerdictDigest_FindingOrderDoesNotAffectDigest()
|
||||
{
|
||||
// Arrange
|
||||
var findings1 = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "high" },
|
||||
new FindingRecord { FindingId = "f2", VulnerabilityId = "CVE-2", Component = "pkg:b", Severity = "medium" });
|
||||
var findings2 = ImmutableArray.Create(
|
||||
new FindingRecord { FindingId = "f2", VulnerabilityId = "CVE-2", Component = "pkg:b", Severity = "medium" },
|
||||
new FindingRecord { FindingId = "f1", VulnerabilityId = "CVE-1", Component = "pkg:a", Severity = "high" });
|
||||
|
||||
var verdict1 = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings1);
|
||||
var verdict2 = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings2);
|
||||
|
||||
// Act
|
||||
var digest1 = _sut.ComputeVerdictDigest(verdict1);
|
||||
var digest2 = _sut.ComputeVerdictDigest(verdict2);
|
||||
|
||||
// Assert - should be the same due to deterministic ordering in digest calculation
|
||||
digest1.Should().Be(digest2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GenerateDiffReport Tests
|
||||
|
||||
[Fact]
|
||||
public void GenerateDiffReport_MatchingResult_ContainsMatchMessage()
|
||||
{
|
||||
// Arrange
|
||||
var result = new VerificationResult
|
||||
{
|
||||
OriginalDigest = "sha256:abc123",
|
||||
ReplayDigest = "sha256:abc123",
|
||||
IsDeterministic = true,
|
||||
DeterminismScore = 1.0,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var report = _sut.GenerateDiffReport(result);
|
||||
|
||||
// Assert
|
||||
report.Should().Contain("## Result: MATCH");
|
||||
report.Should().Contain("identical verdict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDiffReport_MismatchResult_ContainsDifferences()
|
||||
{
|
||||
// Arrange
|
||||
var result = new VerificationResult
|
||||
{
|
||||
OriginalDigest = "sha256:abc123",
|
||||
ReplayDigest = "sha256:def456",
|
||||
IsDeterministic = false,
|
||||
DeterminismScore = 0.5,
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
Differences = ImmutableArray.Create(
|
||||
new VerdictDifference
|
||||
{
|
||||
Field = "Outcome",
|
||||
OriginalValue = "Pass",
|
||||
ReplayValue = "Fail",
|
||||
Severity = DifferenceSeverity.Critical,
|
||||
Explanation = "The final decision differs"
|
||||
})
|
||||
};
|
||||
|
||||
// Act
|
||||
var report = _sut.GenerateDiffReport(result);
|
||||
|
||||
// Assert
|
||||
report.Should().Contain("## Result: MISMATCH");
|
||||
report.Should().Contain("### Outcome");
|
||||
report.Should().Contain("**Original:** `Pass`");
|
||||
report.Should().Contain("**Replay:** `Fail`");
|
||||
report.Should().Contain("## Possible Causes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateDiffReport_ContainsDigestTable()
|
||||
{
|
||||
// Arrange
|
||||
var result = new VerificationResult
|
||||
{
|
||||
OriginalDigest = "sha256:originaldigest",
|
||||
ReplayDigest = "sha256:replaydigest",
|
||||
IsDeterministic = false,
|
||||
DeterminismScore = 0.8,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var report = _sut.GenerateDiffReport(result);
|
||||
|
||||
// Assert
|
||||
report.Should().Contain("## Digests");
|
||||
report.Should().Contain("`sha256:originaldigest`");
|
||||
report.Should().Contain("`sha256:replaydigest`");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeterminismScore Tests
|
||||
|
||||
[Fact]
|
||||
public void Verify_NoDifferences_ScoreIsOne()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.DeterminismScore.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_CriticalDifference_ScoreDecreasesSignificantly()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var original = CreateVerdict("verdict-1", VerdictOutcome.Pass, "medium", findings);
|
||||
var replay = CreateVerdict("verdict-1", VerdictOutcome.Fail, "medium", findings);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.DeterminismScore.Should().BeLessThanOrEqualTo(0.5); // Critical penalty is 0.5
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_MultipleLowDifferences_ScoreDecreasesModestly()
|
||||
{
|
||||
// Arrange
|
||||
var findings = CreateFindings("finding-1", "CVE-2023-1234", "pkg:npm/lodash@4.17.21", "high");
|
||||
var original = CreateVerdictWithRules("verdict-1", VerdictOutcome.Pass, "medium", findings,
|
||||
["rule-1", "rule-2", "rule-3"]);
|
||||
var replay = CreateVerdictWithRules("verdict-1", VerdictOutcome.Pass, "medium", findings,
|
||||
["rule-3", "rule-2", "rule-1"]);
|
||||
|
||||
// Act
|
||||
var result = _sut.Verify(original, replay);
|
||||
|
||||
// Assert
|
||||
result.DeterminismScore.Should().BeGreaterThan(0.8); // Low penalty of 0.05
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static ImmutableArray<FindingRecord> CreateFindings(
|
||||
string findingId,
|
||||
string cveId,
|
||||
string component,
|
||||
string severity)
|
||||
{
|
||||
return ImmutableArray.Create(new FindingRecord
|
||||
{
|
||||
FindingId = findingId,
|
||||
VulnerabilityId = cveId,
|
||||
Component = component,
|
||||
Severity = severity
|
||||
});
|
||||
}
|
||||
|
||||
private static VerdictRecord CreateVerdict(
|
||||
string verdictId,
|
||||
VerdictOutcome outcome,
|
||||
string severity,
|
||||
ImmutableArray<FindingRecord> findings)
|
||||
{
|
||||
return new VerdictRecord
|
||||
{
|
||||
VerdictId = verdictId,
|
||||
Outcome = outcome,
|
||||
Severity = severity,
|
||||
PolicyId = "policy-default",
|
||||
RuleIds = ImmutableArray.Create("rule-1"),
|
||||
Findings = findings,
|
||||
RenderedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static VerdictRecord CreateVerdictWithRules(
|
||||
string verdictId,
|
||||
VerdictOutcome outcome,
|
||||
string severity,
|
||||
ImmutableArray<FindingRecord> findings,
|
||||
string[] ruleIds)
|
||||
{
|
||||
return new VerdictRecord
|
||||
{
|
||||
VerdictId = verdictId,
|
||||
Outcome = outcome,
|
||||
Severity = severity,
|
||||
PolicyId = "policy-default",
|
||||
RuleIds = ruleIds.ToImmutableArray(),
|
||||
Findings = findings,
|
||||
RenderedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// <copyright file="InputManifestResolverTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using StellaOps.Replay.Core;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests.Unit;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InputManifestResolver"/>.
|
||||
/// Sprint: SPRINT_20260107_006_005 Task RB-010
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InputManifestResolverTests : IDisposable
|
||||
{
|
||||
private readonly Mock<IFeedSnapshotStore> _feedStore;
|
||||
private readonly Mock<IPolicyManifestStore> _policyStore;
|
||||
private readonly Mock<IVexDocumentStore> _vexStore;
|
||||
private readonly MemoryCache _cache;
|
||||
private readonly Mock<ILogger<InputManifestResolver>> _logger;
|
||||
private readonly InputManifestResolver _sut;
|
||||
|
||||
public InputManifestResolverTests()
|
||||
{
|
||||
_feedStore = new Mock<IFeedSnapshotStore>();
|
||||
_policyStore = new Mock<IPolicyManifestStore>();
|
||||
_vexStore = new Mock<IVexDocumentStore>();
|
||||
_cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_logger = new Mock<ILogger<InputManifestResolver>>();
|
||||
_sut = new InputManifestResolver(
|
||||
_feedStore.Object,
|
||||
_policyStore.Object,
|
||||
_vexStore.Object,
|
||||
_cache,
|
||||
_logger.Object);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cache.Dispose();
|
||||
}
|
||||
|
||||
#region ResolveAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_EmptyManifest_ReturnsCompleteWithNoData()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = new InputManifest();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
result.FeedData.Should().BeNull();
|
||||
result.PolicyBundle.Should().BeNull();
|
||||
result.VexDocuments.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FeedSnapshotExists_ResolvesFeedData()
|
||||
{
|
||||
// Arrange
|
||||
var feedHash = "sha256:abc123";
|
||||
var feedData = new FeedData
|
||||
{
|
||||
Hash = feedHash,
|
||||
Content = "test feed content"u8.ToArray(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_feedStore.Setup(x => x.GetAsync(feedHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(feedData);
|
||||
|
||||
var manifest = new InputManifest { FeedSnapshotHash = feedHash };
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.FeedData.Should().Be(feedData);
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FeedSnapshotNotFound_AddsError()
|
||||
{
|
||||
// Arrange
|
||||
var feedHash = "sha256:notfound";
|
||||
_feedStore.Setup(x => x.GetAsync(feedHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((FeedData?)null);
|
||||
|
||||
var manifest = new InputManifest { FeedSnapshotHash = feedHash };
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeFalse();
|
||||
result.FeedData.Should().BeNull();
|
||||
result.Errors.Should().ContainSingle(e =>
|
||||
e.Type == InputType.FeedSnapshot &&
|
||||
e.Hash == feedHash &&
|
||||
e.Message.Contains("Not found"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PolicyManifestExists_ResolvesPolicyBundle()
|
||||
{
|
||||
// Arrange
|
||||
var policyHash = "sha256:policy123";
|
||||
var policyBundle = new PolicyBundle
|
||||
{
|
||||
Hash = policyHash,
|
||||
Content = ImmutableArray.Create((byte)1, (byte)2, (byte)3),
|
||||
Version = "1.0.0"
|
||||
};
|
||||
|
||||
_policyStore.Setup(x => x.GetAsync(policyHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(policyBundle);
|
||||
|
||||
var manifest = new InputManifest { PolicyManifestHash = policyHash };
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.PolicyBundle.Should().Be(policyBundle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_VexDocumentsExist_ResolvesAllDocuments()
|
||||
{
|
||||
// Arrange
|
||||
var vex1 = new VexDocument { Hash = "sha256:vex1", Content = "{}", Format = "OpenVEX" };
|
||||
var vex2 = new VexDocument { Hash = "sha256:vex2", Content = "{}", Format = "CSAF" };
|
||||
|
||||
_vexStore.Setup(x => x.GetAsync("sha256:vex1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(vex1);
|
||||
_vexStore.Setup(x => x.GetAsync("sha256:vex2", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(vex2);
|
||||
|
||||
var manifest = new InputManifest
|
||||
{
|
||||
VexDocumentHashes = ImmutableArray.Create("sha256:vex1", "sha256:vex2")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeTrue();
|
||||
result.VexDocuments.Should().HaveCount(2);
|
||||
result.VexDocuments.Should().Contain(vex1);
|
||||
result.VexDocuments.Should().Contain(vex2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PartialVexNotFound_AddsErrorButIncludesFound()
|
||||
{
|
||||
// Arrange
|
||||
var vex1 = new VexDocument { Hash = "sha256:vex1", Content = "{}", Format = "OpenVEX" };
|
||||
|
||||
_vexStore.Setup(x => x.GetAsync("sha256:vex1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(vex1);
|
||||
_vexStore.Setup(x => x.GetAsync("sha256:vex2", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((VexDocument?)null);
|
||||
|
||||
var manifest = new InputManifest
|
||||
{
|
||||
VexDocumentHashes = ImmutableArray.Create("sha256:vex1", "sha256:vex2")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeFalse();
|
||||
result.VexDocuments.Should().ContainSingle(v => v.Hash == "sha256:vex1");
|
||||
result.Errors.Should().ContainSingle(e =>
|
||||
e.Type == InputType.VexDocument &&
|
||||
e.Hash == "sha256:vex2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_FeedStoreThrowsException_AddsError()
|
||||
{
|
||||
// Arrange
|
||||
var feedHash = "sha256:error";
|
||||
_feedStore.Setup(x => x.GetAsync(feedHash, It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Connection failed"));
|
||||
|
||||
var manifest = new InputManifest { FeedSnapshotHash = feedHash };
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsComplete.Should().BeFalse();
|
||||
result.FeedData.Should().BeNull();
|
||||
result.Errors.Should().ContainSingle(e =>
|
||||
e.Type == InputType.FeedSnapshot &&
|
||||
e.Message.Contains("Connection failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_PassThroughFields_CopiedToResult()
|
||||
{
|
||||
// Arrange
|
||||
var timestamp = DateTimeOffset.Parse("2026-01-09T12:00:00Z");
|
||||
var manifest = new InputManifest
|
||||
{
|
||||
SourceCodeHash = "sha256:source",
|
||||
BaseImageDigest = "sha256:baseimage",
|
||||
ToolchainVersion = "1.2.3",
|
||||
RandomSeed = 42,
|
||||
TimestampOverride = timestamp
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.SourceCodeHash.Should().Be("sha256:source");
|
||||
result.BaseImageDigest.Should().Be("sha256:baseimage");
|
||||
result.ToolchainVersion.Should().Be("1.2.3");
|
||||
result.RandomSeed.Should().Be(42);
|
||||
result.TimestampOverride.Should().Be(timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_CachesResolvedInputs()
|
||||
{
|
||||
// Arrange
|
||||
var feedHash = "sha256:cacheable";
|
||||
var feedData = new FeedData
|
||||
{
|
||||
Hash = feedHash,
|
||||
Content = "cached"u8.ToArray(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_feedStore.Setup(x => x.GetAsync(feedHash, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(feedData);
|
||||
|
||||
var manifest = new InputManifest { FeedSnapshotHash = feedHash };
|
||||
|
||||
// Act - first call
|
||||
await _sut.ResolveAsync(manifest);
|
||||
// Act - second call
|
||||
await _sut.ResolveAsync(manifest);
|
||||
|
||||
// Assert - store should only be called once due to caching
|
||||
_feedStore.Verify(x => x.GetAsync(feedHash, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ValidateAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_AllInputsExist_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
_feedStore.Setup(x => x.ExistsAsync("sha256:feed", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_policyStore.Setup(x => x.ExistsAsync("sha256:policy", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_vexStore.Setup(x => x.ExistsAsync("sha256:vex", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var manifest = new InputManifest
|
||||
{
|
||||
FeedSnapshotHash = "sha256:feed",
|
||||
PolicyManifestHash = "sha256:policy",
|
||||
VexDocumentHashes = ImmutableArray.Create("sha256:vex")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.MissingInputs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_FeedMissing_ReturnsInvalidWithMissingList()
|
||||
{
|
||||
// Arrange
|
||||
_feedStore.Setup(x => x.ExistsAsync("sha256:missing", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var manifest = new InputManifest { FeedSnapshotHash = "sha256:missing" };
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.MissingInputs.Should().ContainSingle(m => m.Contains("Feed snapshot") && m.Contains("sha256:missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_PolicyMissing_ReturnsInvalidWithMissingList()
|
||||
{
|
||||
// Arrange
|
||||
_policyStore.Setup(x => x.ExistsAsync("sha256:missing", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var manifest = new InputManifest { PolicyManifestHash = "sha256:missing" };
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.MissingInputs.Should().ContainSingle(m => m.Contains("Policy manifest"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_VexMissing_ReturnsInvalidWithMissingList()
|
||||
{
|
||||
// Arrange
|
||||
_vexStore.Setup(x => x.ExistsAsync("sha256:vex1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
_vexStore.Setup(x => x.ExistsAsync("sha256:vex2", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var manifest = new InputManifest
|
||||
{
|
||||
VexDocumentHashes = ImmutableArray.Create("sha256:vex1", "sha256:vex2")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.MissingInputs.Should().ContainSingle(m => m.Contains("VEX document") && m.Contains("sha256:vex2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_EmptyManifest_ReturnsValid()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = new InputManifest();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ValidateAsync(manifest);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.MissingInputs.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InputManifest Model Tests
|
||||
|
||||
[Fact]
|
||||
public void InputManifest_DefaultValues_AreCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var manifest = new InputManifest();
|
||||
|
||||
// Assert
|
||||
manifest.FeedSnapshotHash.Should().BeNull();
|
||||
manifest.PolicyManifestHash.Should().BeNull();
|
||||
manifest.SourceCodeHash.Should().BeNull();
|
||||
manifest.BaseImageDigest.Should().BeNull();
|
||||
manifest.VexDocumentHashes.Should().BeEmpty();
|
||||
manifest.ToolchainVersion.Should().BeNull();
|
||||
manifest.RandomSeed.Should().BeNull();
|
||||
manifest.TimestampOverride.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InputManifest_WithInitializer_SetsValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var manifest = new InputManifest
|
||||
{
|
||||
FeedSnapshotHash = "feed",
|
||||
PolicyManifestHash = "policy",
|
||||
SourceCodeHash = "source",
|
||||
BaseImageDigest = "image",
|
||||
VexDocumentHashes = ImmutableArray.Create("vex1", "vex2"),
|
||||
ToolchainVersion = "1.0",
|
||||
RandomSeed = 42,
|
||||
TimestampOverride = timestamp
|
||||
};
|
||||
|
||||
// Assert
|
||||
manifest.FeedSnapshotHash.Should().Be("feed");
|
||||
manifest.PolicyManifestHash.Should().Be("policy");
|
||||
manifest.SourceCodeHash.Should().Be("source");
|
||||
manifest.BaseImageDigest.Should().Be("image");
|
||||
manifest.VexDocumentHashes.Should().HaveCount(2);
|
||||
manifest.ToolchainVersion.Should().Be("1.0");
|
||||
manifest.RandomSeed.Should().Be(42);
|
||||
manifest.TimestampOverride.Should().Be(timestamp);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ResolvedInputs Model Tests
|
||||
|
||||
[Fact]
|
||||
public void ResolvedInputs_DefaultValues_AreCorrect()
|
||||
{
|
||||
// Arrange & Act
|
||||
var resolved = new ResolvedInputs();
|
||||
|
||||
// Assert
|
||||
resolved.FeedData.Should().BeNull();
|
||||
resolved.PolicyBundle.Should().BeNull();
|
||||
resolved.VexDocuments.Should().BeEmpty();
|
||||
resolved.Errors.Should().BeEmpty();
|
||||
resolved.IsComplete.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region InputResolutionError Model Tests
|
||||
|
||||
[Fact]
|
||||
public void InputResolutionError_RecordEquality_Works()
|
||||
{
|
||||
// Arrange
|
||||
var error1 = new InputResolutionError(InputType.FeedSnapshot, "hash1", "message1");
|
||||
var error2 = new InputResolutionError(InputType.FeedSnapshot, "hash1", "message1");
|
||||
var error3 = new InputResolutionError(InputType.PolicyManifest, "hash1", "message1");
|
||||
|
||||
// Assert
|
||||
error1.Should().Be(error2);
|
||||
error1.Should().NotBe(error3);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -13,6 +13,8 @@ using StellaOps.AuditPack.Services;
|
||||
using StellaOps.Replay.WebService;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using AuditPackResult = StellaOps.AuditPack.Services.ReplayExecutionResult;
|
||||
using AuditPackStatus = StellaOps.AuditPack.Services.ReplayStatus;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
@@ -52,12 +54,12 @@ public class VerdictReplayEndpointsTests
|
||||
};
|
||||
}
|
||||
|
||||
private static ReplayExecutionResult CreateSuccessResult(bool match = true)
|
||||
private static AuditPackResult CreateSuccessResult(bool match = true)
|
||||
{
|
||||
return new ReplayExecutionResult
|
||||
return new AuditPackResult
|
||||
{
|
||||
Success = true,
|
||||
Status = match ? ReplayStatus.Match : ReplayStatus.Drift,
|
||||
Status = match ? AuditPackStatus.Match : AuditPackStatus.Drift,
|
||||
VerdictMatches = match,
|
||||
DecisionMatches = match,
|
||||
OriginalVerdictDigest = "sha256:verdict",
|
||||
@@ -115,7 +117,7 @@ public class VerdictReplayEndpointsTests
|
||||
ConfidenceScore = 0.95,
|
||||
ExpectedOutcome = new ReplayOutcomePrediction
|
||||
{
|
||||
ExpectedStatus = ReplayStatus.Match,
|
||||
ExpectedStatus = AuditPackStatus.Match,
|
||||
ExpectedDecision = "pass"
|
||||
}
|
||||
});
|
||||
@@ -155,14 +157,14 @@ public class VerdictReplayEndpointsTests
|
||||
public void CompareDivergence_DetectsDifferences()
|
||||
{
|
||||
// Arrange
|
||||
var original = new ReplayExecutionResult
|
||||
var original = new AuditPackResult
|
||||
{
|
||||
Success = true,
|
||||
OriginalVerdictDigest = "sha256:aaa",
|
||||
OriginalDecision = "pass"
|
||||
};
|
||||
|
||||
var replayed = new ReplayExecutionResult
|
||||
var replayed = new AuditPackResult
|
||||
{
|
||||
Success = true,
|
||||
ReplayedVerdictDigest = "sha256:bbb",
|
||||
@@ -174,8 +176,8 @@ public class VerdictReplayEndpointsTests
|
||||
};
|
||||
|
||||
_mockPredicate.Setup(p => p.CompareDivergence(
|
||||
It.IsAny<ReplayExecutionResult>(),
|
||||
It.IsAny<ReplayExecutionResult>()))
|
||||
It.IsAny<AuditPackResult>(),
|
||||
It.IsAny<AuditPackResult>()))
|
||||
.Returns(new ReplayDivergenceReport
|
||||
{
|
||||
HasDivergence = true,
|
||||
@@ -218,10 +220,10 @@ public class VerdictReplayEndpointsTests
|
||||
public void ReplayExecutionResult_DriftItems_ArePopulated()
|
||||
{
|
||||
// Arrange
|
||||
var result = new ReplayExecutionResult
|
||||
var result = new AuditPackResult
|
||||
{
|
||||
Success = true,
|
||||
Status = ReplayStatus.Drift,
|
||||
Status = AuditPackStatus.Drift,
|
||||
VerdictMatches = false,
|
||||
Drifts =
|
||||
[
|
||||
|
||||
@@ -11,6 +11,8 @@ using StellaOps.AuditPack.Models;
|
||||
using StellaOps.AuditPack.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
using AuditPackResult = StellaOps.AuditPack.Services.ReplayExecutionResult;
|
||||
using AuditPackStatus = StellaOps.AuditPack.Services.ReplayStatus;
|
||||
|
||||
namespace StellaOps.Replay.Core.Tests;
|
||||
|
||||
@@ -195,7 +197,7 @@ public class VerdictReplayIntegrationTests
|
||||
{
|
||||
// Arrange
|
||||
var attestationService = new ReplayAttestationService();
|
||||
var replays = new List<(AuditBundleManifest, ReplayExecutionResult)>
|
||||
var replays = new List<(AuditBundleManifest, AuditPackResult)>
|
||||
{
|
||||
(CreateTestManifest("bundle-1"), CreateMatchingReplayResult()),
|
||||
(CreateTestManifest("bundle-2"), CreateDivergentReplayResult()),
|
||||
@@ -358,12 +360,12 @@ public class VerdictReplayIntegrationTests
|
||||
};
|
||||
}
|
||||
|
||||
private static ReplayExecutionResult CreateMatchingReplayResult()
|
||||
private static AuditPackResult CreateMatchingReplayResult()
|
||||
{
|
||||
return new ReplayExecutionResult
|
||||
return new AuditPackResult
|
||||
{
|
||||
Success = true,
|
||||
Status = ReplayStatus.Match,
|
||||
Status = AuditPackStatus.Match,
|
||||
VerdictMatches = true,
|
||||
DecisionMatches = true,
|
||||
OriginalVerdictDigest = "sha256:verdict-digest-123",
|
||||
@@ -376,12 +378,12 @@ public class VerdictReplayIntegrationTests
|
||||
};
|
||||
}
|
||||
|
||||
private static ReplayExecutionResult CreateDivergentReplayResult()
|
||||
private static AuditPackResult CreateDivergentReplayResult()
|
||||
{
|
||||
return new ReplayExecutionResult
|
||||
return new AuditPackResult
|
||||
{
|
||||
Success = true,
|
||||
Status = ReplayStatus.Drift,
|
||||
Status = AuditPackStatus.Drift,
|
||||
VerdictMatches = false,
|
||||
DecisionMatches = false,
|
||||
OriginalVerdictDigest = "sha256:verdict-original",
|
||||
|
||||
Reference in New Issue
Block a user