sprints work

This commit is contained in:
StellaOps Bot
2025-12-25 12:19:12 +02:00
parent 223843f1d1
commit 2a06f780cf
224 changed files with 41796 additions and 1515 deletions

View File

@@ -0,0 +1,244 @@
// -----------------------------------------------------------------------------
// VerdictBudgetCheckTests.cs
// Sprint: SPRINT_8200_0001_0006_budget_threshold_attestation
// Tasks: BUDGET-8200-011, BUDGET-8200-012, BUDGET-8200-013
// Description: Unit tests for budget check attestation
// -----------------------------------------------------------------------------
using FluentAssertions;
using StellaOps.Policy.Engine.Attestation;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Attestation;
public class VerdictBudgetCheckTests
{
[Fact]
public void VerdictBudgetCheck_WithAllFields_CreatesSuccessfully()
{
// Arrange
var config = new VerdictBudgetConfig(
maxUnknownCount: 10,
maxCumulativeUncertainty: 2.5,
action: "warn",
reasonLimits: new Dictionary<string, int> { ["Reachability"] = 5 });
var actualCounts = new VerdictBudgetActualCounts(
total: 3,
cumulativeUncertainty: 1.2,
byReason: new Dictionary<string, int> { ["Reachability"] = 2 });
var configHash = VerdictBudgetCheck.ComputeConfigHash(config);
// Act
var budgetCheck = new VerdictBudgetCheck(
environment: "production",
config: config,
actualCounts: actualCounts,
result: "pass",
configHash: configHash,
evaluatedAt: DateTimeOffset.UtcNow,
violations: null);
// Assert
budgetCheck.Environment.Should().Be("production");
budgetCheck.Config.MaxUnknownCount.Should().Be(10);
budgetCheck.ActualCounts.Total.Should().Be(3);
budgetCheck.Result.Should().Be("pass");
budgetCheck.ConfigHash.Should().StartWith("sha256:");
budgetCheck.Violations.Should().BeEmpty();
}
[Fact]
public void VerdictBudgetCheck_WithViolations_IncludesAllViolations()
{
// Arrange
var config = new VerdictBudgetConfig(5, 2.0, "fail");
var actualCounts = new VerdictBudgetActualCounts(10, 3.0);
var violations = new[]
{
new VerdictBudgetViolation("total", 5, 10),
new VerdictBudgetViolation("reason", 3, 5, "Reachability")
};
// Act
var budgetCheck = new VerdictBudgetCheck(
"staging",
config,
actualCounts,
"fail",
VerdictBudgetCheck.ComputeConfigHash(config),
DateTimeOffset.UtcNow,
violations);
// Assert
budgetCheck.Violations.Should().HaveCount(2);
budgetCheck.Violations[0].Type.Should().Be("reason"); // Sorted
budgetCheck.Violations[1].Type.Should().Be("total");
}
[Fact]
public void ComputeConfigHash_SameConfig_ProducesSameHash()
{
// Arrange
var config1 = new VerdictBudgetConfig(10, 2.5, "warn",
new Dictionary<string, int> { ["Reachability"] = 5 });
var config2 = new VerdictBudgetConfig(10, 2.5, "warn",
new Dictionary<string, int> { ["Reachability"] = 5 });
// Act
var hash1 = VerdictBudgetCheck.ComputeConfigHash(config1);
var hash2 = VerdictBudgetCheck.ComputeConfigHash(config2);
// Assert
hash1.Should().Be(hash2);
}
[Fact]
public void ComputeConfigHash_DifferentConfig_ProducesDifferentHash()
{
// Arrange
var config1 = new VerdictBudgetConfig(10, 2.5, "warn");
var config2 = new VerdictBudgetConfig(20, 2.5, "warn");
// Act
var hash1 = VerdictBudgetCheck.ComputeConfigHash(config1);
var hash2 = VerdictBudgetCheck.ComputeConfigHash(config2);
// Assert
hash1.Should().NotBe(hash2);
}
[Fact]
public void ComputeConfigHash_IsDeterministic()
{
// Arrange
var config = new VerdictBudgetConfig(10, 2.5, "warn",
new Dictionary<string, int>
{
["Reachability"] = 5,
["Identity"] = 3,
["Provenance"] = 2
});
// Act - compute multiple times
var hashes = Enumerable.Range(0, 10)
.Select(_ => VerdictBudgetCheck.ComputeConfigHash(config))
.Distinct()
.ToList();
// Assert
hashes.Should().HaveCount(1, "same config should always produce same hash");
}
[Fact]
public void VerdictBudgetConfig_NormalizesReasonLimits()
{
// Arrange
var limits = new Dictionary<string, int>
{
[" Reachability "] = 5,
[" Identity "] = 3,
[""] = 0 // Should be filtered out
};
// Act
var config = new VerdictBudgetConfig(10, 2.5, "warn", limits);
// Assert
config.ReasonLimits.Should().ContainKey("Reachability");
config.ReasonLimits.Should().ContainKey("Identity");
config.ReasonLimits.Should().NotContainKey("");
}
[Fact]
public void VerdictBudgetActualCounts_NormalizesByReason()
{
// Arrange
var byReason = new Dictionary<string, int>
{
[" Reachability "] = 5,
[" Identity "] = 3
};
// Act
var counts = new VerdictBudgetActualCounts(8, 2.0, byReason);
// Assert
counts.ByReason.Should().ContainKey("Reachability");
counts.ByReason.Should().ContainKey("Identity");
}
[Fact]
public void VerdictBudgetViolation_WithReason_IncludesReason()
{
// Act
var violation = new VerdictBudgetViolation("reason", 5, 10, "Reachability");
// Assert
violation.Type.Should().Be("reason");
violation.Limit.Should().Be(5);
violation.Actual.Should().Be(10);
violation.Reason.Should().Be("Reachability");
}
[Fact]
public void VerdictBudgetViolation_WithoutReason_HasNullReason()
{
// Act
var violation = new VerdictBudgetViolation("total", 5, 10);
// Assert
violation.Reason.Should().BeNull();
}
[Fact]
public void DifferentEnvironments_ProduceDifferentBudgetChecks()
{
// Arrange
var config = new VerdictBudgetConfig(10, 2.5, "warn");
var actualCounts = new VerdictBudgetActualCounts(3, 1.2);
var configHash = VerdictBudgetCheck.ComputeConfigHash(config);
var now = DateTimeOffset.UtcNow;
// Act
var prodCheck = new VerdictBudgetCheck("production", config, actualCounts, "pass", configHash, now);
var devCheck = new VerdictBudgetCheck("development", config, actualCounts, "pass", configHash, now);
// Assert
prodCheck.Environment.Should().Be("production");
devCheck.Environment.Should().Be("development");
prodCheck.ConfigHash.Should().Be(devCheck.ConfigHash, "same config should have same hash");
}
[Fact]
public void VerdictPredicate_IncludesBudgetCheck()
{
// Arrange
var config = new VerdictBudgetConfig(10, 2.5, "warn");
var actualCounts = new VerdictBudgetActualCounts(3, 1.2);
var budgetCheck = new VerdictBudgetCheck(
"production",
config,
actualCounts,
"pass",
VerdictBudgetCheck.ComputeConfigHash(config),
DateTimeOffset.UtcNow);
// Act
var predicate = new VerdictPredicate(
tenantId: "tenant-1",
policyId: "policy-1",
policyVersion: 1,
runId: "run-1",
findingId: "finding-1",
evaluatedAt: DateTimeOffset.UtcNow,
verdict: new VerdictInfo("passed", "low", 25.0),
budgetCheck: budgetCheck);
// Assert
predicate.BudgetCheck.Should().NotBeNull();
predicate.BudgetCheck!.Environment.Should().Be("production");
predicate.BudgetCheck!.Result.Should().Be("pass");
}
}

