up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.PolicyDsl;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Compilation;
|
||||
|
||||
public sealed class PolicyMetadataExtractorTests
|
||||
{
|
||||
private readonly PolicyMetadataExtractor _extractor = new();
|
||||
private readonly PolicyCompiler _compiler = new();
|
||||
|
||||
[Fact]
|
||||
public void Extract_EmptyPolicy_ReturnsEmptyMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "Empty" syntax "stella-dsl@1" {
|
||||
rule empty_rule priority 1 {
|
||||
when true
|
||||
then status := "test"
|
||||
because "Test rule"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.Should().NotBeNull();
|
||||
metadata.SymbolTable.Should().NotBeNull();
|
||||
metadata.RuleIndex.Should().NotBeNull();
|
||||
metadata.Documentation.Should().NotBeNull();
|
||||
metadata.CoverageMetadata.Should().NotBeNull();
|
||||
metadata.Hashes.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_SymbolTable_ContainsRuleSymbols()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "SymbolTest" syntax "stella-dsl@1" {
|
||||
rule severity_check priority 1 {
|
||||
when advisory.severity == "critical"
|
||||
then status := "blocked"
|
||||
because "Block critical vulnerabilities"
|
||||
}
|
||||
rule low_severity priority 2 {
|
||||
when advisory.severity == "low"
|
||||
then status := "allowed"
|
||||
because "Allow low severity"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.SymbolTable.Symbols.Should().Contain(s => s.Name == "severity_check" && s.Kind == PolicySymbolKind.Rule);
|
||||
metadata.SymbolTable.Symbols.Should().Contain(s => s.Name == "low_severity" && s.Kind == PolicySymbolKind.Rule);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_SymbolTable_TracksIdentifierReferences()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "RefTest" syntax "stella-dsl@1" {
|
||||
rule check priority 1 {
|
||||
when advisory.severity == "critical" and component.ecosystem == "npm"
|
||||
then status := "blocked"
|
||||
because "Test"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.SymbolTable.ReferencesByName.Should().ContainKey("advisory");
|
||||
metadata.SymbolTable.ReferencesByName.Should().ContainKey("component");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_SymbolTable_ContainsBuiltInFunctions()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "FuncTest" syntax "stella-dsl@1" {
|
||||
rule check priority 1 {
|
||||
when true
|
||||
then status := "test"
|
||||
because "Test"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.SymbolTable.BuiltInFunctions.Should().NotBeEmpty();
|
||||
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "contains");
|
||||
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "startsWith");
|
||||
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "matches");
|
||||
metadata.SymbolTable.BuiltInFunctions.Should().Contain(f => f.Name == "now");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_RuleIndex_IndexesRulesByName()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "IndexTest" syntax "stella-dsl@1" {
|
||||
rule rule_a priority 1 {
|
||||
when true
|
||||
then status := "a"
|
||||
because "A"
|
||||
}
|
||||
rule rule_b priority 2 {
|
||||
when true
|
||||
then status := "b"
|
||||
because "B"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.RuleIndex.ByName.Should().ContainKey("rule_a");
|
||||
metadata.RuleIndex.ByName.Should().ContainKey("rule_b");
|
||||
metadata.RuleIndex.ByName["rule_a"].Priority.Should().Be(1);
|
||||
metadata.RuleIndex.ByName["rule_b"].Priority.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_RuleIndex_IndexesRulesByPriority()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "PriorityTest" syntax "stella-dsl@1" {
|
||||
rule high_priority priority 1 {
|
||||
when true
|
||||
then status := "high"
|
||||
because "High"
|
||||
}
|
||||
rule also_high priority 1 {
|
||||
when true
|
||||
then status := "also_high"
|
||||
because "Also high"
|
||||
}
|
||||
rule low_priority priority 10 {
|
||||
when true
|
||||
then status := "low"
|
||||
because "Low"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.RuleIndex.ByPriority.Should().ContainKey(1);
|
||||
metadata.RuleIndex.ByPriority.Should().ContainKey(10);
|
||||
metadata.RuleIndex.ByPriority[1].Should().HaveCount(2);
|
||||
metadata.RuleIndex.ByPriority[10].Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_RuleIndex_TracksActionTypes()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "ActionTest" syntax "stella-dsl@1" {
|
||||
rule mixed_actions priority 1 {
|
||||
when advisory.severity == "critical"
|
||||
then status := "blocked"; warn message "blocking"
|
||||
else status := "allowed"
|
||||
because "Mixed actions"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.RuleIndex.ActionTypes.Should().Contain("assign");
|
||||
metadata.RuleIndex.ActionTypes.Should().Contain("warn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_Documentation_ExtractsMetadata()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "DocTest" syntax "stella-dsl@1" {
|
||||
metadata {
|
||||
description = "A test policy for documentation"
|
||||
author = "Test Author"
|
||||
tags = ["security", "compliance"]
|
||||
}
|
||||
rule check priority 1 {
|
||||
when true
|
||||
then status := "test"
|
||||
because "Test rule"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.Documentation.PolicyDescription.Should().Be("A test policy for documentation");
|
||||
metadata.Documentation.Author.Should().Be("Test Author");
|
||||
metadata.Documentation.Tags.Should().Contain("security");
|
||||
metadata.Documentation.Tags.Should().Contain("compliance");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_Documentation_ExtractsRuleJustifications()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "JustificationTest" syntax "stella-dsl@1" {
|
||||
rule critical_block priority 1 {
|
||||
when advisory.severity == "critical"
|
||||
then status := "blocked"
|
||||
because "Critical vulnerabilities must be blocked immediately"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.Documentation.RuleDocumentation.Should().HaveCount(1);
|
||||
metadata.Documentation.RuleDocumentation[0].Justification.Should().Be("Critical vulnerabilities must be blocked immediately");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_CoverageMetadata_TracksCoveragePoints()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "CoverageTest" syntax "stella-dsl@1" {
|
||||
rule with_else priority 1 {
|
||||
when advisory.severity == "critical"
|
||||
then status := "blocked"
|
||||
else status := "allowed"
|
||||
because "Test coverage"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.CoverageMetadata.TotalRules.Should().Be(1);
|
||||
metadata.CoverageMetadata.Rules[0].HasElseBranch.Should().BeTrue();
|
||||
metadata.CoverageMetadata.Rules[0].CoveragePoints.Should().Contain("with_else:condition");
|
||||
metadata.CoverageMetadata.Rules[0].CoveragePoints.Should().Contain("with_else:then");
|
||||
metadata.CoverageMetadata.Rules[0].CoveragePoints.Should().Contain("with_else:else");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_CoverageMetadata_GeneratesCoveragePaths()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "PathTest" syntax "stella-dsl@1" {
|
||||
rule rule_1 priority 1 {
|
||||
when true
|
||||
then status := "1"
|
||||
because "Rule 1"
|
||||
}
|
||||
rule rule_2 priority 2 {
|
||||
when true
|
||||
then status := "2"
|
||||
because "Rule 2"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
// 2 rules = 4 possible paths (2^2)
|
||||
metadata.CoverageMetadata.CoveragePaths.Should().HaveCount(4);
|
||||
metadata.CoverageMetadata.CoveragePaths.Should().OnlyContain(p => p.RuleSequence.Length == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_Hashes_AreConsistentForSameInput()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "HashTest" syntax "stella-dsl@1" {
|
||||
rule check priority 1 {
|
||||
when true
|
||||
then status := "test"
|
||||
because "Test"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata1 = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
var metadata2 = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata1.Hashes.ContentHash.Should().Be(metadata2.Hashes.ContentHash);
|
||||
metadata1.Hashes.StructureHash.Should().Be(metadata2.Hashes.StructureHash);
|
||||
metadata1.Hashes.OrderingHash.Should().Be(metadata2.Hashes.OrderingHash);
|
||||
metadata1.Hashes.IdentityHash.Should().Be(metadata2.Hashes.IdentityHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_Hashes_DifferForDifferentPolicies()
|
||||
{
|
||||
// Arrange
|
||||
var source1 = """
|
||||
policy "Policy1" syntax "stella-dsl@1" {
|
||||
rule check priority 1 {
|
||||
when true
|
||||
then status := "1"
|
||||
because "Test 1"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var source2 = """
|
||||
policy "Policy2" syntax "stella-dsl@1" {
|
||||
rule check priority 1 {
|
||||
when true
|
||||
then status := "2"
|
||||
because "Test 2"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result1 = _compiler.Compile(source1);
|
||||
var result2 = _compiler.Compile(source2);
|
||||
result1.Success.Should().BeTrue();
|
||||
result2.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata1 = _extractor.Extract(result1.Document!, result1.CanonicalRepresentation);
|
||||
var metadata2 = _extractor.Extract(result2.Document!, result2.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata1.Hashes.ContentHash.Should().NotBe(metadata2.Hashes.ContentHash);
|
||||
metadata1.Hashes.IdentityHash.Should().NotBe(metadata2.Hashes.IdentityHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_SymbolTable_TracksVariableDefinitions()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "VarTest" syntax "stella-dsl@1" {
|
||||
rule assign_var priority 1 {
|
||||
when advisory.severity == "critical"
|
||||
then status := "blocked"; reason := "Critical vuln"
|
||||
because "Test"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.SymbolTable.Variables.Should().Contain(v => v.Name == "status");
|
||||
metadata.SymbolTable.Variables.Should().Contain(v => v.Name == "reason");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_RuleIndex_TracksReferencedIdentifiers()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "RefIdentTest" syntax "stella-dsl@1" {
|
||||
rule check priority 1 {
|
||||
when advisory.severity == "critical" and component.ecosystem == "npm"
|
||||
then status := "blocked"
|
||||
because "Test"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.RuleIndex.UsedIdentifiers.Should().Contain("advisory");
|
||||
metadata.RuleIndex.UsedIdentifiers.Should().Contain("component");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extract_CoverageMetadata_CountsActionTypes()
|
||||
{
|
||||
// Arrange
|
||||
var source = """
|
||||
policy "ActionCountTest" syntax "stella-dsl@1" {
|
||||
rule rule1 priority 1 {
|
||||
when true
|
||||
then status := "a"; warn message "warning"
|
||||
because "Rule 1"
|
||||
}
|
||||
rule rule2 priority 2 {
|
||||
when true
|
||||
then status := "b"
|
||||
because "Rule 2"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var result = _compiler.Compile(source);
|
||||
result.Success.Should().BeTrue();
|
||||
|
||||
// Act
|
||||
var metadata = _extractor.Extract(result.Document!, result.CanonicalRepresentation);
|
||||
|
||||
// Assert
|
||||
metadata.CoverageMetadata.ActionTypeCounts.Should().ContainKey("assign");
|
||||
metadata.CoverageMetadata.ActionTypeCounts["assign"].Should().Be(2);
|
||||
metadata.CoverageMetadata.ActionTypeCounts.Should().ContainKey("warn");
|
||||
metadata.CoverageMetadata.ActionTypeCounts["warn"].Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.DeterminismGuard;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.DeterminismGuard;
|
||||
|
||||
public sealed class DeterminismGuardTests
|
||||
{
|
||||
#region ProhibitedPatternAnalyzer Tests
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsDateTimeNow()
|
||||
{
|
||||
// Arrange
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = """
|
||||
public class Test
|
||||
{
|
||||
public DateTime GetTime() => DateTime.Now;
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
// Assert
|
||||
result.Passed.Should().BeFalse();
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "DateTime.Now" &&
|
||||
v.Category == DeterminismViolationCategory.WallClock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsDateTimeUtcNow()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var now = DateTime.UtcNow;";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "DateTime.UtcNow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsRandomClass()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var rng = new Random();";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "Random" &&
|
||||
v.Category == DeterminismViolationCategory.RandomNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsGuidNewGuid()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var id = Guid.NewGuid();";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "Guid.NewGuid" &&
|
||||
v.Category == DeterminismViolationCategory.GuidGeneration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsHttpClient()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "private readonly HttpClient _client = new();";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "HttpClient" &&
|
||||
v.Category == DeterminismViolationCategory.NetworkAccess &&
|
||||
v.Severity == DeterminismViolationSeverity.Critical);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsFileOperations()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = """
|
||||
var content = File.ReadAllText("test.txt");
|
||||
File.WriteAllText("out.txt", content);
|
||||
""";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().HaveCount(2);
|
||||
result.Violations.Should().Contain(v => v.ViolationType == "File.Read");
|
||||
result.Violations.Should().Contain(v => v.ViolationType == "File.Write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_DetectsEnvironmentVariableAccess()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var path = Environment.GetEnvironmentVariable(\"PATH\");";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v =>
|
||||
v.ViolationType == "Environment.GetEnvironmentVariable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_IgnoresComments()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = """
|
||||
// DateTime.Now is not allowed
|
||||
/* DateTime.UtcNow either */
|
||||
* Random comment
|
||||
""";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().BeEmpty();
|
||||
result.Passed.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_RespectsExcludePatterns()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = "var now = DateTime.Now;";
|
||||
var options = DeterminismGuardOptions.Default with
|
||||
{
|
||||
ExcludePatterns = ["test.cs"]
|
||||
};
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", options);
|
||||
|
||||
result.Passed.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_PassesCleanCode()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = """
|
||||
public class PolicyEvaluator
|
||||
{
|
||||
public bool Evaluate(PolicyContext context)
|
||||
{
|
||||
return context.Severity.Score > 7.0m;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "evaluator.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Passed.Should().BeTrue();
|
||||
result.Violations.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeSource_TracksLineNumbers()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var source = """
|
||||
public class Test
|
||||
{
|
||||
public void Method()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var result = analyzer.AnalyzeSource(source, "test.cs", DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().ContainSingle(v => v.LineNumber == 5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeMultiple_AggregatesViolations()
|
||||
{
|
||||
var analyzer = new ProhibitedPatternAnalyzer();
|
||||
var sources = new[]
|
||||
{
|
||||
("file1.cs", "var now = DateTime.Now;"),
|
||||
("file2.cs", "var rng = new Random();"),
|
||||
("file3.cs", "var id = Guid.NewGuid();")
|
||||
};
|
||||
|
||||
var result = analyzer.AnalyzeMultiple(
|
||||
sources.Select(s => (s.Item2, s.Item1)),
|
||||
DeterminismGuardOptions.Default);
|
||||
|
||||
result.Violations.Should().HaveCount(3);
|
||||
result.Violations.Select(v => v.SourceFile).Should()
|
||||
.BeEquivalentTo(["file1.cs", "file2.cs", "file3.cs"]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeterminismGuardService Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateScope_ReturnsFixedTimestamp()
|
||||
{
|
||||
var guard = new DeterminismGuardService();
|
||||
var timestamp = new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
using var scope = guard.CreateScope("test-scope", timestamp);
|
||||
|
||||
scope.GetTimestamp().Should().Be(timestamp);
|
||||
scope.EvaluationTimestamp.Should().Be(timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScope_TracksViolations()
|
||||
{
|
||||
var guard = new DeterminismGuardService();
|
||||
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
|
||||
|
||||
var violation = new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "Test",
|
||||
Message = "Test violation",
|
||||
Severity = DeterminismViolationSeverity.Warning
|
||||
};
|
||||
|
||||
scope.ReportViolation(violation);
|
||||
|
||||
scope.GetViolations().Should().ContainSingle(v => v.Message == "Test violation");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScope_ThrowsOnBlockingViolationWhenEnforcementEnabled()
|
||||
{
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = true,
|
||||
FailOnSeverity = DeterminismViolationSeverity.Error
|
||||
};
|
||||
var guard = new DeterminismGuardService(options);
|
||||
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
|
||||
|
||||
var violation = new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "Test",
|
||||
Message = "Blocking violation",
|
||||
Severity = DeterminismViolationSeverity.Error
|
||||
};
|
||||
|
||||
var act = () => scope.ReportViolation(violation);
|
||||
|
||||
act.Should().Throw<DeterminismViolationException>()
|
||||
.Which.Violation.Should().Be(violation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateScope_DoesNotThrowWhenEnforcementDisabled()
|
||||
{
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = false
|
||||
};
|
||||
var guard = new DeterminismGuardService(options);
|
||||
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
|
||||
|
||||
var violation = new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "Test",
|
||||
Message = "Should not throw",
|
||||
Severity = DeterminismViolationSeverity.Critical
|
||||
};
|
||||
|
||||
var act = () => scope.ReportViolation(violation);
|
||||
|
||||
act.Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_ReturnsAnalysisResult()
|
||||
{
|
||||
var guard = new DeterminismGuardService();
|
||||
using var scope = guard.CreateScope("test-scope", DateTimeOffset.UtcNow);
|
||||
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.RandomNumber,
|
||||
ViolationType = "Test",
|
||||
Message = "Warning violation",
|
||||
Severity = DeterminismViolationSeverity.Warning
|
||||
});
|
||||
|
||||
var result = scope.Complete();
|
||||
|
||||
result.Passed.Should().BeTrue(); // Only warnings, no errors
|
||||
result.Violations.Should().HaveCount(1);
|
||||
result.CountBySeverity.Should().ContainKey(DeterminismViolationSeverity.Warning);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeterministicTimeProvider Tests
|
||||
|
||||
[Fact]
|
||||
public void DeterministicTimeProvider_ReturnsFixedTimestamp()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2025, 6, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
var provider = new DeterministicTimeProvider(fixedTime);
|
||||
|
||||
provider.GetUtcNow().Should().Be(fixedTime);
|
||||
provider.GetUtcNow().Should().Be(fixedTime); // Same value on repeated calls
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeterministicTimeProvider_ReturnsUtcTimeZone()
|
||||
{
|
||||
var provider = new DeterministicTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
provider.LocalTimeZone.Should().Be(TimeZoneInfo.Utc);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GuardedPolicyEvaluator Tests
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_ReturnsResultWithViolations()
|
||||
{
|
||||
var evaluator = new GuardedPolicyEvaluator();
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
var result = evaluator.Evaluate("test-scope", timestamp, scope =>
|
||||
{
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.WallClock,
|
||||
ViolationType = "Test",
|
||||
Message = "Test warning",
|
||||
Severity = DeterminismViolationSeverity.Warning
|
||||
});
|
||||
return 42;
|
||||
});
|
||||
|
||||
result.Succeeded.Should().BeTrue();
|
||||
result.Result.Should().Be(42);
|
||||
result.HasViolations.Should().BeTrue();
|
||||
result.Violations.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_CapturesBlockingViolation()
|
||||
{
|
||||
var options = new DeterminismGuardOptions
|
||||
{
|
||||
EnforcementEnabled = true,
|
||||
FailOnSeverity = DeterminismViolationSeverity.Error
|
||||
};
|
||||
var evaluator = new GuardedPolicyEvaluator(options);
|
||||
|
||||
var result = evaluator.Evaluate("test-scope", DateTimeOffset.UtcNow, scope =>
|
||||
{
|
||||
scope.ReportViolation(new DeterminismViolation
|
||||
{
|
||||
Category = DeterminismViolationCategory.NetworkAccess,
|
||||
ViolationType = "HttpClient",
|
||||
Message = "Network access blocked",
|
||||
Severity = DeterminismViolationSeverity.Critical
|
||||
});
|
||||
return "should not return";
|
||||
});
|
||||
|
||||
result.Succeeded.Should().BeFalse();
|
||||
result.WasBlocked.Should().BeTrue();
|
||||
result.BlockingViolation.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatePolicySource_ReturnsViolations()
|
||||
{
|
||||
var evaluator = new GuardedPolicyEvaluator();
|
||||
var source = "var now = DateTime.Now;";
|
||||
|
||||
var result = evaluator.ValidatePolicySource(source, "policy.cs");
|
||||
|
||||
result.Violations.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WorksWithAsyncCode()
|
||||
{
|
||||
var evaluator = new GuardedPolicyEvaluator();
|
||||
|
||||
var result = await evaluator.EvaluateAsync("async-scope", DateTimeOffset.UtcNow, async scope =>
|
||||
{
|
||||
await Task.Delay(1);
|
||||
return "async result";
|
||||
});
|
||||
|
||||
result.Succeeded.Should().BeTrue();
|
||||
result.Result.Should().Be("async result");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DeterminismGuardOptions Tests
|
||||
|
||||
[Fact]
|
||||
public void Default_HasEnforcementEnabled()
|
||||
{
|
||||
DeterminismGuardOptions.Default.EnforcementEnabled.Should().BeTrue();
|
||||
DeterminismGuardOptions.Default.FailOnSeverity.Should().Be(DeterminismViolationSeverity.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Development_HasEnforcementDisabled()
|
||||
{
|
||||
DeterminismGuardOptions.Development.EnforcementEnabled.Should().BeFalse();
|
||||
DeterminismGuardOptions.Development.FailOnSeverity.Should().Be(DeterminismViolationSeverity.Critical);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.IncrementalOrchestrator;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.IncrementalOrchestrator;
|
||||
|
||||
public sealed class IncrementalOrchestratorTests
|
||||
{
|
||||
#region PolicyChangeEvent Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateAdvisoryUpdated_CreatesValidEvent()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var evt = PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
tenantId: "test-tenant",
|
||||
advisoryId: "GHSA-test-001",
|
||||
vulnerabilityId: "CVE-2021-12345",
|
||||
affectedPurls: ["pkg:npm/lodash", "pkg:npm/express"],
|
||||
source: "concelier",
|
||||
occurredAt: now,
|
||||
createdAt: now);
|
||||
|
||||
evt.ChangeType.Should().Be(PolicyChangeType.AdvisoryUpdated);
|
||||
evt.TenantId.Should().Be("test-tenant");
|
||||
evt.AdvisoryId.Should().Be("GHSA-test-001");
|
||||
evt.VulnerabilityId.Should().Be("CVE-2021-12345");
|
||||
evt.AffectedPurls.Should().HaveCount(2);
|
||||
evt.EventId.Should().StartWith("pce-");
|
||||
evt.ContentHash.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateVexUpdated_CreatesValidEvent()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var evt = PolicyChangeEventFactory.CreateVexUpdated(
|
||||
tenantId: "test-tenant",
|
||||
vulnerabilityId: "CVE-2021-12345",
|
||||
affectedProductKeys: ["pkg:npm/lodash"],
|
||||
source: "excititor",
|
||||
occurredAt: now,
|
||||
createdAt: now);
|
||||
|
||||
evt.ChangeType.Should().Be(PolicyChangeType.VexStatementUpdated);
|
||||
evt.VulnerabilityId.Should().Be("CVE-2021-12345");
|
||||
evt.AffectedProductKeys.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSbomUpdated_CreatesValidEvent()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var evt = PolicyChangeEventFactory.CreateSbomUpdated(
|
||||
tenantId: "test-tenant",
|
||||
sbomId: "sbom-123",
|
||||
productKey: "myapp:v1.0.0",
|
||||
componentPurls: ["pkg:npm/lodash@4.17.21"],
|
||||
source: "scanner",
|
||||
occurredAt: now,
|
||||
createdAt: now);
|
||||
|
||||
evt.ChangeType.Should().Be(PolicyChangeType.SbomUpdated);
|
||||
evt.AffectedSbomIds.Should().Contain("sbom-123");
|
||||
evt.AffectedProductKeys.Should().Contain("myapp:v1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_IsDeterministic()
|
||||
{
|
||||
var hash1 = PolicyChangeEvent.ComputeContentHash(
|
||||
PolicyChangeType.AdvisoryUpdated,
|
||||
"tenant",
|
||||
"ADV-001",
|
||||
"CVE-001",
|
||||
["pkg:npm/a", "pkg:npm/b"],
|
||||
null,
|
||||
null);
|
||||
|
||||
var hash2 = PolicyChangeEvent.ComputeContentHash(
|
||||
PolicyChangeType.AdvisoryUpdated,
|
||||
"tenant",
|
||||
"ADV-001",
|
||||
"CVE-001",
|
||||
["pkg:npm/b", "pkg:npm/a"], // Different order
|
||||
null,
|
||||
null);
|
||||
|
||||
hash1.Should().Be(hash2); // Should be equal due to sorting
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_DiffersForDifferentInput()
|
||||
{
|
||||
var hash1 = PolicyChangeEvent.ComputeContentHash(
|
||||
PolicyChangeType.AdvisoryUpdated,
|
||||
"tenant",
|
||||
"ADV-001",
|
||||
"CVE-001",
|
||||
null, null, null);
|
||||
|
||||
var hash2 = PolicyChangeEvent.ComputeContentHash(
|
||||
PolicyChangeType.AdvisoryUpdated,
|
||||
"tenant",
|
||||
"ADV-002", // Different advisory
|
||||
"CVE-001",
|
||||
null, null, null);
|
||||
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateManualTrigger_IncludesRequestedBy()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var evt = PolicyChangeEventFactory.CreateManualTrigger(
|
||||
tenantId: "test-tenant",
|
||||
policyIds: ["policy-1"],
|
||||
sbomIds: ["sbom-1"],
|
||||
productKeys: null,
|
||||
requestedBy: "admin@example.com",
|
||||
createdAt: now);
|
||||
|
||||
evt.ChangeType.Should().Be(PolicyChangeType.ManualTrigger);
|
||||
evt.Metadata.Should().ContainKey("requestedBy");
|
||||
evt.Metadata["requestedBy"].Should().Be("admin@example.com");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IncrementalPolicyOrchestrator Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_ProcessesEvents()
|
||||
{
|
||||
var eventSource = new InMemoryPolicyChangeEventSource();
|
||||
var submitter = new TestSubmitter();
|
||||
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
var orchestrator = new IncrementalPolicyOrchestrator(
|
||||
eventSource, submitter, idempotencyStore,
|
||||
timeProvider: timeProvider);
|
||||
|
||||
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
"tenant1", "ADV-001", "CVE-001", ["pkg:npm/test"],
|
||||
"test", timeProvider.GetUtcNow(), timeProvider.GetUtcNow()));
|
||||
|
||||
var result = await orchestrator.ProcessAsync(CancellationToken.None);
|
||||
|
||||
result.TotalEventsRead.Should().Be(1);
|
||||
result.BatchesProcessed.Should().Be(1);
|
||||
submitter.SubmittedBatches.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_DeduplicatesEvents()
|
||||
{
|
||||
var eventSource = new InMemoryPolicyChangeEventSource();
|
||||
var submitter = new TestSubmitter();
|
||||
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
var orchestrator = new IncrementalPolicyOrchestrator(
|
||||
eventSource, submitter, idempotencyStore,
|
||||
timeProvider: timeProvider);
|
||||
|
||||
var evt = PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
"tenant1", "ADV-001", "CVE-001", ["pkg:npm/test"],
|
||||
"test", timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
|
||||
|
||||
// Mark as already seen
|
||||
await idempotencyStore.MarkSeenAsync(evt.EventId, timeProvider.GetUtcNow(), CancellationToken.None);
|
||||
|
||||
eventSource.Enqueue(evt);
|
||||
|
||||
var result = await orchestrator.ProcessAsync(CancellationToken.None);
|
||||
|
||||
result.TotalEventsRead.Should().Be(1);
|
||||
result.EventsSkippedDuplicate.Should().Be(1);
|
||||
result.BatchesProcessed.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_SkipsOldEvents()
|
||||
{
|
||||
var options = new IncrementalOrchestratorOptions
|
||||
{
|
||||
MaxEventAge = TimeSpan.FromHours(1)
|
||||
};
|
||||
var eventSource = new InMemoryPolicyChangeEventSource();
|
||||
var submitter = new TestSubmitter();
|
||||
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
var orchestrator = new IncrementalPolicyOrchestrator(
|
||||
eventSource, submitter, idempotencyStore, options,
|
||||
timeProvider: timeProvider);
|
||||
|
||||
// Create an old event
|
||||
var oldTime = timeProvider.GetUtcNow().AddHours(-2);
|
||||
var evt = PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
"tenant1", "ADV-001", "CVE-001", ["pkg:npm/test"],
|
||||
"test", oldTime, oldTime);
|
||||
|
||||
eventSource.Enqueue(evt);
|
||||
|
||||
var result = await orchestrator.ProcessAsync(CancellationToken.None);
|
||||
|
||||
result.TotalEventsRead.Should().Be(1);
|
||||
result.EventsSkippedOld.Should().Be(1);
|
||||
result.BatchesProcessed.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_GroupsByTenant()
|
||||
{
|
||||
var eventSource = new InMemoryPolicyChangeEventSource();
|
||||
var submitter = new TestSubmitter();
|
||||
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
var orchestrator = new IncrementalPolicyOrchestrator(
|
||||
eventSource, submitter, idempotencyStore,
|
||||
timeProvider: timeProvider);
|
||||
|
||||
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
"tenant1", "ADV-001", "CVE-001", [], "test",
|
||||
timeProvider.GetUtcNow(), timeProvider.GetUtcNow()));
|
||||
|
||||
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
"tenant2", "ADV-002", "CVE-002", [], "test",
|
||||
timeProvider.GetUtcNow(), timeProvider.GetUtcNow()));
|
||||
|
||||
var result = await orchestrator.ProcessAsync(CancellationToken.None);
|
||||
|
||||
result.BatchesProcessed.Should().Be(2); // One per tenant
|
||||
submitter.SubmittedBatches.Select(b => b.TenantId).Should()
|
||||
.BeEquivalentTo(["tenant1", "tenant2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessAsync_SortsByPriority()
|
||||
{
|
||||
var eventSource = new InMemoryPolicyChangeEventSource();
|
||||
var submitter = new TestSubmitter();
|
||||
var idempotencyStore = new InMemoryPolicyChangeIdempotencyStore();
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
|
||||
var orchestrator = new IncrementalPolicyOrchestrator(
|
||||
eventSource, submitter, idempotencyStore,
|
||||
timeProvider: timeProvider);
|
||||
|
||||
// Add normal priority first
|
||||
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
"tenant1", "ADV-001", "CVE-001", [], "test",
|
||||
timeProvider.GetUtcNow(), timeProvider.GetUtcNow(),
|
||||
priority: PolicyChangePriority.Normal));
|
||||
|
||||
// Add emergency priority second
|
||||
eventSource.Enqueue(PolicyChangeEventFactory.CreateAdvisoryUpdated(
|
||||
"tenant1", "ADV-002", "CVE-002", [], "test",
|
||||
timeProvider.GetUtcNow(), timeProvider.GetUtcNow(),
|
||||
priority: PolicyChangePriority.Emergency));
|
||||
|
||||
await orchestrator.ProcessAsync(CancellationToken.None);
|
||||
|
||||
// Emergency should be processed first (separate batch due to priority)
|
||||
submitter.SubmittedBatches.Should().HaveCount(2);
|
||||
submitter.SubmittedBatches[0].Priority.Should().Be(PolicyChangePriority.Emergency);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RuleHitSamplingOptions Tests
|
||||
|
||||
[Fact]
|
||||
public void Default_HasReasonableSamplingRates()
|
||||
{
|
||||
var options = RuleHitSamplingOptions.Default;
|
||||
|
||||
options.BaseSamplingRate.Should().BeInRange(0.0, 1.0);
|
||||
options.VexOverrideSamplingRate.Should().Be(1.0); // Always sample VEX
|
||||
options.IncidentModeSamplingRate.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullSampling_SamplesEverything()
|
||||
{
|
||||
var options = RuleHitSamplingOptions.FullSampling;
|
||||
|
||||
options.BaseSamplingRate.Should().Be(1.0);
|
||||
options.VexOverrideSamplingRate.Should().Be(1.0);
|
||||
options.HighSeveritySamplingRate.Should().Be(1.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class TestSubmitter : IPolicyReEvaluationSubmitter
|
||||
{
|
||||
public List<PolicyChangeBatch> SubmittedBatches { get; } = [];
|
||||
|
||||
public Task<PolicyReEvaluationResult> SubmitAsync(
|
||||
PolicyChangeBatch batch,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SubmittedBatches.Add(batch);
|
||||
return Task.FromResult(new PolicyReEvaluationResult
|
||||
{
|
||||
Succeeded = true,
|
||||
JobIds = [$"job-{batch.BatchId}"],
|
||||
ProcessingTimeMs = 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.Materialization;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Materialization;
|
||||
|
||||
public sealed class MaterializationTests
|
||||
{
|
||||
#region EffectiveFinding.CreateId Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateId_IsDeterministic()
|
||||
{
|
||||
var id1 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
|
||||
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateId_NormalizesTenant()
|
||||
{
|
||||
var id1 = EffectiveFinding.CreateId("TENANT1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
|
||||
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateId_NormalizesPurl()
|
||||
{
|
||||
var id1 = EffectiveFinding.CreateId("tenant1", "policy-1", "PKG:NPM/LODASH", "CVE-2021-12345");
|
||||
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateId_DiffersForDifferentInput()
|
||||
{
|
||||
var id1 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-12345");
|
||||
var id2 = EffectiveFinding.CreateId("tenant1", "policy-1", "pkg:npm/lodash", "CVE-2021-99999");
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateId_HandlesNullValues()
|
||||
{
|
||||
var id = EffectiveFinding.CreateId(null!, "policy", "purl", "advisory");
|
||||
|
||||
id.Should().StartWith("sha256:");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EffectiveFinding.ComputeContentHash Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_IsDeterministic()
|
||||
{
|
||||
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", "severity-rule", "not_affected", null);
|
||||
var hash2 = EffectiveFinding.ComputeContentHash("affected", "High", "severity-rule", "not_affected", null);
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_DiffersForDifferentStatus()
|
||||
{
|
||||
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, null);
|
||||
var hash2 = EffectiveFinding.ComputeContentHash("suppressed", "High", null, null, null);
|
||||
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_DiffersForDifferentSeverity()
|
||||
{
|
||||
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, null);
|
||||
var hash2 = EffectiveFinding.ComputeContentHash("affected", "Critical", null, null, null);
|
||||
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_IncludesAnnotations()
|
||||
{
|
||||
var annotations = new Dictionary<string, string> { ["key"] = "value" };
|
||||
var hash1 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, annotations);
|
||||
var hash2 = EffectiveFinding.ComputeContentHash("affected", "High", null, null, null);
|
||||
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_SortsAnnotationsDeterministically()
|
||||
{
|
||||
var annotations1 = new Dictionary<string, string> { ["a"] = "1", ["b"] = "2" };
|
||||
var annotations2 = new Dictionary<string, string> { ["b"] = "2", ["a"] = "1" };
|
||||
|
||||
var hash1 = EffectiveFinding.ComputeContentHash("affected", null, null, null, annotations1);
|
||||
var hash2 = EffectiveFinding.ComputeContentHash("affected", null, null, null, annotations2);
|
||||
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EffectiveFindingHistoryEntry Tests
|
||||
|
||||
[Fact]
|
||||
public void HistoryEntry_CreateId_IsDeterministic()
|
||||
{
|
||||
var id1 = EffectiveFindingHistoryEntry.CreateId("finding-1", 5);
|
||||
var id2 = EffectiveFindingHistoryEntry.CreateId("finding-1", 5);
|
||||
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().Be("finding-1:v5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryEntry_CreateId_DiffersForDifferentVersion()
|
||||
{
|
||||
var id1 = EffectiveFindingHistoryEntry.CreateId("finding-1", 1);
|
||||
var id2 = EffectiveFindingHistoryEntry.CreateId("finding-1", 2);
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MaterializeFindingInput Tests
|
||||
|
||||
[Fact]
|
||||
public void MaterializeFindingInput_CanBeCreated()
|
||||
{
|
||||
var input = new MaterializeFindingInput
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
PolicyId = "policy-1",
|
||||
PolicyVersion = 1,
|
||||
ComponentPurl = "pkg:npm/lodash@4.17.21",
|
||||
ComponentName = "lodash",
|
||||
ComponentVersion = "4.17.21",
|
||||
AdvisoryId = "CVE-2021-12345",
|
||||
AdvisorySource = "nvd",
|
||||
Status = "affected",
|
||||
Severity = "High",
|
||||
RuleName = "severity-rule",
|
||||
VexStatus = "not_affected",
|
||||
VexJustification = "vulnerable_code_not_in_execute_path",
|
||||
Annotations = ImmutableDictionary<string, string>.Empty.Add("key", "value"),
|
||||
PolicyRunId = "run-123",
|
||||
TraceId = "trace-abc",
|
||||
SpanId = "span-def"
|
||||
};
|
||||
|
||||
input.TenantId.Should().Be("tenant-1");
|
||||
input.PolicyId.Should().Be("policy-1");
|
||||
input.PolicyVersion.Should().Be(1);
|
||||
input.ComponentPurl.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
input.Status.Should().Be("affected");
|
||||
input.VexStatus.Should().Be("not_affected");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MaterializeFindingResult Tests
|
||||
|
||||
[Fact]
|
||||
public void MaterializeFindingResult_TracksCreation()
|
||||
{
|
||||
var result = new MaterializeFindingResult
|
||||
{
|
||||
FindingId = "sha256:abc123",
|
||||
WasCreated = true,
|
||||
WasUpdated = false,
|
||||
HistoryVersion = 1,
|
||||
ChangeType = EffectiveFindingChangeType.Created
|
||||
};
|
||||
|
||||
result.WasCreated.Should().BeTrue();
|
||||
result.WasUpdated.Should().BeFalse();
|
||||
result.ChangeType.Should().Be(EffectiveFindingChangeType.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaterializeFindingResult_TracksUpdate()
|
||||
{
|
||||
var result = new MaterializeFindingResult
|
||||
{
|
||||
FindingId = "sha256:abc123",
|
||||
WasCreated = false,
|
||||
WasUpdated = true,
|
||||
HistoryVersion = 2,
|
||||
ChangeType = EffectiveFindingChangeType.StatusChanged
|
||||
};
|
||||
|
||||
result.WasCreated.Should().BeFalse();
|
||||
result.WasUpdated.Should().BeTrue();
|
||||
result.ChangeType.Should().Be(EffectiveFindingChangeType.StatusChanged);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MaterializeBatchResult Tests
|
||||
|
||||
[Fact]
|
||||
public void MaterializeBatchResult_AggregatesCorrectly()
|
||||
{
|
||||
var results = ImmutableArray.Create(
|
||||
new MaterializeFindingResult
|
||||
{
|
||||
FindingId = "id1",
|
||||
WasCreated = true,
|
||||
WasUpdated = false,
|
||||
HistoryVersion = 1,
|
||||
ChangeType = EffectiveFindingChangeType.Created
|
||||
},
|
||||
new MaterializeFindingResult
|
||||
{
|
||||
FindingId = "id2",
|
||||
WasCreated = false,
|
||||
WasUpdated = true,
|
||||
HistoryVersion = 2,
|
||||
ChangeType = EffectiveFindingChangeType.StatusChanged
|
||||
}
|
||||
);
|
||||
|
||||
var batchResult = new MaterializeBatchResult
|
||||
{
|
||||
TotalInputs = 3,
|
||||
Created = 1,
|
||||
Updated = 1,
|
||||
Unchanged = 1,
|
||||
Errors = 0,
|
||||
ProcessingTimeMs = 100,
|
||||
Results = results
|
||||
};
|
||||
|
||||
batchResult.TotalInputs.Should().Be(3);
|
||||
batchResult.Created.Should().Be(1);
|
||||
batchResult.Updated.Should().Be(1);
|
||||
batchResult.Unchanged.Should().Be(1);
|
||||
batchResult.Results.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EffectiveFindingChangeType Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(EffectiveFindingChangeType.Created, "Created")]
|
||||
[InlineData(EffectiveFindingChangeType.StatusChanged, "StatusChanged")]
|
||||
[InlineData(EffectiveFindingChangeType.SeverityChanged, "SeverityChanged")]
|
||||
[InlineData(EffectiveFindingChangeType.VexApplied, "VexApplied")]
|
||||
[InlineData(EffectiveFindingChangeType.AnnotationsChanged, "AnnotationsChanged")]
|
||||
[InlineData(EffectiveFindingChangeType.PolicyVersionChanged, "PolicyVersionChanged")]
|
||||
public void EffectiveFindingChangeType_HasExpectedValues(EffectiveFindingChangeType changeType, string expectedName)
|
||||
{
|
||||
changeType.ToString().Should().Be(expectedName);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -128,7 +128,8 @@ public sealed class PolicyBundleServiceTests
|
||||
var compiler = new PolicyCompiler();
|
||||
var complexity = new PolicyComplexityAnalyzer();
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions());
|
||||
var compilationService = new PolicyCompilationService(compiler, complexity, new StaticOptionsMonitor(options.Value), TimeProvider.System);
|
||||
var metadataExtractor = new PolicyMetadataExtractor();
|
||||
var compilationService = new PolicyCompilationService(compiler, complexity, metadataExtractor, new StaticOptionsMonitor(options.Value), TimeProvider.System);
|
||||
var repo = new InMemoryPolicyPackRepository();
|
||||
return new ServiceHarness(
|
||||
new PolicyBundleService(compilationService, repo, TimeProvider.System),
|
||||
|
||||
@@ -84,7 +84,8 @@ public sealed class PolicyCompilationServiceTests
|
||||
options.Compilation.MaxDurationMilliseconds = maxDurationMilliseconds;
|
||||
var optionsMonitor = new StaticOptionsMonitor<PolicyEngineOptions>(options);
|
||||
var timeProvider = new FakeTimeProvider(simulatedDurationMilliseconds);
|
||||
return new PolicyCompilationService(compiler, analyzer, optionsMonitor, timeProvider);
|
||||
var metadataExtractor = new PolicyMetadataExtractor();
|
||||
return new PolicyCompilationService(compiler, analyzer, metadataExtractor, optionsMonitor, timeProvider);
|
||||
}
|
||||
|
||||
private sealed class StaticOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
|
||||
@@ -157,8 +157,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
var responses = await harness.Service.EvaluateBatchAsync(requests, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, responses.Count);
|
||||
Assert.True(responses.Any(r => r.Cached));
|
||||
Assert.True(responses.Any(r => !r.Cached));
|
||||
Assert.Contains(responses, r => r.Cached);
|
||||
Assert.Contains(responses, r => !r.Cached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -231,7 +231,8 @@ public sealed class PolicyRuntimeEvaluationServiceTests
|
||||
var analyzer = new PolicyComplexityAnalyzer();
|
||||
var options = new PolicyEngineOptions();
|
||||
var optionsMonitor = new StaticOptionsMonitor(options);
|
||||
return new PolicyCompilationService(compiler, analyzer, optionsMonitor, TimeProvider.System);
|
||||
var metadataExtractor = new PolicyMetadataExtractor();
|
||||
return new PolicyCompilationService(compiler, analyzer, metadataExtractor, optionsMonitor, TimeProvider.System);
|
||||
}
|
||||
|
||||
private sealed record TestHarness(
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.SelectionJoin;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.SelectionJoin;
|
||||
|
||||
public sealed class SelectionJoinTests
|
||||
{
|
||||
#region PurlEquivalence Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/lodash@4.17.21", "pkg:npm/lodash")]
|
||||
[InlineData("pkg:maven/org.apache.commons/commons-lang3@3.12.0", "pkg:maven/org.apache.commons/commons-lang3")]
|
||||
[InlineData("pkg:pypi/requests@2.28.0", "pkg:pypi/requests")]
|
||||
[InlineData("pkg:gem/rails@7.0.0", "pkg:gem/rails")]
|
||||
[InlineData("pkg:nuget/Newtonsoft.Json@13.0.1", "pkg:nuget/Newtonsoft.Json")]
|
||||
public void ExtractPackageKey_RemovesVersion(string purl, string expectedKey)
|
||||
{
|
||||
var key = PurlEquivalence.ExtractPackageKey(purl);
|
||||
|
||||
key.Should().Be(expectedKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPackageKey_HandlesNoVersion()
|
||||
{
|
||||
var purl = "pkg:npm/lodash";
|
||||
|
||||
var key = PurlEquivalence.ExtractPackageKey(purl);
|
||||
|
||||
key.Should().Be("pkg:npm/lodash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractPackageKey_HandlesScopedPackages()
|
||||
{
|
||||
var purl = "pkg:npm/@scope/package@1.0.0";
|
||||
|
||||
var key = PurlEquivalence.ExtractPackageKey(purl);
|
||||
|
||||
key.Should().Be("pkg:npm/@scope/package");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("pkg:npm/lodash@4.17.21", "npm")]
|
||||
[InlineData("pkg:maven/org.apache/commons@1.0", "maven")]
|
||||
[InlineData("pkg:pypi/requests@2.28", "pypi")]
|
||||
public void ExtractEcosystem_ReturnsCorrectEcosystem(string purl, string expected)
|
||||
{
|
||||
var ecosystem = PurlEquivalence.ExtractEcosystem(purl);
|
||||
|
||||
ecosystem.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMatchConfidence_ExactMatch_Returns1()
|
||||
{
|
||||
var confidence = PurlEquivalence.ComputeMatchConfidence(
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"pkg:npm/lodash@4.17.21");
|
||||
|
||||
confidence.Should().Be(1.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeMatchConfidence_PackageKeyMatch_Returns08()
|
||||
{
|
||||
var confidence = PurlEquivalence.ComputeMatchConfidence(
|
||||
"pkg:npm/lodash@4.17.21",
|
||||
"pkg:npm/lodash@4.17.20");
|
||||
|
||||
confidence.Should().Be(0.8);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PurlEquivalenceTable Tests
|
||||
|
||||
[Fact]
|
||||
public void FromGroups_CreatesEquivalentMappings()
|
||||
{
|
||||
var groups = new[]
|
||||
{
|
||||
new[] { "pkg:npm/lodash", "pkg:npm/lodash-es" }
|
||||
};
|
||||
|
||||
var table = PurlEquivalenceTable.FromGroups(groups);
|
||||
|
||||
table.AreEquivalent("pkg:npm/lodash", "pkg:npm/lodash-es").Should().BeTrue();
|
||||
table.GroupCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCanonical_ReturnsFirstLexicographically()
|
||||
{
|
||||
var groups = new[]
|
||||
{
|
||||
new[] { "pkg:npm/b-package", "pkg:npm/a-package" }
|
||||
};
|
||||
|
||||
var table = PurlEquivalenceTable.FromGroups(groups);
|
||||
|
||||
// "a-package" is lexicographically first
|
||||
table.GetCanonical("pkg:npm/b-package").Should().Be("pkg:npm/a-package");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEquivalents_ReturnsAllEquivalentPurls()
|
||||
{
|
||||
var groups = new[]
|
||||
{
|
||||
new[] { "pkg:npm/a", "pkg:npm/b", "pkg:npm/c" }
|
||||
};
|
||||
|
||||
var table = PurlEquivalenceTable.FromGroups(groups);
|
||||
var equivalents = table.GetEquivalents("pkg:npm/b");
|
||||
|
||||
equivalents.Should().HaveCount(3);
|
||||
equivalents.Should().Contain("pkg:npm/a");
|
||||
equivalents.Should().Contain("pkg:npm/b");
|
||||
equivalents.Should().Contain("pkg:npm/c");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_HasNoMappings()
|
||||
{
|
||||
var table = PurlEquivalenceTable.Empty;
|
||||
|
||||
table.GroupCount.Should().Be(0);
|
||||
table.TotalEntries.Should().Be(0);
|
||||
table.AreEquivalent("pkg:npm/a", "pkg:npm/b").Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SelectionJoinService Tests
|
||||
|
||||
[Fact]
|
||||
public void ResolveTuples_MatchesByExactPurl()
|
||||
{
|
||||
var service = new SelectionJoinService();
|
||||
var input = new SelectionJoinBatchInput(
|
||||
TenantId: "test-tenant",
|
||||
BatchId: "batch-1",
|
||||
Components: [
|
||||
new SbomComponentInput(
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
Name: "lodash",
|
||||
Version: "4.17.21",
|
||||
Ecosystem: "npm",
|
||||
Metadata: ImmutableDictionary<string, string>.Empty)
|
||||
],
|
||||
Advisories: [
|
||||
new AdvisoryLinksetInput(
|
||||
AdvisoryId: "GHSA-test-001",
|
||||
Source: "github",
|
||||
Purls: ["pkg:npm/lodash@4.17.21"],
|
||||
Cpes: ImmutableArray<string>.Empty,
|
||||
Aliases: ["CVE-2021-12345"],
|
||||
Confidence: 1.0)
|
||||
],
|
||||
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
|
||||
EquivalenceTable: null,
|
||||
Options: new SelectionJoinOptions());
|
||||
|
||||
var result = service.ResolveTuples(input);
|
||||
|
||||
result.Tuples.Should().ContainSingle();
|
||||
result.Tuples[0].MatchType.Should().Be(SelectionMatchType.ExactPurl);
|
||||
result.Tuples[0].Component.Purl.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
result.Statistics.ExactPurlMatches.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTuples_MatchesByPackageKey()
|
||||
{
|
||||
var service = new SelectionJoinService();
|
||||
var input = new SelectionJoinBatchInput(
|
||||
TenantId: "test-tenant",
|
||||
BatchId: "batch-1",
|
||||
Components: [
|
||||
new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm",
|
||||
ImmutableDictionary<string, string>.Empty)
|
||||
],
|
||||
Advisories: [
|
||||
new AdvisoryLinksetInput("GHSA-test-001", "github",
|
||||
Purls: ["pkg:npm/lodash@4.17.20"], // Different version
|
||||
Cpes: ImmutableArray<string>.Empty,
|
||||
Aliases: ["CVE-2021-12345"],
|
||||
Confidence: 1.0)
|
||||
],
|
||||
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
|
||||
EquivalenceTable: null,
|
||||
Options: new SelectionJoinOptions());
|
||||
|
||||
var result = service.ResolveTuples(input);
|
||||
|
||||
result.Tuples.Should().ContainSingle();
|
||||
result.Tuples[0].MatchType.Should().Be(SelectionMatchType.PackageKeyMatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTuples_AppliesVexOverlay()
|
||||
{
|
||||
var service = new SelectionJoinService();
|
||||
var input = new SelectionJoinBatchInput(
|
||||
TenantId: "test-tenant",
|
||||
BatchId: "batch-1",
|
||||
Components: [
|
||||
new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm",
|
||||
ImmutableDictionary<string, string>.Empty)
|
||||
],
|
||||
Advisories: [
|
||||
new AdvisoryLinksetInput("GHSA-test-001", "github",
|
||||
Purls: ["pkg:npm/lodash@4.17.21"],
|
||||
Cpes: ImmutableArray<string>.Empty,
|
||||
Aliases: ["CVE-2021-12345"],
|
||||
Confidence: 1.0)
|
||||
],
|
||||
VexLinksets: [
|
||||
new VexLinksetInput("vex-1", "CVE-2021-12345", "pkg:npm/lodash@4.17.21",
|
||||
"not_affected", "vulnerable_code_not_in_execute_path", VexConfidenceLevel.High)
|
||||
],
|
||||
EquivalenceTable: null,
|
||||
Options: new SelectionJoinOptions());
|
||||
|
||||
var result = service.ResolveTuples(input);
|
||||
|
||||
result.Tuples.Should().ContainSingle();
|
||||
result.Tuples[0].Vex.Should().NotBeNull();
|
||||
result.Tuples[0].Vex!.Status.Should().Be("not_affected");
|
||||
result.Statistics.VexOverlays.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTuples_ProducesDeterministicOrdering()
|
||||
{
|
||||
var service = new SelectionJoinService();
|
||||
var input = new SelectionJoinBatchInput(
|
||||
TenantId: "test-tenant",
|
||||
BatchId: "batch-1",
|
||||
Components: [
|
||||
new SbomComponentInput("pkg:npm/z-package@1.0.0", "z", "1.0.0", "npm",
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomComponentInput("pkg:npm/a-package@1.0.0", "a", "1.0.0", "npm",
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomComponentInput("pkg:npm/m-package@1.0.0", "m", "1.0.0", "npm",
|
||||
ImmutableDictionary<string, string>.Empty)
|
||||
],
|
||||
Advisories: [
|
||||
new AdvisoryLinksetInput("ADV-001", "test",
|
||||
Purls: ["pkg:npm/z-package", "pkg:npm/a-package", "pkg:npm/m-package"],
|
||||
Cpes: ImmutableArray<string>.Empty,
|
||||
Aliases: ["CVE-2021-001"],
|
||||
Confidence: 1.0)
|
||||
],
|
||||
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
|
||||
EquivalenceTable: null,
|
||||
Options: new SelectionJoinOptions());
|
||||
|
||||
var result = service.ResolveTuples(input);
|
||||
|
||||
// Should be sorted by component PURL
|
||||
result.Tuples.Should().HaveCount(3);
|
||||
result.Tuples[0].Component.Purl.Should().Be("pkg:npm/a-package@1.0.0");
|
||||
result.Tuples[1].Component.Purl.Should().Be("pkg:npm/m-package@1.0.0");
|
||||
result.Tuples[2].Component.Purl.Should().Be("pkg:npm/z-package@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTuples_HandlesMultipleAdvisories()
|
||||
{
|
||||
var service = new SelectionJoinService();
|
||||
var input = new SelectionJoinBatchInput(
|
||||
TenantId: "test-tenant",
|
||||
BatchId: "batch-1",
|
||||
Components: [
|
||||
new SbomComponentInput("pkg:npm/lodash@4.17.21", "lodash", "4.17.21", "npm",
|
||||
ImmutableDictionary<string, string>.Empty)
|
||||
],
|
||||
Advisories: [
|
||||
new AdvisoryLinksetInput("ADV-001", "test",
|
||||
Purls: ["pkg:npm/lodash@4.17.21"],
|
||||
Cpes: ImmutableArray<string>.Empty,
|
||||
Aliases: ["CVE-2021-001"],
|
||||
Confidence: 1.0),
|
||||
new AdvisoryLinksetInput("ADV-002", "test",
|
||||
Purls: ["pkg:npm/lodash@4.17.21"],
|
||||
Cpes: ImmutableArray<string>.Empty,
|
||||
Aliases: ["CVE-2021-002"],
|
||||
Confidence: 1.0)
|
||||
],
|
||||
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
|
||||
EquivalenceTable: null,
|
||||
Options: new SelectionJoinOptions());
|
||||
|
||||
var result = service.ResolveTuples(input);
|
||||
|
||||
result.Tuples.Should().HaveCount(2);
|
||||
result.Tuples.Should().Contain(t => t.Advisory.AdvisoryId == "ADV-001");
|
||||
result.Tuples.Should().Contain(t => t.Advisory.AdvisoryId == "ADV-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTuples_ReturnsStatistics()
|
||||
{
|
||||
var service = new SelectionJoinService();
|
||||
var input = new SelectionJoinBatchInput(
|
||||
TenantId: "test-tenant",
|
||||
BatchId: "batch-1",
|
||||
Components: [
|
||||
new SbomComponentInput("pkg:npm/a@1.0.0", "a", "1.0.0", "npm",
|
||||
ImmutableDictionary<string, string>.Empty),
|
||||
new SbomComponentInput("pkg:npm/b@1.0.0", "b", "1.0.0", "npm",
|
||||
ImmutableDictionary<string, string>.Empty)
|
||||
],
|
||||
Advisories: [
|
||||
new AdvisoryLinksetInput("ADV-001", "test",
|
||||
Purls: ["pkg:npm/a"],
|
||||
Cpes: ImmutableArray<string>.Empty,
|
||||
Aliases: ["CVE-001"],
|
||||
Confidence: 1.0)
|
||||
],
|
||||
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
|
||||
EquivalenceTable: null,
|
||||
Options: new SelectionJoinOptions());
|
||||
|
||||
var result = service.ResolveTuples(input);
|
||||
|
||||
result.Statistics.TotalComponents.Should().Be(2);
|
||||
result.Statistics.TotalAdvisories.Should().Be(1);
|
||||
result.Statistics.MatchedTuples.Should().Be(1);
|
||||
result.UnmatchedComponents.Should().ContainSingle(c => c.Purl == "pkg:npm/b@1.0.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveTuples_HandlesEmptyInput()
|
||||
{
|
||||
var service = new SelectionJoinService();
|
||||
var input = new SelectionJoinBatchInput(
|
||||
TenantId: "test-tenant",
|
||||
BatchId: "batch-1",
|
||||
Components: ImmutableArray<SbomComponentInput>.Empty,
|
||||
Advisories: ImmutableArray<AdvisoryLinksetInput>.Empty,
|
||||
VexLinksets: ImmutableArray<VexLinksetInput>.Empty,
|
||||
EquivalenceTable: null,
|
||||
Options: new SelectionJoinOptions());
|
||||
|
||||
var result = service.ResolveTuples(input);
|
||||
|
||||
result.Tuples.Should().BeEmpty();
|
||||
result.Statistics.TotalComponents.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SelectionJoinTuple Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateTupleId_IsDeterministic()
|
||||
{
|
||||
var id1 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
|
||||
var id2 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("tuple:sha256:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTupleId_NormalizesInput()
|
||||
{
|
||||
var id1 = SelectionJoinTuple.CreateTupleId("TENANT1", "PKG:NPM/LODASH@4.17.21", "CVE-2021-12345");
|
||||
var id2 = SelectionJoinTuple.CreateTupleId("tenant1", "pkg:npm/lodash@4.17.21", "CVE-2021-12345");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Policy.Engine.Simulation;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Simulation;
|
||||
|
||||
public sealed class SimulationAnalyticsServiceTests
|
||||
{
|
||||
private readonly SimulationAnalyticsService _service = new();
|
||||
|
||||
[Fact]
|
||||
public void ComputeRuleFiringCounts_EmptyTraces_ReturnsEmptyCounts()
|
||||
{
|
||||
// Arrange
|
||||
var traces = Array.Empty<RuleHitTrace>();
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeRuleFiringCounts(traces, 10);
|
||||
|
||||
// Assert
|
||||
result.TotalEvaluations.Should().Be(10);
|
||||
result.TotalRulesFired.Should().Be(0);
|
||||
result.RulesByName.Should().BeEmpty();
|
||||
result.RulesByPriority.Should().BeEmpty();
|
||||
result.RulesByOutcome.Should().BeEmpty();
|
||||
result.TopRules.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRuleFiringCounts_WithFiredRules_CountsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true),
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true),
|
||||
CreateTrace("rule_b", 2, "allow", expressionResult: true),
|
||||
CreateTrace("rule_c", 3, "warn", expressionResult: false), // Not fired
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeRuleFiringCounts(traces, 10);
|
||||
|
||||
// Assert
|
||||
result.TotalRulesFired.Should().Be(3);
|
||||
result.RulesByName.Should().HaveCount(2);
|
||||
result.RulesByName["rule_a"].FireCount.Should().Be(2);
|
||||
result.RulesByName["rule_b"].FireCount.Should().Be(1);
|
||||
result.RulesByPriority[1].Should().Be(2);
|
||||
result.RulesByPriority[2].Should().Be(1);
|
||||
result.RulesByOutcome["block"].Should().Be(2);
|
||||
result.RulesByOutcome["allow"].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRuleFiringCounts_TopRules_OrderedByFireCount()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new List<RuleHitTrace>();
|
||||
for (var i = 0; i < 15; i++)
|
||||
{
|
||||
traces.Add(CreateTrace("frequently_fired", 1, "block", expressionResult: true));
|
||||
}
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
traces.Add(CreateTrace("sometimes_fired", 2, "warn", expressionResult: true));
|
||||
}
|
||||
traces.Add(CreateTrace("rarely_fired", 3, "allow", expressionResult: true));
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeRuleFiringCounts(traces, 100);
|
||||
|
||||
// Assert
|
||||
result.TopRules.Should().HaveCount(3);
|
||||
result.TopRules[0].RuleName.Should().Be("frequently_fired");
|
||||
result.TopRules[0].FireCount.Should().Be(15);
|
||||
result.TopRules[1].RuleName.Should().Be("sometimes_fired");
|
||||
result.TopRules[1].FireCount.Should().Be(5);
|
||||
result.TopRules[2].RuleName.Should().Be("rarely_fired");
|
||||
result.TopRules[2].FireCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeRuleFiringCounts_VexOverrides_CountedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "allow", expressionResult: true, isVexOverride: true, vexVendor: "vendor_a", vexStatus: "not_affected"),
|
||||
CreateTrace("rule_a", 1, "allow", expressionResult: true, isVexOverride: true, vexVendor: "vendor_a", vexStatus: "fixed"),
|
||||
CreateTrace("rule_b", 2, "allow", expressionResult: true, isVexOverride: true, vexVendor: "vendor_b", vexStatus: "not_affected"),
|
||||
CreateTrace("rule_c", 3, "block", expressionResult: true),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeRuleFiringCounts(traces, 10);
|
||||
|
||||
// Assert
|
||||
result.VexOverrides.TotalOverrides.Should().Be(3);
|
||||
result.VexOverrides.ByVendor["vendor_a"].Should().Be(2);
|
||||
result.VexOverrides.ByVendor["vendor_b"].Should().Be(1);
|
||||
result.VexOverrides.ByStatus["not_affected"].Should().Be(2);
|
||||
result.VexOverrides.ByStatus["fixed"].Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeatmap_RuleSeverityMatrix_BuildsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, severity: "critical"),
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, severity: "critical"),
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, severity: "high"),
|
||||
CreateTrace("rule_b", 2, "warn", expressionResult: true, severity: "medium"),
|
||||
};
|
||||
var findings = CreateFindings(4);
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeHeatmap(traces, findings, SimulationAnalyticsOptions.Default);
|
||||
|
||||
// Assert
|
||||
result.RuleSeverityMatrix.Should().NotBeEmpty();
|
||||
var criticalCell = result.RuleSeverityMatrix.FirstOrDefault(c => c.X == "rule_a" && c.Y == "critical");
|
||||
criticalCell.Should().NotBeNull();
|
||||
criticalCell!.Value.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeatmap_FindingRuleCoverage_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0"),
|
||||
CreateTrace("rule_b", 2, "allow", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0"),
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: false, componentPurl: "pkg:npm/express@5.0.0"),
|
||||
};
|
||||
var findings = new[]
|
||||
{
|
||||
new SimulationFinding("f1", "pkg:npm/lodash@4.0.0", "GHSA-123", new Dictionary<string, object?>()),
|
||||
new SimulationFinding("f2", "pkg:npm/express@5.0.0", "GHSA-456", new Dictionary<string, object?>()),
|
||||
new SimulationFinding("f3", "pkg:npm/axios@1.0.0", "GHSA-789", new Dictionary<string, object?>()),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeHeatmap(traces, findings, SimulationAnalyticsOptions.Default);
|
||||
|
||||
// Assert
|
||||
result.FindingRuleCoverage.TotalFindings.Should().Be(3);
|
||||
result.FindingRuleCoverage.FindingsMatched.Should().Be(1);
|
||||
result.FindingRuleCoverage.CoveragePercentage.Should().BeApproximately(33.33, 0.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSampledTraces_DeterministicOrdering_OrdersByFindingId()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/z-package@1.0.0"),
|
||||
CreateTrace("rule_a", 1, "allow", expressionResult: true, componentPurl: "pkg:npm/a-package@1.0.0"),
|
||||
CreateTrace("rule_b", 2, "warn", expressionResult: true, componentPurl: "pkg:npm/m-package@1.0.0"),
|
||||
};
|
||||
var findings = new[]
|
||||
{
|
||||
new SimulationFinding("finding-z", "pkg:npm/z-package@1.0.0", null, new Dictionary<string, object?>()),
|
||||
new SimulationFinding("finding-a", "pkg:npm/a-package@1.0.0", null, new Dictionary<string, object?>()),
|
||||
new SimulationFinding("finding-m", "pkg:npm/m-package@1.0.0", null, new Dictionary<string, object?>()),
|
||||
};
|
||||
var options = new SimulationAnalyticsOptions { TraceSampleRate = 1.0, MaxSampledTraces = 100 };
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeSampledTraces(traces, findings, options);
|
||||
|
||||
// Assert
|
||||
result.Ordering.PrimaryKey.Should().Be("finding_id");
|
||||
result.Ordering.Direction.Should().Be("ascending");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSampledTraces_DeterminismHash_ConsistentForSameInput()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0"),
|
||||
};
|
||||
var findings = new[]
|
||||
{
|
||||
new SimulationFinding("f1", "pkg:npm/lodash@4.0.0", "GHSA-123", new Dictionary<string, object?>()),
|
||||
};
|
||||
var options = new SimulationAnalyticsOptions { TraceSampleRate = 1.0 };
|
||||
|
||||
// Act
|
||||
var result1 = _service.ComputeSampledTraces(traces, findings, options);
|
||||
var result2 = _service.ComputeSampledTraces(traces, findings, options);
|
||||
|
||||
// Assert
|
||||
result1.DeterminismHash.Should().Be(result2.DeterminismHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeSampledTraces_HighSeverity_AlwaysSampled()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/critical@1.0.0", severity: "critical"),
|
||||
};
|
||||
var findings = new[]
|
||||
{
|
||||
new SimulationFinding("f1", "pkg:npm/critical@1.0.0", null, new Dictionary<string, object?>()),
|
||||
};
|
||||
var options = new SimulationAnalyticsOptions { TraceSampleRate = 0.0 }; // Zero base rate
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeSampledTraces(traces, findings, options);
|
||||
|
||||
// Assert
|
||||
result.SampledCount.Should().BeGreaterThan(0);
|
||||
result.Traces.Should().Contain(t => t.SampleReason == "high_severity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDeltaSummary_OutcomeChanges_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var baseResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "block", "critical", new[] { "rule_a" }),
|
||||
new SimulationFindingResult("f2", "pkg:b", null, "warn", "medium", new[] { "rule_b" }),
|
||||
new SimulationFindingResult("f3", "pkg:c", null, "allow", "low", new[] { "rule_c" }),
|
||||
};
|
||||
var candidateResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "warn", "high", new[] { "rule_a" }), // Improved
|
||||
new SimulationFindingResult("f2", "pkg:b", null, "block", "critical", new[] { "rule_b" }), // Regressed
|
||||
new SimulationFindingResult("f3", "pkg:c", null, "allow", "low", new[] { "rule_c" }), // Unchanged
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
|
||||
|
||||
// Assert
|
||||
result.OutcomeChanges.Unchanged.Should().Be(1);
|
||||
result.OutcomeChanges.Improved.Should().Be(1);
|
||||
result.OutcomeChanges.Regressed.Should().Be(1);
|
||||
result.OutcomeChanges.Transitions.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDeltaSummary_SeverityChanges_TracksEscalationAndDeescalation()
|
||||
{
|
||||
// Arrange
|
||||
var baseResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "block", "medium", Array.Empty<string>()),
|
||||
new SimulationFindingResult("f2", "pkg:b", null, "block", "high", Array.Empty<string>()),
|
||||
new SimulationFindingResult("f3", "pkg:c", null, "warn", "low", Array.Empty<string>()),
|
||||
};
|
||||
var candidateResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "block", "critical", Array.Empty<string>()), // Escalated
|
||||
new SimulationFindingResult("f2", "pkg:b", null, "block", "medium", Array.Empty<string>()), // Deescalated
|
||||
new SimulationFindingResult("f3", "pkg:c", null, "warn", "low", Array.Empty<string>()), // Unchanged
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
|
||||
|
||||
// Assert
|
||||
result.SeverityChanges.Unchanged.Should().Be(1);
|
||||
result.SeverityChanges.Escalated.Should().Be(1);
|
||||
result.SeverityChanges.Deescalated.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDeltaSummary_RuleChanges_DetectsAddedAndRemovedRules()
|
||||
{
|
||||
// Arrange
|
||||
var baseResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "block", "high", new[] { "rule_old", "rule_common" }),
|
||||
};
|
||||
var candidateResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "block", "high", new[] { "rule_new", "rule_common" }),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
|
||||
|
||||
// Assert
|
||||
result.RuleChanges.RulesAdded.Should().Contain("rule_new");
|
||||
result.RuleChanges.RulesRemoved.Should().Contain("rule_old");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDeltaSummary_HighImpactFindings_IdentifiedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var baseResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:critical", "CVE-2024-001", "allow", "low", Array.Empty<string>()),
|
||||
};
|
||||
var candidateResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:critical", "CVE-2024-001", "block", "critical", Array.Empty<string>()),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
|
||||
|
||||
// Assert
|
||||
result.HighImpactFindings.Should().NotBeEmpty();
|
||||
result.HighImpactFindings[0].FindingId.Should().Be("f1");
|
||||
result.HighImpactFindings[0].ImpactScore.Should().BeGreaterThan(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeDeltaSummary_DeterminismHash_ConsistentForSameInput()
|
||||
{
|
||||
// Arrange
|
||||
var baseResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "block", "high", Array.Empty<string>()),
|
||||
};
|
||||
var candidateResults = new[]
|
||||
{
|
||||
new SimulationFindingResult("f1", "pkg:a", null, "warn", "medium", Array.Empty<string>()),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
|
||||
var result2 = _service.ComputeDeltaSummary("v1", "v2", baseResults, candidateResults);
|
||||
|
||||
// Assert
|
||||
result1.DeterminismHash.Should().Be(result2.DeterminismHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeAnalytics_FullAnalysis_ReturnsAllComponents()
|
||||
{
|
||||
// Arrange
|
||||
var traces = new[]
|
||||
{
|
||||
CreateTrace("rule_a", 1, "block", expressionResult: true, componentPurl: "pkg:npm/lodash@4.0.0", severity: "high"),
|
||||
CreateTrace("rule_b", 2, "allow", expressionResult: true, componentPurl: "pkg:npm/express@5.0.0", severity: "low"),
|
||||
};
|
||||
var findings = new[]
|
||||
{
|
||||
new SimulationFinding("f1", "pkg:npm/lodash@4.0.0", "GHSA-123", new Dictionary<string, object?>()),
|
||||
new SimulationFinding("f2", "pkg:npm/express@5.0.0", "GHSA-456", new Dictionary<string, object?>()),
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.ComputeAnalytics("policy-v1", traces, findings);
|
||||
|
||||
// Assert
|
||||
result.RuleFiringCounts.Should().NotBeNull();
|
||||
result.Heatmap.Should().NotBeNull();
|
||||
result.SampledTraces.Should().NotBeNull();
|
||||
result.DeltaSummary.Should().BeNull(); // No delta for single policy analysis
|
||||
}
|
||||
|
||||
private static RuleHitTrace CreateTrace(
|
||||
string ruleName,
|
||||
int priority,
|
||||
string outcome,
|
||||
bool expressionResult,
|
||||
string? severity = null,
|
||||
bool isVexOverride = false,
|
||||
string? vexVendor = null,
|
||||
string? vexStatus = null,
|
||||
string? componentPurl = null)
|
||||
{
|
||||
return new RuleHitTrace
|
||||
{
|
||||
TraceId = Guid.NewGuid().ToString(),
|
||||
SpanId = Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = "test-tenant",
|
||||
PolicyId = "test-policy",
|
||||
RunId = "test-run",
|
||||
RuleName = ruleName,
|
||||
RulePriority = priority,
|
||||
Outcome = outcome,
|
||||
AssignedSeverity = severity,
|
||||
ComponentPurl = componentPurl,
|
||||
ExpressionResult = expressionResult,
|
||||
EvaluationTimestamp = DateTimeOffset.UtcNow,
|
||||
RecordedAt = DateTimeOffset.UtcNow,
|
||||
EvaluationMicroseconds = 100,
|
||||
IsVexOverride = isVexOverride,
|
||||
VexVendor = vexVendor,
|
||||
VexStatus = vexStatus,
|
||||
IsSampled = true,
|
||||
Attributes = ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static SimulationFinding[] CreateFindings(int count)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(i => new SimulationFinding(
|
||||
$"finding-{i}",
|
||||
$"pkg:npm/package-{i}@1.0.0",
|
||||
$"GHSA-{i:D3}",
|
||||
new Dictionary<string, object?>()))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Telemetry;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Telemetry;
|
||||
|
||||
public sealed class TelemetryTests
|
||||
{
|
||||
#region RuleHitTrace Tests
|
||||
|
||||
[Fact]
|
||||
public void RuleHitTrace_GetOrCreateTraceId_ReturnsValidId()
|
||||
{
|
||||
var traceId = RuleHitTrace.GetOrCreateTraceId();
|
||||
|
||||
traceId.Should().NotBeNullOrEmpty();
|
||||
traceId.Should().HaveLength(32); // 16 bytes = 32 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleHitTrace_GetOrCreateSpanId_ReturnsValidId()
|
||||
{
|
||||
var spanId = RuleHitTrace.GetOrCreateSpanId();
|
||||
|
||||
spanId.Should().NotBeNullOrEmpty();
|
||||
spanId.Should().HaveLength(16); // 8 bytes = 16 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuleHitTrace_GetOrCreateTraceId_GeneratesUniqueIds()
|
||||
{
|
||||
var ids = Enumerable.Range(0, 100)
|
||||
.Select(_ => RuleHitTrace.GetOrCreateTraceId())
|
||||
.ToList();
|
||||
|
||||
ids.Distinct().Should().HaveCount(100);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RuleHitTraceFactory Tests
|
||||
|
||||
[Fact]
|
||||
public void Create_ProducesValidTrace()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var timeProvider = new FakeTimeProvider(timestamp);
|
||||
|
||||
var trace = RuleHitTraceFactory.Create(
|
||||
tenantId: "TENANT-1",
|
||||
policyId: "policy-1",
|
||||
policyVersion: 2,
|
||||
runId: "run-123",
|
||||
ruleName: "block-critical",
|
||||
rulePriority: 10,
|
||||
outcome: "deny",
|
||||
evaluationTimestamp: timestamp,
|
||||
timeProvider: timeProvider,
|
||||
ruleCategory: "severity",
|
||||
assignedSeverity: "Critical",
|
||||
componentPurl: "pkg:npm/lodash@4.17.21",
|
||||
advisoryId: "GHSA-test-001",
|
||||
vulnerabilityId: "CVE-2021-12345");
|
||||
|
||||
trace.TenantId.Should().Be("tenant-1"); // Normalized to lowercase
|
||||
trace.PolicyId.Should().Be("policy-1");
|
||||
trace.PolicyVersion.Should().Be(2);
|
||||
trace.RunId.Should().Be("run-123");
|
||||
trace.RuleName.Should().Be("block-critical");
|
||||
trace.RulePriority.Should().Be(10);
|
||||
trace.Outcome.Should().Be("deny");
|
||||
trace.RuleCategory.Should().Be("severity");
|
||||
trace.AssignedSeverity.Should().Be("Critical");
|
||||
trace.ComponentPurl.Should().Be("pkg:npm/lodash@4.17.21");
|
||||
trace.EvaluationTimestamp.Should().Be(timestamp);
|
||||
trace.RecordedAt.Should().Be(timestamp);
|
||||
trace.TraceId.Should().NotBeNullOrEmpty();
|
||||
trace.SpanId.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_TracksVexOverride()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
var trace = RuleHitTraceFactory.Create(
|
||||
tenantId: "tenant-1",
|
||||
policyId: "policy-1",
|
||||
policyVersion: 1,
|
||||
runId: "run-123",
|
||||
ruleName: "vex-override",
|
||||
rulePriority: 1,
|
||||
outcome: "suppress",
|
||||
evaluationTimestamp: timestamp,
|
||||
vexStatus: "not_affected",
|
||||
vexJustification: "vulnerable_code_not_in_execute_path",
|
||||
vexVendor: "vendor-1",
|
||||
isVexOverride: true);
|
||||
|
||||
trace.VexStatus.Should().Be("not_affected");
|
||||
trace.VexJustification.Should().Be("vulnerable_code_not_in_execute_path");
|
||||
trace.VexVendor.Should().Be("vendor-1");
|
||||
trace.IsVexOverride.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_TracksReachability()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
|
||||
var trace = RuleHitTraceFactory.Create(
|
||||
tenantId: "tenant-1",
|
||||
policyId: "policy-1",
|
||||
policyVersion: 1,
|
||||
runId: "run-123",
|
||||
ruleName: "reachability-rule",
|
||||
rulePriority: 5,
|
||||
outcome: "allow",
|
||||
evaluationTimestamp: timestamp,
|
||||
reachabilityState: "reachable",
|
||||
reachabilityConfidence: 0.95);
|
||||
|
||||
trace.ReachabilityState.Should().Be("reachable");
|
||||
trace.ReachabilityConfidence.Should().Be(0.95);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_IncludesCustomAttributes()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var attributes = ImmutableDictionary<string, string>.Empty
|
||||
.Add("custom_key", "custom_value")
|
||||
.Add("another_key", "another_value");
|
||||
|
||||
var trace = RuleHitTraceFactory.Create(
|
||||
tenantId: "tenant-1",
|
||||
policyId: "policy-1",
|
||||
policyVersion: 1,
|
||||
runId: "run-123",
|
||||
ruleName: "test-rule",
|
||||
rulePriority: 1,
|
||||
outcome: "allow",
|
||||
evaluationTimestamp: timestamp,
|
||||
attributes: attributes);
|
||||
|
||||
trace.Attributes.Should().ContainKey("custom_key");
|
||||
trace.Attributes["custom_key"].Should().Be("custom_value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_ProducesValidJson()
|
||||
{
|
||||
var trace = RuleHitTraceFactory.Create(
|
||||
tenantId: "tenant-1",
|
||||
policyId: "policy-1",
|
||||
policyVersion: 1,
|
||||
runId: "run-123",
|
||||
ruleName: "test-rule",
|
||||
rulePriority: 1,
|
||||
outcome: "allow",
|
||||
evaluationTimestamp: DateTimeOffset.UtcNow);
|
||||
|
||||
var json = RuleHitTraceFactory.ToJson(trace);
|
||||
|
||||
json.Should().Contain("\"tenant_id\":\"tenant-1\"");
|
||||
json.Should().Contain("\"policy_id\":\"policy-1\"");
|
||||
json.Should().Contain("\"rule_name\":\"test-rule\"");
|
||||
json.Should().NotContain("\n"); // Single line
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToNdjson_ProducesMultipleLines()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var traces = new[]
|
||||
{
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-1", 1, "allow", timestamp),
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-2", 2, "deny", timestamp),
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-3", 3, "suppress", timestamp)
|
||||
};
|
||||
|
||||
var ndjson = RuleHitTraceFactory.ToNdjson(traces);
|
||||
var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
lines.Should().HaveCount(3);
|
||||
lines[0].Should().Contain("rule-1");
|
||||
lines[1].Should().Contain("rule-2");
|
||||
lines[2].Should().Contain("rule-3");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RuleHitStatistics Tests
|
||||
|
||||
[Fact]
|
||||
public void CreateStatistics_AggregatesCorrectly()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var traces = new[]
|
||||
{
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-1", 1, "allow", timestamp,
|
||||
ruleCategory: "severity"),
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-2", 2, "deny", timestamp,
|
||||
ruleCategory: "severity"),
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-3", 3, "suppress", timestamp,
|
||||
ruleCategory: "vex", isVexOverride: true, vexVendor: "vendor-1", vexStatus: "not_affected"),
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", "rule-4", 4, "suppress", timestamp,
|
||||
ruleCategory: "vex", isVexOverride: true, vexVendor: "vendor-2", vexStatus: "fixed")
|
||||
};
|
||||
|
||||
var stats = RuleHitTraceFactory.CreateStatistics(
|
||||
runId: "run-1",
|
||||
policyId: "policy-1",
|
||||
traces: traces,
|
||||
totalRulesEvaluated: 10,
|
||||
totalEvaluationMs: 50);
|
||||
|
||||
stats.RunId.Should().Be("run-1");
|
||||
stats.PolicyId.Should().Be("policy-1");
|
||||
stats.TotalRulesEvaluated.Should().Be(10);
|
||||
stats.TotalRulesFired.Should().Be(4);
|
||||
stats.TotalVexOverrides.Should().Be(2);
|
||||
|
||||
stats.RulesFiredByCategory.Should().ContainKey("severity");
|
||||
stats.RulesFiredByCategory["severity"].Should().Be(2);
|
||||
stats.RulesFiredByCategory["vex"].Should().Be(2);
|
||||
|
||||
stats.RulesFiredByOutcome.Should().ContainKey("allow");
|
||||
stats.RulesFiredByOutcome["allow"].Should().Be(1);
|
||||
stats.RulesFiredByOutcome["deny"].Should().Be(1);
|
||||
stats.RulesFiredByOutcome["suppress"].Should().Be(2);
|
||||
|
||||
stats.VexOverridesByVendor.Should().HaveCount(2);
|
||||
stats.VexOverridesByStatus.Should().ContainKey("not_affected");
|
||||
stats.VexOverridesByStatus.Should().ContainKey("fixed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStatistics_ComputesAverageEvaluationTime()
|
||||
{
|
||||
var traces = Array.Empty<RuleHitTrace>();
|
||||
var stats = RuleHitTraceFactory.CreateStatistics(
|
||||
runId: "run-1",
|
||||
policyId: "policy-1",
|
||||
traces: traces,
|
||||
totalRulesEvaluated: 100,
|
||||
totalEvaluationMs: 50);
|
||||
|
||||
stats.TotalEvaluationMs.Should().Be(50);
|
||||
stats.AverageRuleEvaluationMicroseconds.Should().Be(500); // 50ms * 1000 / 100 rules
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStatistics_HandlesZeroRules()
|
||||
{
|
||||
var traces = Array.Empty<RuleHitTrace>();
|
||||
var stats = RuleHitTraceFactory.CreateStatistics(
|
||||
runId: "run-1",
|
||||
policyId: "policy-1",
|
||||
traces: traces,
|
||||
totalRulesEvaluated: 0,
|
||||
totalEvaluationMs: 0);
|
||||
|
||||
stats.TotalRulesEvaluated.Should().Be(0);
|
||||
stats.AverageRuleEvaluationMicroseconds.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateStatistics_GeneratesTopRules()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var traces = Enumerable.Range(0, 20)
|
||||
.SelectMany(i => Enumerable.Range(0, i + 1).Select(_ =>
|
||||
RuleHitTraceFactory.Create("tenant-1", "policy-1", 1, "run-1", $"rule-{i}", i, "allow", timestamp)))
|
||||
.ToArray();
|
||||
|
||||
var stats = RuleHitTraceFactory.CreateStatistics("run-1", "policy-1", traces, 100, 50);
|
||||
|
||||
stats.TopRulesByHitCount.Should().HaveCount(10);
|
||||
stats.TopRulesByHitCount[0].RuleName.Should().Be("rule-19"); // Highest count
|
||||
stats.TopRulesByHitCount[0].HitCount.Should().Be(20);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RuleHitCount Tests
|
||||
|
||||
[Fact]
|
||||
public void RuleHitCount_RecordWorks()
|
||||
{
|
||||
var hitCount = new RuleHitCount("severity-rule", 42, "deny");
|
||||
|
||||
hitCount.RuleName.Should().Be("severity-rule");
|
||||
hitCount.HitCount.Should().Be(42);
|
||||
hitCount.Outcome.Should().Be("deny");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user