doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -16,4 +16,8 @@
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -11,4 +11,8 @@
<ProjectReference Include="../../StellaOps.Auth.Security/StellaOps.Auth.Security.csproj" />
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -0,0 +1,360 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-005 - Unit tests for GateEvaluator
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.DeltaVerdict.Bundles;
using StellaOps.Signals.EvidenceWeightedScore;
namespace StellaOps.DeltaVerdict.Tests.Bundles;
public class GateEvaluatorTests
{
private readonly GateEvaluator _evaluator = new();
private readonly DateTimeOffset _evaluatedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
[Theory]
[InlineData(0.70, GateAction.Block)] // Above block threshold
[InlineData(0.65, GateAction.Block)] // At block threshold
[InlineData(0.64, GateAction.Warn)] // Below block, above warn
[InlineData(0.50, GateAction.Warn)] // In warn range
[InlineData(0.40, GateAction.Warn)] // At warn threshold
[InlineData(0.39, GateAction.Pass)] // Below warn threshold
[InlineData(0.10, GateAction.Pass)] // Well below all thresholds
public void Evaluate_WithDefaultConfig_ReturnsExpectedAction(double score, GateAction expectedAction)
{
// Arrange
var input = CreateTestInput();
var config = GateConfiguration.Default;
// Act
var decision = _evaluator.Evaluate(score, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(expectedAction);
}
[Fact]
public void Evaluate_BlockDecision_ContainsCorrectThreshold()
{
// Arrange
var input = CreateTestInput();
var config = GateConfiguration.Default;
// Act
var decision = _evaluator.Evaluate(0.70, input, config, _evaluatedAt);
// Assert
decision.Threshold.Should().Be(0.65);
decision.MatchedRules.Should().Contain("block_threshold");
}
[Fact]
public void Evaluate_WithTrustedVexNotAffected_ReturnsPass()
{
// Arrange
var input = CreateTestInput(vexStatus: "not_affected", vexSource: ".vex/document.json");
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
// Act
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt); // Would normally block
// Assert
decision.Action.Should().Be(GateAction.Pass);
decision.MatchedRules.Should().Contain("auto_pass_trusted_vex");
decision.Reason.Should().Contain("not_affected");
}
[Fact]
public void Evaluate_WithTrustedVexFixed_ReturnsPass()
{
// Arrange
var input = CreateTestInput(vexStatus: "fixed", vexSource: "vendor:acme");
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
// Act
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Pass);
}
[Fact]
public void Evaluate_WithUntrustedVexSource_DoesNotAutoPass()
{
// Arrange
var input = CreateTestInput(vexStatus: "not_affected", vexSource: "unknown-source");
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
// Act
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Block); // VEX not authoritative
}
[Fact]
public void Evaluate_WithVexAffected_DoesNotAutoPass()
{
// Arrange
var input = CreateTestInput(vexStatus: "affected", vexSource: "vendor:acme");
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
// Act
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Block); // affected status doesn't auto-pass
}
[Fact]
public void Evaluate_WithAutoPassDisabled_IgnoresVex()
{
// Arrange
var input = CreateTestInput(vexStatus: "not_affected", vexSource: ".vex/document.json");
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = false };
// Act
var decision = _evaluator.Evaluate(0.80, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Block);
}
[Fact]
public void Evaluate_WarnWithHighPatchProof_ReturnsPass()
{
// Arrange
var input = CreateTestInput(patchProofConfidence: 0.75); // Above bypass (0.70)
var config = GateConfiguration.Default;
// Act
var decision = _evaluator.Evaluate(0.50, input, config, _evaluatedAt); // In warn range
// Assert
decision.Action.Should().Be(GateAction.Pass);
decision.MatchedRules.Should().Contain("warn_threshold");
decision.MatchedRules.Should().Contain("patch_proof_bypass");
}
[Fact]
public void Evaluate_WarnWithLowPatchProof_ReturnsWarn()
{
// Arrange
var input = CreateTestInput(patchProofConfidence: 0.50); // Below bypass
var config = GateConfiguration.Default;
// Act
var decision = _evaluator.Evaluate(0.50, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Warn);
}
[Fact]
public void Evaluate_StrictConfig_HasLowerThresholds()
{
// Arrange
var input = CreateTestInput();
var config = GateConfiguration.Strict;
// Act
var decision55 = _evaluator.Evaluate(0.55, input, config, _evaluatedAt);
var decision35 = _evaluator.Evaluate(0.35, input, config, _evaluatedAt);
// Assert
decision55.Action.Should().Be(GateAction.Block); // 0.50 strict block threshold
decision35.Action.Should().Be(GateAction.Warn); // 0.30 strict warn threshold
}
[Fact]
public void Evaluate_BlockDecision_IncludesSuggestions()
{
// Arrange
var input = CreateTestInput();
var config = GateConfiguration.Default;
// Act
var decision = _evaluator.Evaluate(0.75, input, config, _evaluatedAt);
// Assert
decision.Suggestions.Should().NotBeEmpty();
decision.Suggestions.Should().Contain(s => s.Contains("urgently") || s.Contains("VEX"));
}
[Fact]
public void Evaluate_WarnDecision_IncludesSuggestions()
{
// Arrange
var input = CreateTestInput();
var config = GateConfiguration.Default;
// Act
var decision = _evaluator.Evaluate(0.50, input, config, _evaluatedAt);
// Assert
decision.Suggestions.Should().NotBeEmpty();
}
[Fact]
public void Evaluate_WithCustomBlockRule_OverridesDefaultThreshold()
{
// Arrange
var input = CreateTestInput(cvssBase: 9.0);
var customRule = new GateRule
{
Id = "critical-cvss",
Name = "Block Critical CVSS",
Condition = "cvss >= 9.0",
Action = GateAction.Block,
Priority = 0 // High priority
};
var config = GateConfiguration.Default with
{
CustomRules = ImmutableArray.Create(customRule)
};
// Act
var decision = _evaluator.Evaluate(0.30, input, config, _evaluatedAt); // Would normally pass
// Assert
decision.Action.Should().Be(GateAction.Block);
decision.MatchedRules.Should().Contain("critical-cvss");
}
[Fact]
public void Evaluate_WithCustomEpssRule_Matches()
{
// Arrange
var input = CreateTestInput(epssScore: 0.6);
var customRule = new GateRule
{
Id = "high-epss",
Name = "Block High EPSS",
Condition = "epss >= 0.5",
Action = GateAction.Block,
Priority = 0
};
var config = GateConfiguration.Default with
{
CustomRules = ImmutableArray.Create(customRule)
};
// Act
var decision = _evaluator.Evaluate(0.30, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Block);
decision.MatchedRules.Should().Contain("high-epss");
}
[Fact]
public void Evaluate_CustomRulesRespectPriority()
{
// Arrange
var input = CreateTestInput(cvssBase: 8.0, epssScore: 0.6);
var rule1 = new GateRule
{
Id = "warn-cvss",
Name = "Warn High CVSS",
Condition = "cvss >= 7.0",
Action = GateAction.Warn,
Priority = 10 // Lower priority
};
var rule2 = new GateRule
{
Id = "block-epss",
Name = "Block High EPSS",
Condition = "epss >= 0.5",
Action = GateAction.Block,
Priority = 1 // Higher priority
};
var config = GateConfiguration.Default with
{
CustomRules = ImmutableArray.Create(rule1, rule2)
};
// Act
var decision = _evaluator.Evaluate(0.30, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Block);
decision.MatchedRules.Should().Contain("block-epss");
}
[Fact]
public void EvaluateBatch_EvaluatesAllFindings()
{
// Arrange
var findings = new List<(double FinalScore, EvidenceWeightedScoreInput Input)>
{
(0.75, CreateTestInput()),
(0.50, CreateTestInput()),
(0.20, CreateTestInput())
};
var config = GateConfiguration.Default;
// Act
var decisions = _evaluator.EvaluateBatch(findings, config, _evaluatedAt);
// Assert
decisions.Should().HaveCount(3);
decisions[0].Action.Should().Be(GateAction.Block);
decisions[1].Action.Should().Be(GateAction.Warn);
decisions[2].Action.Should().Be(GateAction.Pass);
}
[Fact]
public void Evaluate_InProjectVex_IsAlwaysAuthoritative()
{
// Arrange
var input = CreateTestInput(vexStatus: "not_affected", vexSource: "in-project:local");
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
// Act
var decision = _evaluator.Evaluate(0.90, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Pass);
}
[Fact]
public void Evaluate_VendorPrefixVex_IsAuthoritative()
{
// Arrange
var input = CreateTestInput(vexStatus: "fixed", vexSource: "vendor:microsoft");
var config = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
// Act
var decision = _evaluator.Evaluate(0.90, input, config, _evaluatedAt);
// Assert
decision.Action.Should().Be(GateAction.Pass);
}
private static EvidenceWeightedScoreInput CreateTestInput(
double cvssBase = 7.5,
double epssScore = 0.42,
double patchProofConfidence = 0.3,
string? vexStatus = null,
string? vexSource = null)
{
return new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
CvssBase = cvssBase,
EpssScore = epssScore,
PatchProofConfidence = patchProofConfidence,
Rch = 0.7,
Rts = 0.3,
Bkp = 0.5,
Xpl = 0.6,
Src = 0.8,
Mit = 0.2,
VexStatus = vexStatus,
VexSource = vexSource
};
}
}

View File