View File

@@ -6,7 +6,7 @@
using FluentAssertions;
using StellaOps.Policy.Engine;
using StellaOps.DeltaVerdict;
using StellaOps.Excititor.Core.Vex;
using StellaOps.Excititor.Core;
using StellaOps.Policy.Unknowns;
using Xunit;

View File

@@ -0,0 +1,608 @@
// -----------------------------------------------------------------------------
// VerdictSummaryTests.cs
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
// Task: PINT-8200-024
// Description: Unit tests for VerdictSummary extension methods
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.Policy.Confidence.Models;
using StellaOps.Policy.Engine.Evaluation;
using StellaOps.Signals.EvidenceWeightedScore;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Evaluation;
/// <summary>
/// Unit tests for <see cref="VerdictSummary"/> and <see cref="VerdictSummaryExtensions"/>.
/// </summary>
public class VerdictSummaryTests
{
#region ToSummary Tests
[Fact]
public void ToSummary_WithFullEws_ReturnsCompleteSummary()
{
// Arrange
var ews = CreateEwsResult(
score: 85,
bucket: ScoreBucket.ScheduleNext,
flags: ["kev", "reachable"],
explanations: ["High EPSS score", "Confirmed reachable"]);
var result = CreatePolicyResult(
status: "affected",
severity: "High",
matched: true,
ruleName: "block-kev",
ews: ews);
// Act
var summary = result.ToSummary();
// Assert
summary.Status.Should().Be("affected");
summary.Severity.Should().Be("High");
summary.RuleMatched.Should().BeTrue();
summary.RuleName.Should().Be("block-kev");
summary.ScoreBucket.Should().Be("ScheduleNext");
summary.Score.Should().Be(85);
summary.Flags.Should().BeEquivalentTo(["kev", "reachable"]);
summary.Explanations.Should().BeEquivalentTo(["High EPSS score", "Confirmed reachable"]);
}
[Fact]
public void ToSummary_WithoutEws_ReturnsPartialSummary()
{
// Arrange
var result = CreatePolicyResult(
status: "not_affected",
severity: "Medium",
matched: false,
ews: null);
// Act
var summary = result.ToSummary();
// Assert
summary.Status.Should().Be("not_affected");
summary.Severity.Should().Be("Medium");
summary.RuleMatched.Should().BeFalse();
summary.ScoreBucket.Should().BeNull();
summary.Score.Should().BeNull();
summary.TopFactors.Should().BeEmpty();
summary.Flags.Should().BeEmpty();
}
[Fact]
public void ToSummary_ExtractsTopFactorsOrderedByContribution()
{
// Arrange
var breakdown = new List<DimensionContribution>
{
CreateContribution("Runtime", "RTS", 0.8, 20, 16.0),
CreateContribution("Reachability", "RCH", 0.9, 25, 22.5),
CreateContribution("Exploit", "XPL", 0.5, 15, 7.5),
CreateContribution("Mitigation", "MIT", 0.3, 10, -3.0, isSubtractive: true),
};
var ews = CreateEwsResultWithBreakdown(85, ScoreBucket.ScheduleNext, breakdown);
var result = CreatePolicyResult(ews: ews);
// Act
var summary = result.ToSummary();
// Assert
summary.TopFactors.Should().HaveCount(4);
summary.TopFactors[0].Symbol.Should().Be("RCH"); // 22.5 contribution
summary.TopFactors[1].Symbol.Should().Be("RTS"); // 16.0 contribution
summary.TopFactors[2].Symbol.Should().Be("XPL"); // 7.5 contribution
summary.TopFactors[3].Symbol.Should().Be("MIT"); // -3.0 (abs = 3.0)
}
[Fact]
public void ToSummary_LimitsTopFactorsToFive()
{
// Arrange
var breakdown = new List<DimensionContribution>
{
CreateContribution("Reachability", "RCH", 0.9, 25, 22.5),
CreateContribution("Runtime", "RTS", 0.8, 20, 16.0),
CreateContribution("Exploit", "XPL", 0.5, 15, 7.5),
CreateContribution("Source", "SRC", 0.4, 10, 4.0),
CreateContribution("Backport", "BKP", 0.3, 10, 3.0),
CreateContribution("Mitigation", "MIT", 0.2, 5, -1.0, isSubtractive: true),
};
var ews = CreateEwsResultWithBreakdown(85, ScoreBucket.ScheduleNext, breakdown);
var result = CreatePolicyResult(ews: ews);
// Act
var summary = result.ToSummary();
// Assert
summary.TopFactors.Should().HaveCount(5);
}
[Fact]
public void ToSummary_IncludesGuardrailsApplied()
{
// Arrange
var ews = CreateEwsResult(
score: 65,
bucket: ScoreBucket.Investigate,
guardrails: new AppliedGuardrails
{
SpeculativeCap = true,
OriginalScore = 85,
AdjustedScore = 65
});
var result = CreatePolicyResult(ews: ews);
// Act
var summary = result.ToSummary();
// Assert
summary.GuardrailsApplied.Should().BeTrue();
}
[Fact]
public void ToSummary_IncludesExceptionApplied()
{
// Arrange
var result = CreatePolicyResult(
exception: new PolicyExceptionApplication(
ExceptionId: "EXC-001",
EffectId: "effect-001",
EffectType: PolicyExceptionEffectType.Suppress,
OriginalStatus: "affected",
OriginalSeverity: "high",
AppliedStatus: "not_affected",
AppliedSeverity: null,
Metadata: ImmutableDictionary<string, string>.Empty));
// Act
var summary = result.ToSummary();
// Assert
summary.ExceptionApplied.Should().BeTrue();
}
[Fact]
public void ToSummary_IncludesLegacyConfidence()
{
// Arrange - Value=0.75 gives Tier=High
var confidence = new ConfidenceScore
{
Value = 0.75m,
Factors = [],
Explanation = "Medium confidence test"
};
var result = CreatePolicyResult(confidence: confidence);
// Act
var summary = result.ToSummary();
// Assert
summary.ConfidenceScore.Should().Be(0.75m);
summary.ConfidenceBand.Should().Be("High");
}
#endregion
#region ToMinimalSummary Tests
[Fact]
public void ToMinimalSummary_IncludesOnlyEssentialFields()
{
// Arrange
var ews = CreateEwsResult(
score: 92,
bucket: ScoreBucket.ActNow,
flags: ["live-signal", "kev"],
explanations: ["Runtime exploitation detected"]);
var result = CreatePolicyResult(
status: "affected",
severity: "Critical",
matched: true,
ruleName: "block-live-signal",
ews: ews);
// Act
var summary = result.ToMinimalSummary();
// Assert
summary.Status.Should().Be("affected");
summary.Severity.Should().Be("Critical");
summary.RuleMatched.Should().BeTrue();
summary.RuleName.Should().Be("block-live-signal");
summary.ScoreBucket.Should().Be("ActNow");
summary.Score.Should().Be(92);
// Minimal summary should NOT include top factors, flags, explanations
summary.TopFactors.Should().BeEmpty();
summary.Flags.Should().BeEmpty();
summary.Explanations.Should().BeEmpty();
}
#endregion
#region GetPrimaryFactor Tests
[Fact]
public void GetPrimaryFactor_ReturnsHighestContributor()
{
// Arrange
var breakdown = new List<DimensionContribution>
{
CreateContribution("Runtime", "RTS", 0.8, 20, 16.0),
CreateContribution("Reachability", "RCH", 0.9, 25, 22.5),
CreateContribution("Exploit", "XPL", 0.5, 15, 7.5),
};
var ews = CreateEwsResultWithBreakdown(85, ScoreBucket.ScheduleNext, breakdown);
// Act
var primary = ews.GetPrimaryFactor();
// Assert
primary.Should().NotBeNull();
primary!.Symbol.Should().Be("RCH");
primary.Contribution.Should().Be(22.5);
}
[Fact]
public void GetPrimaryFactor_WithNullEws_ReturnsNull()
{
// Arrange
EvidenceWeightedScoreResult? ews = null;
// Act
var primary = ews.GetPrimaryFactor();
// Assert
primary.Should().BeNull();
}
[Fact]
public void GetPrimaryFactor_WithEmptyBreakdown_ReturnsNull()
{
// Arrange
var ews = CreateEwsResultWithBreakdown(50, ScoreBucket.Investigate, []);
// Act
var primary = ews.GetPrimaryFactor();
// Assert
primary.Should().BeNull();
}
#endregion
#region FormatTriageLine Tests
[Fact]
public void FormatTriageLine_IncludesAllComponents()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
Score = 92,
ScoreBucket = "ActNow",
TopFactors =
[
new VerdictFactor { Dimension = "Reachability", Symbol = "RCH", Contribution = 25, Weight = 25, InputValue = 1.0 },
new VerdictFactor { Dimension = "Runtime", Symbol = "RTS", Contribution = 20, Weight = 20, InputValue = 1.0 },
new VerdictFactor { Dimension = "Exploit", Symbol = "XPL", Contribution = 15, Weight = 15, InputValue = 1.0 },
],
Flags = ["live-signal", "kev"],
};
// Act
var line = summary.FormatTriageLine("CVE-2024-1234");
// Assert
line.Should().Contain("[ActNow 92]");
line.Should().Contain("CVE-2024-1234:");
line.Should().Contain("RCH(+25)");
line.Should().Contain("RTS(+20)");
line.Should().Contain("XPL(+15)");
line.Should().Contain("| live-signal, kev");
}
[Fact]
public void FormatTriageLine_HandlesNegativeContributions()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
Score = 45,
ScoreBucket = "Investigate",
TopFactors =
[
new VerdictFactor { Dimension = "Mitigation", Symbol = "MIT", Contribution = -15, Weight = 15, InputValue = 1.0, IsSubtractive = true },
],
};
// Act
var line = summary.FormatTriageLine();
// Assert
line.Should().Contain("MIT(-15)");
}
[Fact]
public void FormatTriageLine_WithoutScore_OmitsScoreSection()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
};
// Act
var line = summary.FormatTriageLine();
// Assert
line.Should().NotContain("[");
line.Should().NotContain("]");
}
#endregion
#region GetBucketExplanation Tests
[Fact]
public void GetBucketExplanation_ActNow_ReturnsUrgentMessage()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
Score = 95,
ScoreBucket = "ActNow",
};
// Act
var explanation = summary.GetBucketExplanation();
// Assert
explanation.Should().Contain("95/100");
explanation.Should().Contain("Strong evidence");
explanation.Should().Contain("Immediate action");
}
[Fact]
public void GetBucketExplanation_WithKevFlag_MentionsKev()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
Score = 85,
ScoreBucket = "ScheduleNext",
Flags = ["kev"],
};
// Act
var explanation = summary.GetBucketExplanation();
// Assert
explanation.Should().Contain("Known Exploited Vulnerability");
}
[Fact]
public void GetBucketExplanation_WithLiveSignal_ShowsAlert()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
Score = 92,
ScoreBucket = "ActNow",
Flags = ["live-signal"],
};
// Act
var explanation = summary.GetBucketExplanation();
// Assert
explanation.Should().Contain("ALERT");
explanation.Should().Contain("Live exploitation");
}
[Fact]
public void GetBucketExplanation_WithVendorNa_MentionsVendorConfirmation()
{
// Arrange
var summary = new VerdictSummary
{
Status = "not_affected",
Score = 15,
ScoreBucket = "Watchlist",
Flags = ["vendor-na"],
};
// Act
var explanation = summary.GetBucketExplanation();
// Assert
explanation.Should().Contain("Vendor has confirmed not affected");
}
[Fact]
public void GetBucketExplanation_WithPrimaryReachabilityFactor_MentionsReachability()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
Score = 75,
ScoreBucket = "ScheduleNext",
TopFactors =
[
new VerdictFactor { Dimension = "Reachability", Symbol = "RCH", Contribution = 25, Weight = 25, InputValue = 1.0 },
],
};
// Act
var explanation = summary.GetBucketExplanation();
// Assert
explanation.Should().Contain("Reachability analysis is the primary driver");
}
[Fact]
public void GetBucketExplanation_WithoutScore_ReturnsNotAvailable()
{
// Arrange
var summary = new VerdictSummary
{
Status = "affected",
};
// Act
var explanation = summary.GetBucketExplanation();
// Assert
explanation.Should().Be("No evidence-weighted score available.");
}
#endregion
#region Null Safety Tests
[Fact]
public void ToSummary_NullResult_ThrowsArgumentNullException()
{
// Arrange
PolicyEvaluationResult? result = null;
// Act & Assert
var action = () => result!.ToSummary();
action.Should().Throw<ArgumentNullException>();
}
[Fact]
public void ToMinimalSummary_NullResult_ThrowsArgumentNullException()
{
// Arrange
PolicyEvaluationResult? result = null;
// Act & Assert
var action = () => result!.ToMinimalSummary();
action.Should().Throw<ArgumentNullException>();
}
[Fact]
public void FormatTriageLine_NullSummary_ThrowsArgumentNullException()
{
// Arrange
VerdictSummary? summary = null;
// Act & Assert
var action = () => summary!.FormatTriageLine();
action.Should().Throw<ArgumentNullException>();
}
[Fact]
public void GetBucketExplanation_NullSummary_ThrowsArgumentNullException()
{
// Arrange
VerdictSummary? summary = null;
// Act & Assert
var action = () => summary!.GetBucketExplanation();
action.Should().Throw<ArgumentNullException>();
}
#endregion
#region Helpers
private static PolicyEvaluationResult CreatePolicyResult(
string status = "affected",
string? severity = null,
bool matched = false,
string? ruleName = null,
int? priority = null,
EvidenceWeightedScoreResult? ews = null,
ConfidenceScore? confidence = null,
PolicyExceptionApplication? exception = null)
{
return new PolicyEvaluationResult(
Matched: matched,
Status: status,
Severity: severity,
RuleName: ruleName,
Priority: priority,
Annotations: ImmutableDictionary<string, string>.Empty,
Warnings: ImmutableArray<string>.Empty,
AppliedException: exception,
Confidence: confidence,
EvidenceWeightedScore: ews);
}
private static EvidenceWeightedScoreResult CreateEwsResult(
int score = 50,
ScoreBucket bucket = ScoreBucket.Investigate,
IEnumerable<string>? flags = null,
IEnumerable<string>? explanations = null,
AppliedGuardrails? guardrails = null)
{
return new EvidenceWeightedScoreResult
{
FindingId = "test-finding-001",
Score = score,
Bucket = bucket,
Inputs = new EvidenceInputValues(0.5, 0.5, 0.5, 0.5, 0.5, 0.5),
Weights = EvidenceWeights.Default,
Breakdown = [],
Flags = flags?.ToList() ?? [],
Explanations = explanations?.ToList() ?? [],
Caps = guardrails ?? AppliedGuardrails.None(score),
PolicyDigest = "sha256:abc123",
CalculatedAt = DateTimeOffset.UtcNow,
};
}
private static EvidenceWeightedScoreResult CreateEwsResultWithBreakdown(
int score,
ScoreBucket bucket,
IReadOnlyList<DimensionContribution> breakdown)
{
return new EvidenceWeightedScoreResult
{
FindingId = "test-finding-001",
Score = score,
Bucket = bucket,
Inputs = new EvidenceInputValues(0.5, 0.5, 0.5, 0.5, 0.5, 0.5),
Weights = EvidenceWeights.Default,
Breakdown = breakdown,
Flags = [],
Explanations = [],
Caps = AppliedGuardrails.None(score),
PolicyDigest = "sha256:abc123",
CalculatedAt = DateTimeOffset.UtcNow,
};
}
private static DimensionContribution CreateContribution(
string dimension,
string symbol,
double inputValue,
double weight,
double contribution,
bool isSubtractive = false)
{
return new DimensionContribution
{
Dimension = dimension,
Symbol = symbol,
InputValue = inputValue,
Weight = weight,
Contribution = contribution,
IsSubtractive = isSubtractive,
};
}
#endregion
}

View File

@@ -4,6 +4,7 @@
using FluentAssertions;
using FsCheck;
using FsCheck.Xunit;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Excititor.Core;
using StellaOps.Excititor.Core.Lattice;

View File

@@ -0,0 +1,571 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright © 2025 StellaOps
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
// Task: PINT-8200-008 - Unit tests for enricher invocation, context population, caching
using FluentAssertions;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Engine.Scoring.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore;
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
using Xunit;
namespace StellaOps.Policy.Engine.Tests.Scoring.EvidenceWeightedScore;
/// <summary>
/// Unit tests for EvidenceWeightedScoreEnricher.
/// </summary>
[Trait("Category", "Unit")]
[Trait("Sprint", "8200.0012.0003")]
public sealed class EvidenceWeightedScoreEnricherTests
{
private readonly TestNormalizerAggregator _aggregator;
private readonly EvidenceWeightedScoreCalculator _calculator;
private readonly TestPolicyProvider _policyProvider;
public EvidenceWeightedScoreEnricherTests()
{
_aggregator = new TestNormalizerAggregator();
_calculator = new EvidenceWeightedScoreCalculator();
_policyProvider = new TestPolicyProvider();
}
#region Feature Flag Tests
[Fact(DisplayName = "Enrich returns skipped when feature disabled")]
public void Enrich_WhenDisabled_ReturnsSkipped()
{
// Arrange
var options = CreateOptions(enabled: false);
var enricher = CreateEnricher(options);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.Should().NotBeNull();
result.IsSuccess.Should().BeFalse();
result.Score.Should().BeNull();
result.FindingId.Should().Be("CVE-2024-1234@pkg:npm/test@1.0.0");
}
[Fact(DisplayName = "Enrich calculates score when feature enabled")]
public void Enrich_WhenEnabled_CalculatesScore()
{
// Arrange
var options = CreateOptions(enabled: true);
var enricher = CreateEnricher(options);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.Should().NotBeNull();
result.IsSuccess.Should().BeTrue();
result.Score.Should().NotBeNull();
result.FindingId.Should().Be("CVE-2024-1234@pkg:npm/test@1.0.0");
result.FromCache.Should().BeFalse();
}
[Fact(DisplayName = "IsEnabled reflects options")]
public void IsEnabled_ReflectsOptions()
{
// Arrange
var enabledOptions = CreateOptions(enabled: true);
var disabledOptions = CreateOptions(enabled: false);
var enabledEnricher = CreateEnricher(enabledOptions);
var disabledEnricher = CreateEnricher(disabledOptions);
// Assert
enabledEnricher.IsEnabled.Should().BeTrue();
disabledEnricher.IsEnabled.Should().BeFalse();
}
#endregion
#region Caching Tests
[Fact(DisplayName = "Enrich caches result when caching enabled")]
public void Enrich_WhenCachingEnabled_CachesResult()
{
// Arrange
var options = CreateOptions(enabled: true, enableCaching: true);
var cache = new InMemoryScoreEnrichmentCache();
var enricher = CreateEnricher(options, cache);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result1 = enricher.Enrich(evidence);
var result2 = enricher.Enrich(evidence);
// Assert
result1.FromCache.Should().BeFalse();
result2.FromCache.Should().BeTrue();
cache.Count.Should().Be(1);
}
[Fact(DisplayName = "Enrich does not cache when caching disabled")]
public void Enrich_WhenCachingDisabled_DoesNotCache()
{
// Arrange
var options = CreateOptions(enabled: true, enableCaching: false);
var cache = new InMemoryScoreEnrichmentCache();
var enricher = CreateEnricher(options, cache);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result1 = enricher.Enrich(evidence);
var result2 = enricher.Enrich(evidence);
// Assert
result1.FromCache.Should().BeFalse();
result2.FromCache.Should().BeFalse();
cache.Count.Should().Be(0);
}
[Fact(DisplayName = "Cache respects max size limit")]
public void Cache_RespectsMaxSizeLimit()
{
// Arrange
var options = CreateOptions(enabled: true, enableCaching: true, maxCachedScores: 2);
var cache = new InMemoryScoreEnrichmentCache();
var enricher = CreateEnricher(options, cache);
// Act - add 3 items
enricher.Enrich(CreateEvidence("finding-1"));
enricher.Enrich(CreateEvidence("finding-2"));
enricher.Enrich(CreateEvidence("finding-3"));
// Assert - cache should stop at max (third item not cached)
cache.Count.Should().Be(2);
}
#endregion
#region Score Calculation Tests
[Fact(DisplayName = "Enrich produces valid score range")]
public void Enrich_ProducesValidScoreRange()
{
// Arrange
var options = CreateOptions(enabled: true);
var enricher = CreateEnricher(options);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.Score.Should().NotBeNull();
result.Score!.Score.Should().BeInRange(0, 100);
}
[Fact(DisplayName = "Enrich with high evidence produces high score")]
public void Enrich_HighEvidence_ProducesHighScore()
{
// Arrange
var options = CreateOptions(enabled: true);
var enricher = CreateEnricher(options);
var evidence = CreateHighEvidenceData("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.Score.Should().NotBeNull();
result.Score!.Score.Should().BeGreaterThanOrEqualTo(70);
}
[Fact(DisplayName = "Enrich with low evidence produces low score")]
public void Enrich_LowEvidence_ProducesLowScore()
{
// Arrange
var options = CreateOptions(enabled: true);
var enricher = CreateEnricher(options);
var evidence = CreateLowEvidenceData("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.Score.Should().NotBeNull();
result.Score!.Score.Should().BeLessThanOrEqualTo(50);
}
[Fact(DisplayName = "Enrich records calculation duration")]
public void Enrich_RecordsCalculationDuration()
{
// Arrange
var options = CreateOptions(enabled: true, enableCaching: false);
var enricher = CreateEnricher(options);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.CalculationDuration.Should().NotBeNull();
result.CalculationDuration!.Value.Should().BeGreaterThan(TimeSpan.Zero);
}
#endregion
#region Async Tests
[Fact(DisplayName = "EnrichAsync returns same result as sync")]
public async Task EnrichAsync_ReturnsSameResultAsSync()
{
// Arrange
var options = CreateOptions(enabled: true, enableCaching: false);
var enricher = CreateEnricher(options);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var syncResult = enricher.Enrich(evidence);
var asyncResult = await enricher.EnrichAsync(evidence);
// Assert
asyncResult.IsSuccess.Should().Be(syncResult.IsSuccess);
asyncResult.Score?.Score.Should().Be(syncResult.Score?.Score);
}
[Fact(DisplayName = "EnrichBatchAsync processes all items")]
public async Task EnrichBatchAsync_ProcessesAllItems()
{
// Arrange
var options = CreateOptions(enabled: true);
var enricher = CreateEnricher(options);
var evidenceList = new[]
{
CreateEvidence("finding-1"),
CreateEvidence("finding-2"),
CreateEvidence("finding-3")
};
// Act
var results = new List<ScoreEnrichmentResult>();
await foreach (var result in enricher.EnrichBatchAsync(evidenceList))
{
results.Add(result);
}
// Assert
results.Should().HaveCount(3);
results.Should().OnlyContain(r => r.IsSuccess);
}
[Fact(DisplayName = "EnrichBatchAsync respects cancellation")]
public async Task EnrichBatchAsync_RespectsCancellation()
{
// Arrange
var options = CreateOptions(enabled: true, enableCaching: false);
var enricher = CreateEnricher(options);
var evidenceList = Enumerable.Range(1, 100)
.Select(i => CreateEvidence($"finding-{i}"))
.ToList();
var cts = new CancellationTokenSource();
cts.Cancel(); // Cancel immediately
// Act
var results = new List<ScoreEnrichmentResult>();
await foreach (var result in enricher.EnrichBatchAsync(evidenceList, cts.Token))
{
results.Add(result);
}
// Assert
results.Should().BeEmpty();
}
#endregion
#region Policy Override Tests
[Fact(DisplayName = "Enrich applies weight overrides")]
public void Enrich_AppliesWeightOverrides()
{
// Arrange
var options = CreateOptions(enabled: true);
options.Weights = new EvidenceWeightsConfiguration
{
Rch = 0.5,
Rts = 0.3,
Bkp = 0.1,
Xpl = 0.05,
Src = 0.05,
Mit = 0.1
};
var enricher = CreateEnricher(options);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert - score calculation should use custom weights
result.IsSuccess.Should().BeTrue();
result.Score.Should().NotBeNull();
}
[Fact(DisplayName = "Enrich applies bucket threshold overrides")]
public void Enrich_AppliesBucketThresholdOverrides()
{
// Arrange
var options = CreateOptions(enabled: true);
options.BucketThresholds = new BucketThresholdsConfiguration
{
ActNowMin = 95,
ScheduleNextMin = 80,
InvestigateMin = 50
};
var enricher = CreateEnricher(options);
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.IsSuccess.Should().BeTrue();
result.Score.Should().NotBeNull();
}
#endregion
#region Error Handling Tests
[Fact(DisplayName = "Enrich handles aggregator exception gracefully")]
public void Enrich_HandleAggregatorException_Gracefully()
{
// Arrange
var options = CreateOptions(enabled: true);
var failingAggregator = new FailingNormalizerAggregator();
var enricher = new EvidenceWeightedScoreEnricher(
failingAggregator,
_calculator,
_policyProvider,
CreateOptionsMonitor(options));
var evidence = CreateEvidence("CVE-2024-1234@pkg:npm/test@1.0.0");
// Act
var result = enricher.Enrich(evidence);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().NotBeNullOrEmpty();
result.Score.Should().BeNull();
}
#endregion
#region Helper Methods
private EvidenceWeightedScoreEnricher CreateEnricher(
PolicyEvidenceWeightedScoreOptions options,
IScoreEnrichmentCache? cache = null)
{
return new EvidenceWeightedScoreEnricher(
_aggregator,
_calculator,
_policyProvider,
CreateOptionsMonitor(options),
logger: null,
cache: cache);
}
private static PolicyEvidenceWeightedScoreOptions CreateOptions(
bool enabled = false,
bool enableCaching = true,
int maxCachedScores = 10_000)
{
return new PolicyEvidenceWeightedScoreOptions
{
Enabled = enabled,
EnableCaching = enableCaching,
MaxCachedScoresPerContext = maxCachedScores
};
}
private static IOptionsMonitor<PolicyEvidenceWeightedScoreOptions> CreateOptionsMonitor(
PolicyEvidenceWeightedScoreOptions options)
{
return new StaticOptionsMonitor<PolicyEvidenceWeightedScoreOptions>(options);
}
private static FindingEvidence CreateEvidence(string findingId)
{
return new FindingEvidence
{
FindingId = findingId
};
}
private static FindingEvidence CreateHighEvidenceData(string findingId)
{
return new FindingEvidence
{
FindingId = findingId,
Reachability = new ReachabilityInput
{
State = ReachabilityState.DynamicReachable,
Confidence = 0.95
},
Runtime = new RuntimeInput
{
Posture = RuntimePosture.ActiveTracing,
ObservationCount = 10,
RecencyFactor = 0.95
},
Exploit = new ExploitInput
{
EpssScore = 0.85,
EpssPercentile = 95,
KevStatus = KevStatus.InKev,
PublicExploitAvailable = true
}
};
}
private static FindingEvidence CreateLowEvidenceData(string findingId)
{
return new FindingEvidence
{
FindingId = findingId,
Reachability = new ReachabilityInput
{
State = ReachabilityState.Unknown,
Confidence = 0.1
}
};
}
#endregion
#region Test Doubles
private sealed class TestNormalizerAggregator : INormalizerAggregator
{
public Task<EvidenceWeightedScoreInput> AggregateAsync(
string findingId,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Aggregate(new FindingEvidence { FindingId = findingId }));
}
public EvidenceWeightedScoreInput Aggregate(FindingEvidence evidence)
{
// Simple aggregation - use defaults for missing evidence
var rch = evidence.Reachability is not null
? (evidence.Reachability.Confidence * MapReachabilityState(evidence.Reachability.State))
: 0.3; // Default
var rts = evidence.Runtime is not null
? 0.7 * (evidence.Runtime.ObservationCount > 0 ? 1.0 : 0.5)
: 0.0;
var xpl = evidence.Exploit is not null
? (evidence.Exploit.EpssScore +
(evidence.Exploit.KevStatus == KevStatus.InKev ? 0.3 : 0.0) +
(evidence.Exploit.PublicExploitAvailable ? 0.2 : 0.0)) / 1.5
: 0.0;
return new EvidenceWeightedScoreInput
{
FindingId = evidence.FindingId,
Rch = Math.Clamp(rch, 0, 1),
Rts = Math.Clamp(rts, 0, 1),
Bkp = 0.0,
Xpl = Math.Clamp(xpl, 0, 1),
Src = 0.5,
Mit = 0.0
};
}
public AggregationResult AggregateWithDetails(FindingEvidence evidence)
{
return new AggregationResult
{
Input = Aggregate(evidence),
Details = new Dictionary<string, NormalizationResult>()
};
}
private static double MapReachabilityState(ReachabilityState state) => state switch
{
ReachabilityState.LiveExploitPath => 1.0,
ReachabilityState.DynamicReachable => 0.9,
ReachabilityState.StaticReachable => 0.7,
ReachabilityState.PotentiallyReachable => 0.4,
ReachabilityState.NotReachable => 0.1,
_ => 0.3
};
}
private sealed class FailingNormalizerAggregator : INormalizerAggregator
{
public Task<EvidenceWeightedScoreInput> AggregateAsync(
string findingId,
CancellationToken cancellationToken = default)
{
throw new InvalidOperationException("Simulated aggregator failure");
}
public EvidenceWeightedScoreInput Aggregate(FindingEvidence evidence)
{
throw new InvalidOperationException("Simulated aggregator failure");
}
public AggregationResult AggregateWithDetails(FindingEvidence evidence)
{
throw new InvalidOperationException("Simulated aggregator failure");
}
}
private sealed class TestPolicyProvider : IEvidenceWeightPolicyProvider
{
public EvidenceWeightPolicy Policy { get; set; } = EvidenceWeightPolicy.DefaultProduction;
public Task<EvidenceWeightPolicy> GetPolicyAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
return Task.FromResult(Policy);
}
public Task<EvidenceWeightPolicy> GetDefaultPolicyAsync(
string environment,
CancellationToken cancellationToken = default)
{
return Task.FromResult(EvidenceWeightPolicy.DefaultProduction);
}
public Task<bool> PolicyExistsAsync(
string? tenantId,
string environment,
CancellationToken cancellationToken = default)
{
return Task.FromResult(true);
}
}
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
where T : class
{
private readonly T _value;
public StaticOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
#endregion
}