@@ -0,0 +1,366 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-002 - Unit tests for VerdictBundleBuilder
using FluentAssertions;
using StellaOps.DeltaVerdict.Bundles;
using StellaOps.Signals.EvidenceWeightedScore;
namespace StellaOps.DeltaVerdict.Tests.Bundles;
public class VerdictBundleBuilderTests
{
private readonly TimeProvider _fixedTimeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-01-18T12:00:00Z"));
private readonly VerdictBundleBuilder _builder;
public VerdictBundleBuilderTests()
{
var gateEvaluator = new GateEvaluator();
_builder = new VerdictBundleBuilder(gateEvaluator, _fixedTimeProvider);
}
[Fact]
public void Build_WithValidInput_ReturnsVerdictBundle()
{
// Arrange
var ewsResult = CreateTestEwsResult();
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle = _builder.Build(ewsResult, input, policy);
// Assert
bundle.Should().NotBeNull();
bundle.FindingId.Should().Be("CVE-2024-1234@pkg:npm/lodash@4.17.20");
bundle.SchemaVersion.Should().Be(VerdictBundle.CurrentSchemaVersion);
bundle.BundleId.Should().StartWith("sha256:");
bundle.BundleDigest.Should().Be(bundle.BundleId);
}
[Fact]
public void Build_ExtractsInputs_WithCorrectValues()
{
// Arrange
var ewsResult = CreateTestEwsResult();
var input = CreateTestEwsInput(cvssBase: 7.5, epssScore: 0.42, reachability: 0.8);
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle = _builder.Build(ewsResult, input, policy);
// Assert
bundle.Inputs.Cvss.BaseScore.Should().Be(7.5);
bundle.Inputs.Epss.Probability.Should().Be(0.42);
bundle.Inputs.Reachability.Value.Should().Be(0.8);
}
[Fact]
public void Build_CreatesNormalizationTrace_FromBreakdown()
{
// Arrange
var ewsResult = CreateTestEwsResult();
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle = _builder.Build(ewsResult, input, policy);
// Assert
bundle.Normalization.Should().NotBeNull();
bundle.Normalization.Dimensions.Should().NotBeEmpty();
bundle.Normalization.Dimensions
.Should().Contain(d => d.Symbol == "CVS" || d.Symbol == "RCH");
}
[Fact]
public void Build_ComputesScores_Correctly()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 65);
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle = _builder.Build(ewsResult, input, policy);
// Assert
bundle.FinalScore.Should().Be(0.65);
}
[Fact]
public void Build_WithBlockingScore_ReturnsBlockDecision()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 70); // Above default block threshold (0.65)
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
var gateConfig = GateConfiguration.Default;
// Act
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
// Assert
bundle.Gate.Action.Should().Be(GateAction.Block);
bundle.Gate.Threshold.Should().Be(0.65);
bundle.Gate.MatchedRules.Should().Contain("block_threshold");
}
[Fact]
public void Build_WithWarnScore_ReturnsWarnDecision()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 50); // Between warn (0.40) and block (0.65)
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
var gateConfig = GateConfiguration.Default;
// Act
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
// Assert
bundle.Gate.Action.Should().Be(GateAction.Warn);
bundle.Gate.Threshold.Should().Be(0.40);
}
[Fact]
public void Build_WithPassScore_ReturnsPassDecision()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 30); // Below warn threshold
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
var gateConfig = GateConfiguration.Default;
// Act
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
// Assert
bundle.Gate.Action.Should().Be(GateAction.Pass);
}
[Fact]
public void Build_WithTrustedVexNotAffected_ReturnsPassDecision()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 70, flags: ["vex-override", "vendor-na"]);
var input = CreateTestEwsInput(vexStatus: "not_affected", vexSource: ".vex/document.json");
var policy = EvidenceWeightPolicy.AdvisoryProduction;
var gateConfig = GateConfiguration.Default with { AutoPassOnTrustedVex = true };
// Act
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
// Assert
bundle.Gate.Action.Should().Be(GateAction.Pass);
bundle.Gate.MatchedRules.Should().Contain("auto_pass_trusted_vex");
}
[Fact]
public void Build_WithVexOverride_SetsOverrideField()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 0, flags: ["vex-override", "vendor-na"]);
var input = CreateTestEwsInput(vexStatus: "not_affected", vexSource: "vendor:acme");
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle = _builder.Build(ewsResult, input, policy);
// Assert
bundle.Override.Should().NotBeNull();
bundle.Override!.Applied.Should().BeTrue();
bundle.Override.Type.Should().Be("vex_not_affected");
}
[Fact]
public void Build_GeneratesContentAddressedBundleId()
{
// Arrange
var ewsResult = CreateTestEwsResult();
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle1 = _builder.Build(ewsResult, input, policy);
var bundle2 = _builder.Build(ewsResult, input, policy);
// Assert - Same inputs should produce same digest
bundle1.BundleId.Should().Be(bundle2.BundleId);
}
[Fact]
public void Build_DifferentInputs_ProduceDifferentBundleIds()
{
// Arrange
var ewsResult1 = CreateTestEwsResult(score: 50);
var ewsResult2 = CreateTestEwsResult(score: 60);
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle1 = _builder.Build(ewsResult1, input, policy);
var bundle2 = _builder.Build(ewsResult2, input, policy);
// Assert
bundle1.BundleId.Should().NotBe(bundle2.BundleId);
}
[Fact]
public void Build_SetsComputedAtTimestamp()
{
// Arrange
var expectedTime = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
var ewsResult = CreateTestEwsResult();
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
// Act
var bundle = _builder.Build(ewsResult, input, policy);
// Assert
bundle.ComputedAt.Should().Be(expectedTime);
}
[Fact]
public void Build_WithStrictGateConfiguration_LowersThresholds()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 55); // Above strict block (0.50), below default block (0.65)
var input = CreateTestEwsInput();
var policy = EvidenceWeightPolicy.AdvisoryProduction;
var gateConfig = GateConfiguration.Strict;
// Act
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
// Assert
bundle.Gate.Action.Should().Be(GateAction.Block);
bundle.Gate.Threshold.Should().Be(0.50);
}
[Fact]
public void Build_WithPatchProofBypass_ConvertsWarnToPass()
{
// Arrange
var ewsResult = CreateTestEwsResult(score: 50); // In warn range
var input = CreateTestEwsInput(patchProofConfidence: 0.75); // Above bypass threshold (0.70)
var policy = EvidenceWeightPolicy.AdvisoryProduction;
var gateConfig = GateConfiguration.Default;
// Act
var bundle = _builder.Build(ewsResult, input, policy, gateConfig);
// Assert
bundle.Gate.Action.Should().Be(GateAction.Pass);
bundle.Gate.MatchedRules.Should().Contain("patch_proof_bypass");
}
private static EvidenceWeightedScoreResult CreateTestEwsResult(
int score = 50,
string[] flags = null!)
{
flags ??= ["high-epss"];
return new EvidenceWeightedScoreResult
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
Score = score,
Bucket = score >= 90 ? ScoreBucket.ActNow :
score >= 70 ? ScoreBucket.ScheduleNext :
score >= 40 ? ScoreBucket.Investigate :
ScoreBucket.Watchlist,
Inputs = new EvidenceInputValues(0.7, 0.3, 0.5, 0.6, 0.8, 0.2),
Weights = EvidenceWeights.Advisory,
Breakdown =
[
new DimensionContribution
{
Dimension = "CVSS Base",
Symbol = "CVS",
InputValue = 0.75,
Weight = 0.25,
Contribution = 0.1875
},
new DimensionContribution
{
Dimension = "EPSS",
Symbol = "EPS",
InputValue = 0.42,
Weight = 0.30,
Contribution = 0.126
},
new DimensionContribution
{
Dimension = "Reachability",
Symbol = "RCH",
InputValue = 0.7,
Weight = 0.20,
Contribution = 0.14
},
new DimensionContribution
{
Dimension = "Exploit Maturity",
Symbol = "XPL",
InputValue = 0.6,
Weight = 0.10,
Contribution = 0.06
},
new DimensionContribution
{
Dimension = "Patch Proof",
Symbol = "PPF",
InputValue = 0.3,
Weight = 0.15,
Contribution = -0.045,
IsSubtractive = true
}
],
Flags = flags,
Explanations = ["CVSS: high (75%)", "EPSS: medium (42%)"],
Caps = AppliedGuardrails.None(score),
PolicyDigest = "abc123",
CalculatedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z")
};
}
private static EvidenceWeightedScoreInput CreateTestEwsInput(
double cvssBase = 7.5,
double epssScore = 0.42,
double reachability = 0.7,
double patchProofConfidence = 0.3,
string? vexStatus = null,
string? vexSource = null)
{
return new EvidenceWeightedScoreInput
{
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
CvssBase = cvssBase,
CvssVersion = "3.1",
EpssScore = epssScore,
ExploitMaturity = ExploitMaturityLevel.Functional,
PatchProofConfidence = patchProofConfidence,
Rch = reachability,
Rts = 0.3,
Bkp = 0.5,
Xpl = 0.6,
Src = 0.8,
Mit = 0.2,
VexStatus = vexStatus,
VexSource = vexSource
};
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _utcNow;
public FakeTimeProvider(DateTimeOffset utcNow)
{
_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}

View File

@@ -0,0 +1,440 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-004 - Unit tests for VerdictRekorAnchorService
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.DeltaVerdict.Bundles;
using StellaOps.DeltaVerdict.Manifest;
namespace StellaOps.DeltaVerdict.Tests.Bundles;
public class VerdictRekorAnchorServiceTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly StubVerdictRekorClient _stubClient;
private readonly VerdictRekorAnchorService _anchorService;
private readonly VerdictSigningService _signingService;
private readonly string _testSecret;
public VerdictRekorAnchorServiceTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
_stubClient = new StubVerdictRekorClient(_timeProvider);
_anchorService = new VerdictRekorAnchorService(_stubClient, _timeProvider);
_signingService = new VerdictSigningService();
_testSecret = Convert.ToBase64String(new byte[32] {
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20
});
}
[Fact]
public async Task AnchorAsync_WithSignedBundle_ReturnsAnchoredBundle()
{
// Arrange
var bundle = await CreateSignedBundle();
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
// Act
var result = await _anchorService.AnchorAsync(bundle, options);
// Assert
result.IsSuccess.Should().BeTrue();
result.AnchoredBundle.Should().NotBeNull();
result.AnchoredBundle!.RekorAnchor.Should().NotBeNull();
result.Linkage.Should().NotBeNull();
}
[Fact]
public async Task AnchorAsync_WithSignedBundle_SetsRekorFields()
{
// Arrange
var bundle = await CreateSignedBundle();
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
// Act
var result = await _anchorService.AnchorAsync(bundle, options);
// Assert
var anchor = result.AnchoredBundle!.RekorAnchor!;
anchor.Uuid.Should().NotBeNullOrEmpty();
anchor.LogIndex.Should().BeGreaterThan(0);
anchor.IntegratedTime.Should().BeGreaterThan(0);
}
[Fact]
public async Task AnchorAsync_WithSignedBundle_IncludesInclusionProof()
{
// Arrange
var bundle = await CreateSignedBundle();
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
// Act
var result = await _anchorService.AnchorAsync(bundle, options);
// Assert
var proof = result.AnchoredBundle!.RekorAnchor!.InclusionProof;
proof.Should().NotBeNull();
proof!.TreeSize.Should().BeGreaterThan(0);
proof.RootHash.Should().NotBeNullOrEmpty();
proof.LogId.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task AnchorAsync_WithUnsignedBundle_ReturnsFail()
{
// Arrange
var bundle = CreateTestBundle();
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
// Act
var result = await _anchorService.AnchorAsync(bundle, options);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("must be signed");
}
[Fact]
public async Task AnchorAsync_MultipleSubmissions_GetUniqueLogIndexes()
{
// Arrange
var bundle1 = await CreateSignedBundle();
var bundle2 = await CreateSignedBundle("CVE-2024-5678@pkg:npm/express@4.0.0");
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
// Act
var result1 = await _anchorService.AnchorAsync(bundle1, options);
var result2 = await _anchorService.AnchorAsync(bundle2, options);
// Assert
result1.Linkage!.LogIndex.Should().NotBe(result2.Linkage!.LogIndex);
}
[Fact]
public async Task VerifyAnchorAsync_WithValidAnchor_ReturnsSuccess()
{
// Arrange
var bundle = await CreateSignedBundle();
var anchorOptions = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
var anchoredResult = await _anchorService.AnchorAsync(bundle, anchorOptions);
var verifyOptions = VerdictAnchorVerificationOptions.Default;
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredResult.AnchoredBundle!, verifyOptions);
// Assert
result.IsValid.Should().BeTrue();
result.VerifiedUuid.Should().NotBeNullOrEmpty();
result.VerifiedLogIndex.Should().BeGreaterThan(0);
}
[Fact]
public async Task VerifyAnchorAsync_WithNoAnchor_ReturnsFail()
{
// Arrange
var bundle = await CreateSignedBundle();
var verifyOptions = VerdictAnchorVerificationOptions.Default;
// Act
var result = await _anchorService.VerifyAnchorAsync(bundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("no Rekor anchor");
}
[Fact]
public async Task VerifyAnchorAsync_WithFutureTimestamp_ReturnsFail()
{
// Arrange
var bundle = await CreateSignedBundle();
var futureTime = _timeProvider.GetUtcNow().AddHours(2).ToUnixTimeSeconds();
var anchoredBundle = bundle with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 1,
IntegratedTime = futureTime,
InclusionProof = new InclusionProof
{
TreeSize = 1,
RootHash = "abc123",
Hashes = ImmutableArray<string>.Empty,
LogId = "test-log"
}
}
};
var verifyOptions = VerdictAnchorVerificationOptions.Default;
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("future");
}
[Fact]
public async Task VerifyAnchorAsync_WithOldTimestamp_ReturnsFail_WhenMaxAgeExceeded()
{
// Arrange
var bundle = await CreateSignedBundle();
var oldTime = _timeProvider.GetUtcNow().AddHours(-48).ToUnixTimeSeconds();
var anchoredBundle = bundle with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 1,
IntegratedTime = oldTime,
InclusionProof = new InclusionProof
{
TreeSize = 1,
RootHash = "abc123",
Hashes = ImmutableArray<string>.Empty,
LogId = "test-log"
}
}
};
var verifyOptions = new VerdictAnchorVerificationOptions { MaxAgeHours = 24 };
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("older than");
}
[Fact]
public async Task VerifyAnchorAsync_WithNoInclusionProof_ReturnsFail_WhenRequired()
{
// Arrange
var bundle = await CreateSignedBundle();
var anchoredBundle = bundle with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 1,
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds(),
InclusionProof = null
}
};
var verifyOptions = new VerdictAnchorVerificationOptions { RequireInclusionProof = true };
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("no inclusion proof");
}
[Fact]
public async Task VerifyAnchorAsync_WithNoInclusionProof_ReturnsSuccess_WhenNotRequired()
{
// Arrange
var bundle = await CreateSignedBundle();
var anchoredBundle = bundle with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 1,
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds(),
InclusionProof = null
}
};
var verifyOptions = VerdictAnchorVerificationOptions.Relaxed;
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task VerifyAnchorAsync_WithInvalidUuid_ReturnsFail()
{
// Arrange
var bundle = await CreateSignedBundle();
var anchoredBundle = bundle with
{
RekorAnchor = new RekorLinkage
{
Uuid = "",
LogIndex = 1,
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds()
}
};
var verifyOptions = VerdictAnchorVerificationOptions.Relaxed;
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Invalid Rekor UUID");
}
[Fact]
public async Task VerifyAnchorAsync_WithNegativeLogIndex_ReturnsFail()
{
// Arrange
var bundle = await CreateSignedBundle();
var anchoredBundle = bundle with
{
RekorAnchor = new RekorLinkage
{
Uuid = "valid-uuid",
LogIndex = -1,
IntegratedTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds()
}
};
var verifyOptions = VerdictAnchorVerificationOptions.Relaxed;
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Invalid log index");
}
[Fact]
public async Task VerifyAnchorAsync_VerifiesInclusionProof_WhenEnabled()
{
// Arrange
var bundle = await CreateSignedBundle();
var anchorOptions = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
var anchoredResult = await _anchorService.AnchorAsync(bundle, anchorOptions);
var verifyOptions = new VerdictAnchorVerificationOptions
{
RequireInclusionProof = true,
VerifyInclusionProof = true
};
// Act
var result = await _anchorService.VerifyAnchorAsync(anchoredResult.AnchoredBundle!, verifyOptions);
// Assert
result.IsValid.Should().BeTrue();
result.VerifiedIntegratedTime.Should().NotBeNull();
}
[Fact]
public async Task AnchorAsync_PreservesOriginalBundleFields()
{
// Arrange
var bundle = await CreateSignedBundle();
var options = new VerdictAnchorOptions { RekorUrl = "https://rekor.example.com" };
// Act
var result = await _anchorService.AnchorAsync(bundle, options);
// Assert
result.AnchoredBundle!.BundleId.Should().Be(bundle.BundleId);
result.AnchoredBundle.FindingId.Should().Be(bundle.FindingId);
result.AnchoredBundle.FinalScore.Should().Be(bundle.FinalScore);
result.AnchoredBundle.DsseSignature.Should().Be(bundle.DsseSignature);
}
private async Task<VerdictBundle> CreateSignedBundle(string? findingId = null)
{
var bundle = CreateTestBundle(findingId);
var options = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
return await _signingService.SignAsync(bundle, options);
}
private static VerdictBundle CreateTestBundle(string? findingId = null)
{
var computedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
return new VerdictBundle
{
BundleId = "sha256:abc123",
FindingId = findingId ?? "CVE-2024-1234@pkg:npm/lodash@4.17.20",
ManifestRef = new ScoringManifestRef
{
ScoringVersion = "v2026-01-18-1",
ManifestDigest = "sha256:manifest123"
},
Inputs = new VerdictInputs
{
Cvss = new CvssInput
{
BaseScore = 7.5,
Version = "3.1",
Source = "nvd",
CapturedAt = computedAt
},
Epss = new EpssInput
{
Probability = 0.42,
Source = "first.org",
CapturedAt = computedAt
},
Reachability = new ReachabilityInputRecord
{
Level = "function",
Value = 0.7,
Source = "stella-scanner",
CapturedAt = computedAt
},
ExploitMaturity = new ExploitMaturityInput
{
Level = "poc",
Value = 0.5,
Source = "nvd",
CapturedAt = computedAt
},
PatchProof = new PatchProofInput
{
Confidence = 0.3,
Source = "stella-verifier",
CapturedAt = computedAt
}
},
Normalization = new NormalizationTrace
{
Dimensions = ImmutableArray.Create(
new DimensionNormalization
{
Dimension = "cvss_base",
Symbol = "CVS",
RawValue = 7.5,
NormalizedValue = 0.75,
Method = "linear",
Weight = 0.25,
Contribution = 0.1875
})
},
RawScore = 0.65,
FinalScore = 0.65,
Gate = new GateDecision
{
Action = GateAction.Block,
Reason = "Score 0.65 exceeds block threshold 0.65",
Threshold = 0.65,
MatchedRules = ["block_threshold"],
Suggestions = ["Review finding urgently"]
},
ComputedAt = computedAt,
BundleDigest = "sha256:abc123"
};
}
}

View File

@@ -0,0 +1,473 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_030_LIB_verdict_rekor_gate_api
// Task: TASK-030-003 - Unit tests for VerdictSigningService
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using StellaOps.DeltaVerdict.Bundles;
using StellaOps.DeltaVerdict.Manifest;
namespace StellaOps.DeltaVerdict.Tests.Bundles;
public class VerdictSigningServiceTests
{
private readonly VerdictSigningService _signingService = new();
private readonly string _testSecret = Convert.ToBase64String(new byte[32] {
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10,
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20
});
[Fact]
public async Task SignAsync_WithValidBundle_ReturnsSignedBundle()
{
// Arrange
var bundle = CreateTestBundle();
var options = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var signedBundle = await _signingService.SignAsync(bundle, options);
// Assert
signedBundle.DsseSignature.Should().NotBeNullOrEmpty();
signedBundle.BundleId.Should().Be(bundle.BundleId);
signedBundle.FindingId.Should().Be(bundle.FindingId);
signedBundle.FinalScore.Should().Be(bundle.FinalScore);
}
[Fact]
public async Task SignAsync_ProducesValidDsseEnvelope()
{
// Arrange
var bundle = CreateTestBundle();
var options = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var signedBundle = await _signingService.SignAsync(bundle, options);
// Assert
var envelope = JsonSerializer.Deserialize<VerdictDsseEnvelope>(signedBundle.DsseSignature!);
envelope.Should().NotBeNull();
envelope!.PayloadType.Should().Be(VerdictSigningService.PayloadType);
envelope.Payload.Should().NotBeNullOrEmpty();
envelope.Signatures.Should().HaveCount(1);
envelope.Signatures[0].KeyId.Should().Be("test-key-1");
envelope.Signatures[0].Sig.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task VerifyAsync_WithValidSignature_ReturnsSuccess()
{
// Arrange
var bundle = CreateTestBundle();
var signingOptions = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
var verifyOptions = new VerdictVerificationOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var result = await _signingService.VerifyAsync(signedBundle, verifyOptions);
// Assert
result.IsValid.Should().BeTrue();
result.VerifiedKeyId.Should().Be("test-key-1");
result.Error.Should().BeNull();
}
[Fact]
public async Task VerifyAsync_WithWrongSecret_ReturnsFail()
{
// Arrange
var bundle = CreateTestBundle();
var signingOptions = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
var wrongSecret = Convert.ToBase64String(new byte[32]);
var verifyOptions = new VerdictVerificationOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = wrongSecret
};
// Act
var result = await _signingService.VerifyAsync(signedBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Signature verification failed");
}
[Fact]
public async Task VerifyAsync_WithWrongKeyId_ReturnsFail()
{
// Arrange
var bundle = CreateTestBundle();
var signingOptions = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
var verifyOptions = new VerdictVerificationOptions
{
KeyId = "different-key",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var result = await _signingService.VerifyAsync(signedBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Signature verification failed");
}
[Fact]
public async Task VerifyAsync_WithUnsignedBundle_ReturnsFail()
{
// Arrange
var bundle = CreateTestBundle();
var verifyOptions = new VerdictVerificationOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var result = await _signingService.VerifyAsync(bundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Bundle is not signed");
}
[Fact]
public async Task VerifyAsync_WithInvalidEnvelope_ReturnsFail()
{
// Arrange
var bundle = CreateTestBundle() with { DsseSignature = "invalid json" };
var verifyOptions = new VerdictVerificationOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var result = await _signingService.VerifyAsync(bundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Invalid signature envelope");
}
[Fact]
public async Task VerifyAsync_DetectsTamperedContent()
{
// Arrange
var bundle = CreateTestBundle();
var signingOptions = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
// Tamper with the content
var tamperedBundle = signedBundle with { FinalScore = 0.99 };
var verifyOptions = new VerdictVerificationOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var result = await _signingService.VerifyAsync(tamperedBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("modified");
}
[Fact]
public async Task SignAsync_WithSha256Algorithm_ProducesSignature()
{
// Arrange
var bundle = CreateTestBundle();
var options = new VerdictSigningOptions
{
KeyId = "test-key-sha256",
Algorithm = VerdictSigningAlgorithm.Sha256
};
// Act
var signedBundle = await _signingService.SignAsync(bundle, options);
// Assert
signedBundle.DsseSignature.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task SignAsync_SigningTwiceWithSameOptions_ProducesSameSignature()
{
// Arrange
var bundle = CreateTestBundle();
var options = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var signedBundle1 = await _signingService.SignAsync(bundle, options);
var signedBundle2 = await _signingService.SignAsync(bundle, options);
// Assert
signedBundle1.DsseSignature.Should().Be(signedBundle2.DsseSignature);
}
[Fact]
public void GetCanonicalJson_IsDeterministic()
{
// Arrange
var bundle = CreateTestBundle();
// Act
var json1 = _signingService.GetCanonicalJson(bundle);
var json2 = _signingService.GetCanonicalJson(bundle);
// Assert
json1.Should().Be(json2);
}
[Fact]
public void GetCanonicalJson_ExcludesSignatureAndRekorFields()
{
// Arrange
var bundle = CreateTestBundle() with
{
DsseSignature = "some-signature",
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 123,
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}
};
// Act
var json = _signingService.GetCanonicalJson(bundle);
// Assert
json.Should().NotContain("dsse_signature");
json.Should().NotContain("rekor_anchor");
json.Should().Contain("finding_id");
json.Should().Contain("final_score");
}
[Fact]
public async Task SignAsync_WithExistingSignature_ReplacesSignature()
{
// Arrange
var bundle = CreateTestBundle();
var options1 = new VerdictSigningOptions
{
KeyId = "key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var options2 = new VerdictSigningOptions
{
KeyId = "key-2",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var firstSigned = await _signingService.SignAsync(bundle, options1);
var secondSigned = await _signingService.SignAsync(firstSigned, options2);
// Assert
var envelope = JsonSerializer.Deserialize<VerdictDsseEnvelope>(secondSigned.DsseSignature!);
envelope!.Signatures.Should().HaveCount(1);
envelope.Signatures[0].KeyId.Should().Be("key-2");
}
[Fact]
public async Task VerifyAsync_WithWrongPayloadType_ReturnsFail()
{
// Arrange
var bundle = CreateTestBundle();
var signingOptions = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedBundle = await _signingService.SignAsync(bundle, signingOptions);
// Tamper with envelope to change payload type
var envelope = JsonSerializer.Deserialize<VerdictDsseEnvelope>(signedBundle.DsseSignature!);
var tamperedEnvelope = new VerdictDsseEnvelope(
"application/wrong-type",
envelope!.Payload,
envelope.Signatures);
var tamperedBundle = signedBundle with
{
DsseSignature = JsonSerializer.Serialize(tamperedEnvelope)
};
var verifyOptions = new VerdictVerificationOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
// Act
var result = await _signingService.VerifyAsync(tamperedBundle, verifyOptions);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Invalid payload type");
}
[Fact]
public async Task SignAsync_ThrowsOnMissingHmacSecret()
{
// Arrange
var bundle = CreateTestBundle();
var options = new VerdictSigningOptions
{
KeyId = "test-key-1",
Algorithm = VerdictSigningAlgorithm.HmacSha256,
SecretBase64 = null
};
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => _signingService.SignAsync(bundle, options));
}
[Fact]
public void PayloadType_IsCorrect()
{
// Assert
VerdictSigningService.PayloadType.Should().Be("application/vnd.stella.scoring.v1+json");
}
private static VerdictBundle CreateTestBundle()
{
var computedAt = DateTimeOffset.Parse("2026-01-18T12:00:00Z");
return new VerdictBundle
{
BundleId = "sha256:abc123",
FindingId = "CVE-2024-1234@pkg:npm/lodash@4.17.20",
ManifestRef = new ScoringManifestRef
{
ScoringVersion = "v2026-01-18-1",
ManifestDigest = "sha256:manifest123"
},
Inputs = new VerdictInputs
{
Cvss = new CvssInput
{
BaseScore = 7.5,
Version = "3.1",
Source = "nvd",
CapturedAt = computedAt
},
Epss = new EpssInput
{
Probability = 0.42,
Source = "first.org",
CapturedAt = computedAt
},
Reachability = new ReachabilityInputRecord
{
Level = "function",
Value = 0.7,
Source = "stella-scanner",
CapturedAt = computedAt
},
ExploitMaturity = new ExploitMaturityInput
{
Level = "poc",
Value = 0.5,
Source = "nvd",
CapturedAt = computedAt
},
PatchProof = new PatchProofInput
{
Confidence = 0.3,
Source = "stella-verifier",
CapturedAt = computedAt
}
},
Normalization = new NormalizationTrace
{
Dimensions = ImmutableArray.Create(
new DimensionNormalization
{
Dimension = "cvss_base",
Symbol = "CVS",
RawValue = 7.5,
NormalizedValue = 0.75,
Method = "linear",
Weight = 0.25,
Contribution = 0.1875
})
},
RawScore = 0.65,
FinalScore = 0.65,
Gate = new GateDecision
{
Action = GateAction.Block,
Reason = "Score 0.65 exceeds block threshold 0.65",
Threshold = 0.65,
MatchedRules = ["block_threshold"],
Suggestions = ["Review finding urgently"]
},
ComputedAt = computedAt,
BundleDigest = "sha256:abc123"
};
}
}

View File

@@ -0,0 +1,221 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
// Task: TASK-028-001 - Unit tests for ScoringManifest model
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.DeltaVerdict.Manifest;
namespace StellaOps.DeltaVerdict.Tests.Manifest;
public class ScoringManifestTests
{
[Fact]
public void ScoringManifest_Instantiation_WithAllRequiredFields()
{
// Arrange & Act
var manifest = CreateTestManifest();
// Assert
manifest.Should().NotBeNull();
manifest.SchemaVersion.Should().Be(ScoringManifest.CurrentSchemaVersion);
manifest.ScoringVersion.Should().Be("v2026-01-18-1");
manifest.Weights.Should().NotBeNull();
manifest.Normalizers.Should().NotBeNull();
manifest.TrustedVexKeys.Should().HaveCount(2);
manifest.CodeHash.Should().StartWith("sha256:");
}
[Fact]
public void ScoringWeights_Default_SumsToOne()
{
// Arrange & Act
var weights = ScoringWeights.Default;
// Assert
weights.Sum.Should().BeApproximately(1.0, 0.01);
}
[Fact]
public void ScoringWeights_Validate_ReturnsNoErrors_WhenValid()
{
// Arrange
var weights = ScoringWeights.Default;
// Act
var errors = weights.Validate();
// Assert
errors.Should().BeEmpty();
}
[Fact]
public void ScoringWeights_Validate_ReturnsErrors_WhenWeightOutOfRange()
{
// Arrange
var weights = new ScoringWeights
{
CvssBase = 1.5, // Invalid: > 1.0
Epss = 0.2,
Reachability = 0.25,
ExploitMaturity = 0.15,
PatchProofConfidence = 0.15
};
// Act
var errors = weights.Validate();
// Assert
errors.Should().Contain(e => e.Contains("cvss_base") && e.Contains("range"));
}
[Fact]
public void ScoringWeights_Validate_ReturnsErrors_WhenNaN()
{
// Arrange
var weights = new ScoringWeights
{
CvssBase = double.NaN,
Epss = 0.2,
Reachability = 0.25,
ExploitMaturity = 0.15,
PatchProofConfidence = 0.15
};
// Act
var errors = weights.Validate();
// Assert
errors.Should().Contain(e => e.Contains("cvss_base") && e.Contains("valid number"));
}
[Fact]
public void ScoringNormalizers_Default_HasExpectedRanges()
{
// Arrange & Act
var normalizers = ScoringNormalizers.Default;
// Assert
normalizers.CvssRange.Min.Should().Be(0.0);
normalizers.CvssRange.Max.Should().Be(10.0);
normalizers.EpssRange.Min.Should().Be(0.0);
normalizers.EpssRange.Max.Should().Be(1.0);
}
[Fact]
public void NormalizerRange_Normalize_ReturnsExpectedValues()
{
// Arrange
var range = new NormalizerRange { Min = 0.0, Max = 10.0 };
// Act & Assert
range.Normalize(0.0).Should().Be(0.0);
range.Normalize(5.0).Should().Be(0.5);
range.Normalize(10.0).Should().Be(1.0);
}
[Fact]
public void NormalizerRange_Normalize_ClampsValues()
{
// Arrange
var range = new NormalizerRange { Min = 0.0, Max = 10.0 };
// Act & Assert
range.Normalize(-5.0).Should().Be(0.0); // Clamped to min
range.Normalize(15.0).Should().Be(1.0); // Clamped to max
}
[Fact]
public void NormalizerRange_Normalize_HandlesZeroRange()
{
// Arrange
var range = new NormalizerRange { Min = 5.0, Max = 5.0 };
// Act
var result = range.Normalize(5.0);
// Assert
result.Should().Be(0.0); // Zero-width range returns 0
}
[Fact]
public void RekorLinkage_Instantiation_WithRequiredFields()
{
// Arrange & Act
var linkage = new RekorLinkage
{
Uuid = "abc123",
LogIndex = 12345,
IntegratedTime = 1705593600
};
// Assert
linkage.Uuid.Should().Be("abc123");
linkage.LogIndex.Should().Be(12345);
linkage.IntegratedTime.Should().Be(1705593600);
linkage.InclusionProof.Should().BeNull();
}
[Fact]
public void InclusionProof_Instantiation_WithRequiredFields()
{
// Arrange & Act
var proof = new InclusionProof
{
TreeSize = 1000000,
RootHash = "sha256:abc123",
Hashes = ImmutableArray.Create("hash1", "hash2", "hash3"),
LogId = "sigstore-log-1"
};
// Assert
proof.TreeSize.Should().Be(1000000);
proof.RootHash.Should().Be("sha256:abc123");
proof.Hashes.Should().HaveCount(3);
proof.LogId.Should().Be("sigstore-log-1");
}
[Fact]
public void ScoringManifest_IsImmutable_ViaRecordSemantics()
{
// Arrange
var original = CreateTestManifest();
// Act - Using 'with' expression creates a new instance
var modified = original with { ScoringVersion = "v2026-01-18-2" };
// Assert
original.ScoringVersion.Should().Be("v2026-01-18-1");
modified.ScoringVersion.Should().Be("v2026-01-18-2");
original.Should().NotBeSameAs(modified);
}
[Fact]
public void ScoringManifest_TrustedVexKeys_IsImmutableArray()
{
// Arrange
var manifest = CreateTestManifest();
// Act
var keys = manifest.TrustedVexKeys;
// Assert
keys.Should().BeOfType<ImmutableArray<string>>();
keys.IsDefault.Should().BeFalse();
}
private static ScoringManifest CreateTestManifest()
{
return new ScoringManifest
{
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
ScoringVersion = "v2026-01-18-1",
Weights = ScoringWeights.Default,
Normalizers = ScoringNormalizers.Default,
TrustedVexKeys = ImmutableArray.Create("key-fingerprint-1", "key-fingerprint-2"),
CodeHash = "sha256:abc123def456",
CreatedAt = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,520 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
// Task: TASK-028-006 - Manifest Version Bump Workflow
using System.Collections.Immutable;
using System.Security.Cryptography;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.DeltaVerdict.Manifest;
using StellaOps.DeltaVerdict.Signing;
using Xunit;
namespace StellaOps.DeltaVerdict.Tests.Manifest;
public class ScoringManifestVersionerTests
{
private readonly FakeTimeProvider _timeProvider;
private readonly ScoringManifestVersioner _versioner;
public ScoringManifestVersionerTests()
{
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
_versioner = new ScoringManifestVersioner(
new ScoringManifestSigningService(),
null,
_timeProvider);
}
private static ScoringManifest CreateTestManifest(string version = "v2026-01-17-1")
{
return new ScoringManifest
{
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
ScoringVersion = version,
Weights = ScoringWeights.Default,
Normalizers = ScoringNormalizers.Default,
TrustedVexKeys = ImmutableArray.Create("key1", "key2"),
CodeHash = "sha256:abc123def456",
CreatedAt = new DateTimeOffset(2026, 1, 17, 12, 0, 0, TimeSpan.Zero)
};
}
#region Compare Tests
[Fact]
public void Compare_IdenticalManifests_RequiresNoBump()
{
var manifest = CreateTestManifest();
var result = _versioner.Compare(manifest, manifest);
result.RequiresBump.Should().BeFalse();
result.Changes.Should().BeEmpty();
}
[Fact]
public void Compare_DifferentWeights_RequiresBump()
{
var current = CreateTestManifest();
var proposed = current with
{
Weights = ScoringWeights.Default with { CvssBase = 0.30 }
};
var result = _versioner.Compare(current, proposed);
result.RequiresBump.Should().BeTrue();
result.Changes.Should().ContainSingle();
result.Changes[0].Field.Should().Be("weights.cvss_base");
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.WeightChange);
}
[Fact]
public void Compare_MultipleWeightChanges_ReportsAll()
{
var current = CreateTestManifest();
var proposed = current with
{
Weights = new ScoringWeights
{
CvssBase = 0.30,
Epss = 0.25,
Reachability = ScoringWeights.Default.Reachability,
ExploitMaturity = ScoringWeights.Default.ExploitMaturity,
PatchProofConfidence = ScoringWeights.Default.PatchProofConfidence
}
};
var result = _versioner.Compare(current, proposed);
result.RequiresBump.Should().BeTrue();
result.Changes.Should().HaveCount(2);
result.Changes.Should().Contain(c => c.Field == "weights.cvss_base");
result.Changes.Should().Contain(c => c.Field == "weights.epss");
}
[Fact]
public void Compare_DifferentTrustedKeys_RequiresBump()
{
var current = CreateTestManifest();
var proposed = current with
{
TrustedVexKeys = ImmutableArray.Create("key1", "key2", "key3")
};
var result = _versioner.Compare(current, proposed);
result.RequiresBump.Should().BeTrue();
result.Changes.Should().Contain(c => c.Field == "trusted_vex_keys.added");
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.TrustChange);
}
[Fact]
public void Compare_RemovedTrustedKey_RequiresBump()
{
var current = CreateTestManifest();
var proposed = current with
{
TrustedVexKeys = ImmutableArray.Create("key1")
};
var result = _versioner.Compare(current, proposed);
result.RequiresBump.Should().BeTrue();
result.Changes.Should().Contain(c => c.Field == "trusted_vex_keys.removed");
}
[Fact]
public void Compare_DifferentCodeHash_RequiresBump()
{
var current = CreateTestManifest();
var proposed = current with { CodeHash = "sha256:newcodehash" };
var result = _versioner.Compare(current, proposed);
result.RequiresBump.Should().BeTrue();
result.Changes.Should().ContainSingle();
result.Changes[0].Field.Should().Be("code_hash");
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.CodeChange);
}
[Fact]
public void Compare_DifferentSchemaVersion_RequiresBump()
{
var current = CreateTestManifest();
var proposed = current with { SchemaVersion = "stella-scoring/2.0.0" };
var result = _versioner.Compare(current, proposed);
result.RequiresBump.Should().BeTrue();
result.Changes.Should().ContainSingle();
result.Changes[0].Field.Should().Be("schema_version");
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.SchemaChange);
}
[Fact]
public void Compare_DifferentNormalizerRange_RequiresBump()
{
var current = CreateTestManifest();
var proposed = current with
{
Normalizers = ScoringNormalizers.Default with
{
CvssRange = new NormalizerRange { Min = 0, Max = 15 }
}
};
var result = _versioner.Compare(current, proposed);
result.RequiresBump.Should().BeTrue();
result.Changes.Should().ContainSingle();
result.Changes[0].Field.Should().Be("normalizers.cvss_range");
result.Changes[0].ChangeType.Should().Be(ManifestChangeType.NormalizerChange);
}
[Fact]
public void Compare_ReturnsDigests()
{
var current = CreateTestManifest();
var proposed = current with { CodeHash = "sha256:newcodehash" };
var result = _versioner.Compare(current, proposed);
result.CurrentDigest.Should().NotBeNullOrEmpty();
result.ProposedDigest.Should().NotBeNullOrEmpty();
result.CurrentDigest.Should().NotBe(result.ProposedDigest);
}
#endregion
#region GenerateNextVersion Tests
[Fact]
public void GenerateNextVersion_NullVersion_StartsAtOne()
{
var version = _versioner.GenerateNextVersion(null);
version.Should().Be("v2026-01-18-1");
}
[Fact]
public void GenerateNextVersion_EmptyVersion_StartsAtOne()
{
var version = _versioner.GenerateNextVersion("");
version.Should().Be("v2026-01-18-1");
}
[Fact]
public void GenerateNextVersion_SameDay_IncrementsSequence()
{
var version = _versioner.GenerateNextVersion("v2026-01-18-1");
version.Should().Be("v2026-01-18-2");
}
[Fact]
public void GenerateNextVersion_SameDay_MultipleIncrements()
{
var version1 = _versioner.GenerateNextVersion("v2026-01-18-5");
var version2 = _versioner.GenerateNextVersion("v2026-01-18-99");
version1.Should().Be("v2026-01-18-6");
version2.Should().Be("v2026-01-18-100");
}
[Fact]
public void GenerateNextVersion_DifferentDay_ResetsSequence()
{
var version = _versioner.GenerateNextVersion("v2026-01-17-5");
version.Should().Be("v2026-01-18-1");
}
[Fact]
public void GenerateNextVersion_InvalidFormat_StartsAtOne()
{
var version = _versioner.GenerateNextVersion("invalid");
version.Should().Be("v2026-01-18-1");
}
[Fact]
public void GenerateNextVersion_UsesTimeProvider()
{
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero));
var version = _versioner.GenerateNextVersion(null);
version.Should().Be("v2026-06-15-1");
}
#endregion
#region Bump Tests
[Fact]
public void Bump_NoChanges_ReturnsNoBumpRequired()
{
var manifest = CreateTestManifest();
var result = _versioner.Bump(manifest, manifest, "No reason");
result.IsSuccess.Should().BeTrue();
result.BumpRequired.Should().BeFalse();
result.BumpedManifest.Should().Be(manifest);
}
[Fact]
public void Bump_WithChanges_CreatesNewVersion()
{
var current = CreateTestManifest("v2026-01-17-1");
var proposed = current with
{
Weights = ScoringWeights.Default with { CvssBase = 0.30 }
};
var result = _versioner.Bump(current, proposed, "Updated CVSS weight");
result.IsSuccess.Should().BeTrue();
result.BumpRequired.Should().BeTrue();
result.BumpedManifest.Should().NotBeNull();
result.BumpedManifest!.ScoringVersion.Should().Be("v2026-01-18-1");
}
[Fact]
public void Bump_PreservesProposedChanges()
{
var current = CreateTestManifest();
var proposed = current with
{
Weights = new ScoringWeights
{
CvssBase = 0.30,
Epss = 0.25,
Reachability = 0.20,
ExploitMaturity = 0.15,
PatchProofConfidence = 0.10
}
};
var result = _versioner.Bump(current, proposed, "Updated all weights");
result.BumpedManifest!.Weights.CvssBase.Should().Be(0.30);
result.BumpedManifest.Weights.Epss.Should().Be(0.25);
}
[Fact]
public void Bump_UpdatesCreatedAt()
{
var current = CreateTestManifest();
var proposed = current with { CodeHash = "sha256:newcodehash" };
var result = _versioner.Bump(current, proposed, "Updated code");
result.BumpedManifest!.CreatedAt.Should().Be(_timeProvider.GetUtcNow());
}
[Fact]
public void Bump_ClearsSignatureAndAnchor()
{
var current = CreateTestManifest() with
{
ManifestDigest = "sha256:old",
DsseSignature = "old-sig",
RekorAnchor = new RekorLinkage
{
Uuid = "old-uuid",
LogIndex = 1,
IntegratedTime = 1234567890
}
};
var proposed = current with { CodeHash = "sha256:newcodehash" };
var result = _versioner.Bump(current, proposed, "Updated code");
result.BumpedManifest!.ManifestDigest.Should().BeNull();
result.BumpedManifest.DsseSignature.Should().BeNull();
result.BumpedManifest.RekorAnchor.Should().BeNull();
}
[Fact]
public void Bump_CreatesHistoryEntry()
{
var current = CreateTestManifest("v2026-01-17-2");
var proposed = current with { CodeHash = "sha256:newcodehash" };
var result = _versioner.Bump(current, proposed, "Algorithm update");
result.HistoryEntry.Should().NotBeNull();
result.HistoryEntry!.PreviousVersion.Should().Be("v2026-01-17-2");
result.HistoryEntry.NewVersion.Should().Be("v2026-01-18-1");
result.HistoryEntry.Reason.Should().Be("Algorithm update");
result.HistoryEntry.BumpedAt.Should().Be(_timeProvider.GetUtcNow());
result.HistoryEntry.Changes.Should().NotBeEmpty();
}
[Fact]
public void Bump_IncludesComparison()
{
var current = CreateTestManifest();
var proposed = current with { CodeHash = "sha256:newcodehash" };
var result = _versioner.Bump(current, proposed, "Algorithm update");
result.Comparison.Should().NotBeNull();
result.Comparison!.RequiresBump.Should().BeTrue();
}
[Fact]
public void Bump_EmptyReason_Throws()
{
var current = CreateTestManifest();
var proposed = current with { CodeHash = "sha256:newcodehash" };
var act = () => _versioner.Bump(current, proposed, "");
act.Should().Throw<ArgumentException>();
}
#endregion
#region BumpAndSignAsync Tests
[Fact]
public async Task BumpAndSignAsync_SignsManifest()
{
var current = CreateTestManifest();
var proposed = current with { CodeHash = "sha256:newcodehash" };
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var signingOptions = new ManifestSigningOptions
{
KeyId = "test-key",
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = testSecret
};
var result = await _versioner.BumpAndSignAsync(current, proposed, "Updated", signingOptions);
result.IsSuccess.Should().BeTrue();
result.BumpedManifest.Should().NotBeNull();
result.BumpedManifest!.ManifestDigest.Should().NotBeNull();
result.BumpedManifest.DsseSignature.Should().NotBeNull();
}
[Fact]
public async Task BumpAndSignAsync_NoChanges_ReturnsOriginal()
{
var manifest = CreateTestManifest();
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var signingOptions = new ManifestSigningOptions
{
KeyId = "test-key",
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = testSecret
};
var result = await _versioner.BumpAndSignAsync(manifest, manifest, "No changes", signingOptions);
result.IsSuccess.Should().BeTrue();
result.BumpRequired.Should().BeFalse();
}
[Fact]
public async Task BumpAndSignAsync_WithAnchorService_AnchorsManifest()
{
var signingService = new ScoringManifestSigningService();
var stubRekorClient = new StubRekorSubmissionClient(_timeProvider);
var anchorService = new ScoringManifestRekorAnchorService(stubRekorClient, _timeProvider);
var versioner = new ScoringManifestVersioner(signingService, anchorService, _timeProvider);
var current = CreateTestManifest();
var proposed = current with { CodeHash = "sha256:newcodehash" };
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var signingOptions = new ManifestSigningOptions
{
KeyId = "test-key",
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = testSecret
};
var anchorOptions = new ManifestAnchorOptions
{
RekorUrl = "https://rekor.sigstore.dev"
};
var result = await versioner.BumpAndSignAsync(current, proposed, "Updated", signingOptions, anchorOptions);
result.IsSuccess.Should().BeTrue();
result.BumpedManifest.Should().NotBeNull();
result.BumpedManifest!.RekorAnchor.Should().NotBeNull();
}
#endregion
#region Integration Tests
[Fact]
public async Task FullWorkflow_Bump_Sign_Verify()
{
var signingService = new ScoringManifestSigningService();
var versioner = new ScoringManifestVersioner(signingService, null, _timeProvider);
var current = CreateTestManifest("v2026-01-17-3");
var proposed = current with
{
Weights = ScoringWeights.Default with { Epss = 0.25 }
};
var testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var signingOptions = new ManifestSigningOptions
{
KeyId = "test-key",
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = testSecret
};
// Bump and sign
var bumpResult = await versioner.BumpAndSignAsync(current, proposed, "Updated EPSS weight", signingOptions);
bumpResult.IsSuccess.Should().BeTrue();
// Verify signature
var verifyOptions = new ManifestVerificationOptions
{
KeyId = "test-key",
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = testSecret
};
var verifyResult = await signingService.VerifyAsync(bumpResult.BumpedManifest!, verifyOptions);
verifyResult.IsValid.Should().BeTrue();
}
[Fact]
public void HistoryEntry_TracksAllChanges()
{
var current = CreateTestManifest();
var proposed = current with
{
Weights = new ScoringWeights
{
CvssBase = 0.30,
Epss = 0.25,
Reachability = 0.20,
ExploitMaturity = 0.15,
PatchProofConfidence = 0.10
},
CodeHash = "sha256:newcodehash"
};
var result = _versioner.Bump(current, proposed, "Major update");
result.HistoryEntry!.Changes.Should().HaveCountGreaterThan(1);
result.HistoryEntry.Changes.Should().Contain(c => c.Field == "code_hash");
result.HistoryEntry.Changes.Should().Contain(c => c.Field.StartsWith("weights."));
}
#endregion
}

View File

@@ -0,0 +1,459 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
// Task: TASK-028-005 - Scoring Manifest Rekor Anchoring
using System.Collections.Immutable;
using System.Security.Cryptography;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.DeltaVerdict.Manifest;
using StellaOps.DeltaVerdict.Signing;
using Xunit;
namespace StellaOps.DeltaVerdict.Tests.Signing;
public class ScoringManifestRekorAnchorServiceTests
{
private readonly ScoringManifestSigningService _signingService = new();
private readonly string _testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private const string TestKeyId = "test-key-001";
private const string TestRekorUrl = "https://rekor.sigstore.dev";
private static ScoringManifest CreateTestManifest()
{
return new ScoringManifest
{
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
ScoringVersion = "v2026-01-18-1",
Weights = ScoringWeights.Default,
Normalizers = ScoringNormalizers.Default,
TrustedVexKeys = ImmutableArray.Create("key1", "key2"),
CodeHash = "sha256:abc123def456",
CreatedAt = new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero)
};
}
private async Task<ScoringManifest> CreateSignedManifest()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
return await _signingService.SignAsync(manifest, signingOptions);
}
#region Anchor Tests
[Fact]
public async Task AnchorAsync_SignedManifest_Succeeds()
{
var signedManifest = await CreateSignedManifest();
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var result = await service.AnchorAsync(signedManifest, options);
result.IsSuccess.Should().BeTrue();
result.AnchoredManifest.Should().NotBeNull();
result.Linkage.Should().NotBeNull();
result.Error.Should().BeNull();
}
[Fact]
public async Task AnchorAsync_PopulatesRekorLinkage()
{
var signedManifest = await CreateSignedManifest();
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var result = await service.AnchorAsync(signedManifest, options);
result.AnchoredManifest!.RekorAnchor.Should().NotBeNull();
result.AnchoredManifest.RekorAnchor!.Uuid.Should().NotBeNullOrEmpty();
result.AnchoredManifest.RekorAnchor.LogIndex.Should().BeGreaterThan(0);
result.AnchoredManifest.RekorAnchor.IntegratedTime.Should().BeGreaterThan(0);
}
[Fact]
public async Task AnchorAsync_PopulatesInclusionProof()
{
var signedManifest = await CreateSignedManifest();
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var result = await service.AnchorAsync(signedManifest, options);
result.AnchoredManifest!.RekorAnchor!.InclusionProof.Should().NotBeNull();
result.AnchoredManifest.RekorAnchor.InclusionProof!.TreeSize.Should().BeGreaterThan(0);
result.AnchoredManifest.RekorAnchor.InclusionProof.RootHash.Should().NotBeNullOrEmpty();
result.AnchoredManifest.RekorAnchor.InclusionProof.LogId.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task AnchorAsync_UnsignedManifest_Fails()
{
var unsignedManifest = CreateTestManifest();
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var result = await service.AnchorAsync(unsignedManifest, options);
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("signed");
}
[Fact]
public async Task AnchorAsync_InvalidDsseSignature_Fails()
{
var manifest = CreateTestManifest() with { DsseSignature = "invalid json {" };
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var result = await service.AnchorAsync(manifest, options);
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("Invalid DSSE signature");
}
[Fact]
public async Task AnchorAsync_SameManifest_ProducesSameUuid()
{
// Deterministic UUID based on content
var signedManifest = await CreateSignedManifest();
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var options = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var result1 = await service.AnchorAsync(signedManifest, options);
// Create a new service instance but resubmit same manifest
var stubClient2 = new StubRekorSubmissionClient();
var service2 = new ScoringManifestRekorAnchorService(stubClient2);
var result2 = await service2.AnchorAsync(signedManifest, options);
// UUIDs should be the same since they're derived from content
result1.AnchoredManifest!.RekorAnchor!.Uuid.Should().Be(
result2.AnchoredManifest!.RekorAnchor!.Uuid);
}
#endregion
#region Verify Anchor Tests
[Fact]
public async Task VerifyAnchorAsync_ValidAnchor_Succeeds()
{
var signedManifest = await CreateSignedManifest();
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var anchorOptions = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var anchorResult = await service.AnchorAsync(signedManifest, anchorOptions);
var verifyOptions = new ManifestAnchorVerificationOptions
{
RequireInclusionProof = true,
VerifyInclusionProof = true
};
var result = await service.VerifyAnchorAsync(anchorResult.AnchoredManifest!, verifyOptions);
result.IsValid.Should().BeTrue();
result.VerifiedUuid.Should().NotBeNullOrEmpty();
result.VerifiedLogIndex.Should().BeGreaterThan(0);
result.VerifiedIntegratedTime.Should().NotBeNull();
}
[Fact]
public async Task VerifyAnchorAsync_NoAnchor_Fails()
{
var signedManifest = await CreateSignedManifest();
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var verifyOptions = new ManifestAnchorVerificationOptions();
var result = await service.VerifyAnchorAsync(signedManifest, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("no Rekor anchor");
}
[Fact]
public async Task VerifyAnchorAsync_NoInclusionProof_FailsWhenRequired()
{
var signedManifest = await CreateSignedManifest();
// Manually add anchor without inclusion proof
var manifestWithAnchor = signedManifest with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 123,
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
InclusionProof = null
}
};
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var verifyOptions = new ManifestAnchorVerificationOptions
{
RequireInclusionProof = true
};
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("no inclusion proof");
}
[Fact]
public async Task VerifyAnchorAsync_NoInclusionProof_SucceedsWhenNotRequired()
{
var signedManifest = await CreateSignedManifest();
var manifestWithAnchor = signedManifest with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 123,
IntegratedTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
InclusionProof = null
}
};
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var verifyOptions = new ManifestAnchorVerificationOptions
{
RequireInclusionProof = false
};
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task VerifyAnchorAsync_FutureTimestamp_Fails()
{
var signedManifest = await CreateSignedManifest();
var futureTime = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds();
var manifestWithAnchor = signedManifest with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 123,
IntegratedTime = futureTime,
InclusionProof = new InclusionProof
{
TreeSize = 100,
RootHash = "abc123",
Hashes = ImmutableArray<string>.Empty,
LogId = "test-log"
}
}
};
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var verifyOptions = new ManifestAnchorVerificationOptions();
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("future");
}
[Fact]
public async Task VerifyAnchorAsync_OldAnchor_FailsWhenMaxAgeExceeded()
{
var signedManifest = await CreateSignedManifest();
var oldTime = DateTimeOffset.UtcNow.AddHours(-25).ToUnixTimeSeconds();
var manifestWithAnchor = signedManifest with
{
RekorAnchor = new RekorLinkage
{
Uuid = "test-uuid",
LogIndex = 123,
IntegratedTime = oldTime,
InclusionProof = new InclusionProof
{
TreeSize = 100,
RootHash = "abc123",
Hashes = ImmutableArray<string>.Empty,
LogId = "test-log"
}
}
};
var stubClient = new StubRekorSubmissionClient();
var service = new ScoringManifestRekorAnchorService(stubClient);
var verifyOptions = new ManifestAnchorVerificationOptions
{
MaxAgeHours = 24
};
var result = await service.VerifyAnchorAsync(manifestWithAnchor, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("older than 24 hours");
}
#endregion
#region Integration Tests
[Fact]
public async Task FullWorkflow_Sign_Anchor_Verify_Succeeds()
{
// Sign
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _signingService.SignAsync(manifest, signingOptions);
// Anchor
var stubClient = new StubRekorSubmissionClient();
var anchorService = new ScoringManifestRekorAnchorService(stubClient);
var anchorOptions = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var anchorResult = await anchorService.AnchorAsync(signedManifest, anchorOptions);
anchorResult.IsSuccess.Should().BeTrue();
// Verify signing
var verifySigningOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signatureResult = await _signingService.VerifyAsync(anchorResult.AnchoredManifest!, verifySigningOptions);
signatureResult.IsValid.Should().BeTrue();
// Verify anchor
var verifyAnchorOptions = new ManifestAnchorVerificationOptions
{
RequireInclusionProof = true,
VerifyInclusionProof = true
};
var anchorVerifyResult = await anchorService.VerifyAnchorAsync(anchorResult.AnchoredManifest!, verifyAnchorOptions);
anchorVerifyResult.IsValid.Should().BeTrue();
}
[Fact]
public async Task FullWorkflow_PreservesManifestData()
{
var originalManifest = CreateTestManifest();
// Sign
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _signingService.SignAsync(originalManifest, signingOptions);
// Anchor
var stubClient = new StubRekorSubmissionClient();
var anchorService = new ScoringManifestRekorAnchorService(stubClient);
var anchorOptions = new ManifestAnchorOptions { RekorUrl = TestRekorUrl };
var result = await anchorService.AnchorAsync(signedManifest, anchorOptions);
// Verify original data is preserved
var finalManifest = result.AnchoredManifest!;
finalManifest.SchemaVersion.Should().Be(originalManifest.SchemaVersion);
finalManifest.ScoringVersion.Should().Be(originalManifest.ScoringVersion);
finalManifest.Weights.Should().Be(originalManifest.Weights);
finalManifest.Normalizers.Should().Be(originalManifest.Normalizers);
finalManifest.TrustedVexKeys.Should().BeEquivalentTo(originalManifest.TrustedVexKeys);
finalManifest.CodeHash.Should().Be(originalManifest.CodeHash);
finalManifest.CreatedAt.Should().Be(originalManifest.CreatedAt);
// And adds signing/anchoring data
finalManifest.ManifestDigest.Should().NotBeNull();
finalManifest.DsseSignature.Should().NotBeNull();
finalManifest.RekorAnchor.Should().NotBeNull();
}
#endregion
#region Stub Client Tests
[Fact]
public async Task StubClient_IncrementsLogIndex()
{
var stubClient = new StubRekorSubmissionClient();
var request = new ManifestRekorSubmissionRequest
{
PayloadType = "test",
PayloadBase64 = "dGVzdA==",
Signatures = [],
BundleSha256 = "abc123",
ArtifactKind = "test",
ArtifactSha256 = "def456"
};
var response1 = await stubClient.SubmitAsync(request, TestRekorUrl);
var response2 = await stubClient.SubmitAsync(request, TestRekorUrl);
response1.LogIndex.Should().Be(1);
response2.LogIndex.Should().Be(2);
}
[Fact]
public async Task StubClient_ProducesConsistentUuidForSameContent()
{
var stubClient1 = new StubRekorSubmissionClient();
var stubClient2 = new StubRekorSubmissionClient();
var request = new ManifestRekorSubmissionRequest
{
PayloadType = "test",
PayloadBase64 = "dGVzdA==",
Signatures = [],
BundleSha256 = "abc123",
ArtifactKind = "test",
ArtifactSha256 = "def456"
};
var response1 = await stubClient1.SubmitAsync(request, TestRekorUrl);
var response2 = await stubClient2.SubmitAsync(request, TestRekorUrl);
response1.Uuid.Should().Be(response2.Uuid);
}
[Fact]
public async Task StubClient_UsesFakeTimeProvider()
{
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero));
var stubClient = new StubRekorSubmissionClient(fakeTime);
var request = new ManifestRekorSubmissionRequest
{
PayloadType = "test",
PayloadBase64 = "dGVzdA==",
Signatures = [],
BundleSha256 = "abc123",
ArtifactKind = "test",
ArtifactSha256 = "def456"
};
var response = await stubClient.SubmitAsync(request, TestRekorUrl);
response.IntegratedTime.Should().Be(fakeTime.GetUtcNow().ToUnixTimeSeconds());
}
#endregion
}

View File

@@ -0,0 +1,449 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2026 StellaOps
// Sprint: SPRINT_20260118_028_LIB_scoring_manifest_jcs_integration
// Task: TASK-028-004 - Scoring Manifest DSSE Signing
using System.Collections.Immutable;
using System.Security.Cryptography;
using FluentAssertions;
using StellaOps.DeltaVerdict.Manifest;
using StellaOps.DeltaVerdict.Signing;
using Xunit;
namespace StellaOps.DeltaVerdict.Tests.Signing;
public class ScoringManifestSigningServiceTests
{
private readonly ScoringManifestSigningService _service = new();
private readonly string _testSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
private const string TestKeyId = "test-key-001";
private static ScoringManifest CreateTestManifest()
{
return new ScoringManifest
{
SchemaVersion = ScoringManifest.CurrentSchemaVersion,
ScoringVersion = "v2026-01-18-1",
Weights = ScoringWeights.Default,
Normalizers = ScoringNormalizers.Default,
TrustedVexKeys = ImmutableArray.Create("key1", "key2"),
CodeHash = "sha256:abc123def456",
CreatedAt = new DateTimeOffset(2026, 1, 18, 12, 0, 0, TimeSpan.Zero)
};
}
#region Sign/Verify Round-Trip Tests
[Fact]
public async Task SignAsync_ProducesValidSignature()
{
var manifest = CreateTestManifest();
var options = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, options);
signedManifest.ManifestDigest.Should().NotBeNullOrEmpty();
signedManifest.ManifestDigest.Should().StartWith("sha256:");
signedManifest.DsseSignature.Should().NotBeNullOrEmpty();
signedManifest.DsseSignature.Should().Contain("payloadType");
signedManifest.DsseSignature.Should().Contain("payload");
signedManifest.DsseSignature.Should().Contain("signatures");
}
[Fact]
public async Task SignAsync_VerifyAsync_RoundTrip_Succeeds()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, signingOptions);
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
result.IsValid.Should().BeTrue();
result.VerifiedKeyId.Should().Be(TestKeyId);
result.Error.Should().BeNull();
}
[Fact]
public async Task SignAsync_ProducesCorrectPayloadType()
{
var manifest = CreateTestManifest();
var options = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, options);
signedManifest.DsseSignature.Should().Contain(ScoringManifestSigningService.PayloadType);
}
[Fact]
public async Task SignAsync_IsDeterministic()
{
var manifest = CreateTestManifest();
var options = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signed1 = await _service.SignAsync(manifest, options);
var signed2 = await _service.SignAsync(manifest, options);
signed1.ManifestDigest.Should().Be(signed2.ManifestDigest);
signed1.DsseSignature.Should().Be(signed2.DsseSignature);
}
#endregion
#region Tamper Detection Tests
[Fact]
public async Task VerifyAsync_DetectsTamperedWeight()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, signingOptions);
// Tamper with the manifest by changing a weight
var tamperedManifest = signedManifest with
{
Weights = ScoringWeights.Default with { CvssBase = 0.99 }
};
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var result = await _service.VerifyAsync(tamperedManifest, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("modified");
}
[Fact]
public async Task VerifyAsync_DetectsTamperedScoringVersion()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, signingOptions);
// Tamper with the scoring version
var tamperedManifest = signedManifest with
{
ScoringVersion = "v2026-01-19-1"
};
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var result = await _service.VerifyAsync(tamperedManifest, verifyOptions);
result.IsValid.Should().BeFalse();
}
[Fact]
public async Task VerifyAsync_DetectsTamperedTrustedVexKeys()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, signingOptions);
// Tamper by adding a malicious key
var tamperedManifest = signedManifest with
{
TrustedVexKeys = ImmutableArray.Create("key1", "key2", "malicious-key")
};
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var result = await _service.VerifyAsync(tamperedManifest, verifyOptions);
result.IsValid.Should().BeFalse();
}
#endregion
#region Wrong Key Tests
[Fact]
public async Task VerifyAsync_FailsWithWrongSecret()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, signingOptions);
var wrongSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = wrongSecret
};
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("verification failed");
}
[Fact]
public async Task VerifyAsync_FailsWithWrongKeyId()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var signedManifest = await _service.SignAsync(manifest, signingOptions);
var verifyOptions = new ManifestVerificationOptions
{
KeyId = "wrong-key-id",
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
result.IsValid.Should().BeFalse();
}
#endregion
#region Unsigned Manifest Tests
[Fact]
public async Task VerifyAsync_FailsForUnsignedManifest()
{
var manifest = CreateTestManifest();
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var result = await _service.VerifyAsync(manifest, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("not signed");
}
[Fact]
public async Task VerifyAsync_FailsForInvalidSignatureJson()
{
var manifest = CreateTestManifest() with
{
DsseSignature = "not valid json {"
};
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = _testSecret
};
var result = await _service.VerifyAsync(manifest, verifyOptions);
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("Invalid signature envelope");
}
#endregion
#region Digest Tests
[Fact]
public void ComputeDigest_ProducesSha256Digest()
{
var manifest = CreateTestManifest();
var digest = _service.ComputeDigest(manifest);
digest.Should().StartWith("sha256:");
digest.Should().HaveLength(71); // "sha256:" (7) + 64 hex chars
digest.Substring(7).Should().MatchRegex("^[0-9a-f]{64}$");
}
[Fact]
public void ComputeDigest_IsDeterministic()
{
var manifest = CreateTestManifest();
var digest1 = _service.ComputeDigest(manifest);
var digest2 = _service.ComputeDigest(manifest);
digest1.Should().Be(digest2);
}
[Fact]
public void ComputeDigest_DiffersForDifferentManifests()
{
var manifest1 = CreateTestManifest();
var manifest2 = CreateTestManifest() with { ScoringVersion = "v2026-01-19-1" };
var digest1 = _service.ComputeDigest(manifest1);
var digest2 = _service.ComputeDigest(manifest2);
digest1.Should().NotBe(digest2);
}
#endregion
#region Canonical JSON Tests
[Fact]
public void GetCanonicalJson_ProducesValidJson()
{
var manifest = CreateTestManifest();
var json = _service.GetCanonicalJson(manifest);
json.Should().NotBeNullOrEmpty();
json.Should().Contain("\"schema_version\"");
json.Should().Contain("\"scoring_version\"");
json.Should().Contain("\"weights\"");
json.Should().Contain("\"normalizers\"");
json.Should().Contain("\"trusted_vex_keys\"");
json.Should().Contain("\"code_hash\"");
}
[Fact]
public void GetCanonicalJson_ExcludesSignatureFields()
{
var manifest = CreateTestManifest() with
{
ManifestDigest = "sha256:test",
DsseSignature = "test-signature"
};
var json = _service.GetCanonicalJson(manifest);
json.Should().NotContain("manifest_digest");
json.Should().NotContain("dsse_signature");
json.Should().NotContain("rekor_anchor");
}
[Fact]
public void GetCanonicalJson_IsDeterministic_100Iterations()
{
var manifest = CreateTestManifest();
var results = Enumerable.Range(0, 100)
.Select(_ => _service.GetCanonicalJson(manifest))
.Distinct()
.ToList();
results.Should().ContainSingle("All 100 serializations should produce identical output");
}
#endregion
#region Algorithm Tests
[Fact]
public async Task SignAsync_WithSha256Algorithm_Works()
{
var manifest = CreateTestManifest();
var signingOptions = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.Sha256
};
var signedManifest = await _service.SignAsync(manifest, signingOptions);
signedManifest.DsseSignature.Should().NotBeNullOrEmpty();
var verifyOptions = new ManifestVerificationOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.Sha256
};
var result = await _service.VerifyAsync(signedManifest, verifyOptions);
result.IsValid.Should().BeTrue();
}
[Fact]
public async Task SignAsync_WithoutSecret_ThrowsForHmac()
{
var manifest = CreateTestManifest();
var options = new ManifestSigningOptions
{
KeyId = TestKeyId,
Algorithm = ManifestSigningAlgorithm.HmacSha256,
SecretBase64 = null
};
var act = async () => await _service.SignAsync(manifest, options);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*secret*");
}
#endregion
}

View File

@@ -12,5 +12,10 @@
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.DeltaVerdict\StellaOps.DeltaVerdict.csproj" />
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
<ProjectReference Include="../../../Signals/StellaOps.Signals/StellaOps.Signals.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -24,4 +24,8 @@
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -17,4 +17,8 @@
<ProjectReference Include="..\..\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -19,4 +19,8 @@
<ProjectReference Include="../../StellaOps.HybridLogicalClock/StellaOps.HybridLogicalClock.csproj" />
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -26,4 +26,8 @@
<ProjectReference Include="../../StellaOps.Provcache.Api/StellaOps.Provcache.Api.csproj" />
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}

View File

@@ -21,4 +21,8 @@
<ProjectReference Include="..\..\StellaOps.Reachability.Core\StellaOps.Reachability.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"parallelizeTestCollections": false,
"maxParallelThreads": 1
}