sprints work

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,470 @@
// -----------------------------------------------------------------------------
// DslCompletionProviderTests.cs
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
// Task: PINT-8200-019
// Description: Unit tests for DSL autocomplete hints for score fields
// -----------------------------------------------------------------------------
using FluentAssertions;
using Xunit;
namespace StellaOps.PolicyDsl.Tests;
/// <summary>
/// Tests for DslCompletionProvider and DslCompletionCatalog.
/// </summary>
public class DslCompletionProviderTests
{
#region Catalog Tests
[Fact]
public void GetCompletionCatalog_ReturnsNonNullCatalog()
{
// Act
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert
catalog.Should().NotBeNull();
}
[Fact]
public void Catalog_ContainsScoreFields()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert
catalog.ScoreFields.Should().NotBeEmpty();
catalog.ScoreFields.Should().Contain(f => f.Label == "value");
catalog.ScoreFields.Should().Contain(f => f.Label == "bucket");
catalog.ScoreFields.Should().Contain(f => f.Label == "is_act_now");
catalog.ScoreFields.Should().Contain(f => f.Label == "flags");
catalog.ScoreFields.Should().Contain(f => f.Label == "rch");
catalog.ScoreFields.Should().Contain(f => f.Label == "reachability");
}
[Fact]
public void Catalog_ContainsScoreBuckets()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert
catalog.ScoreBuckets.Should().NotBeEmpty();
catalog.ScoreBuckets.Should().HaveCount(4);
catalog.ScoreBuckets.Should().Contain(b => b.Label == "ActNow");
catalog.ScoreBuckets.Should().Contain(b => b.Label == "ScheduleNext");
catalog.ScoreBuckets.Should().Contain(b => b.Label == "Investigate");
catalog.ScoreBuckets.Should().Contain(b => b.Label == "Watchlist");
}
[Fact]
public void Catalog_ContainsScoreFlags()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert
catalog.ScoreFlags.Should().NotBeEmpty();
catalog.ScoreFlags.Should().Contain(f => f.Label == "kev");
catalog.ScoreFlags.Should().Contain(f => f.Label == "live-signal");
catalog.ScoreFlags.Should().Contain(f => f.Label == "vendor-na");
catalog.ScoreFlags.Should().Contain(f => f.Label == "reachable");
catalog.ScoreFlags.Should().Contain(f => f.Label == "unreachable");
}
[Fact]
public void Catalog_ContainsAllDimensionAliases()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert - short aliases
catalog.ScoreFields.Should().Contain(f => f.Label == "rch");
catalog.ScoreFields.Should().Contain(f => f.Label == "rts");
catalog.ScoreFields.Should().Contain(f => f.Label == "bkp");
catalog.ScoreFields.Should().Contain(f => f.Label == "xpl");
catalog.ScoreFields.Should().Contain(f => f.Label == "src");
catalog.ScoreFields.Should().Contain(f => f.Label == "mit");
// Assert - long aliases
catalog.ScoreFields.Should().Contain(f => f.Label == "reachability");
catalog.ScoreFields.Should().Contain(f => f.Label == "runtime");
catalog.ScoreFields.Should().Contain(f => f.Label == "backport");
catalog.ScoreFields.Should().Contain(f => f.Label == "exploit");
catalog.ScoreFields.Should().Contain(f => f.Label == "source_trust");
catalog.ScoreFields.Should().Contain(f => f.Label == "mitigation");
}
[Fact]
public void Catalog_ContainsVexStatuses()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert
catalog.VexStatuses.Should().NotBeEmpty();
catalog.VexStatuses.Should().Contain(s => s.Label == "affected");
catalog.VexStatuses.Should().Contain(s => s.Label == "not_affected");
catalog.VexStatuses.Should().Contain(s => s.Label == "fixed");
}
[Fact]
public void Catalog_ContainsKeywordsAndFunctions()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert - keywords
catalog.Keywords.Should().NotBeEmpty();
catalog.Keywords.Should().Contain(k => k.Label == "policy");
catalog.Keywords.Should().Contain(k => k.Label == "rule");
catalog.Keywords.Should().Contain(k => k.Label == "when");
catalog.Keywords.Should().Contain(k => k.Label == "then");
// Assert - functions
catalog.Functions.Should().NotBeEmpty();
catalog.Functions.Should().Contain(f => f.Label == "normalize_cvss");
catalog.Functions.Should().Contain(f => f.Label == "exists");
}
#endregion
#region Context-Based Completion Tests
[Fact]
public void GetCompletionsForContext_ScoreDot_ReturnsScoreFields()
{
// Arrange
var context = new DslCompletionContext("when score.");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "value");
completions.Should().Contain(c => c.Label == "bucket");
completions.Should().Contain(c => c.Label == "flags");
completions.Should().OnlyContain(c =>
DslCompletionProvider.GetCompletionCatalog().ScoreFields.Any(sf => sf.Label == c.Label));
}
[Fact]
public void GetCompletionsForContext_SbomDot_ReturnsSbomFields()
{
// Arrange
var context = new DslCompletionContext("when sbom.");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "purl");
completions.Should().Contain(c => c.Label == "name");
completions.Should().Contain(c => c.Label == "version");
}
[Fact]
public void GetCompletionsForContext_AdvisoryDot_ReturnsAdvisoryFields()
{
// Arrange
var context = new DslCompletionContext("when advisory.");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "id");
completions.Should().Contain(c => c.Label == "source");
completions.Should().Contain(c => c.Label == "severity");
}
[Fact]
public void GetCompletionsForContext_VexDot_ReturnsVexFields()
{
// Arrange
var context = new DslCompletionContext("when vex.");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "status");
completions.Should().Contain(c => c.Label == "justification");
completions.Should().Contain(c => c.Label == "any");
}
[Fact]
public void GetCompletionsForContext_ScoreBucketEquals_ReturnsBuckets()
{
// Arrange
var context = new DslCompletionContext("when score.bucket == ");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "ActNow");
completions.Should().Contain(c => c.Label == "ScheduleNext");
completions.Should().Contain(c => c.Label == "Investigate");
completions.Should().Contain(c => c.Label == "Watchlist");
}
[Fact]
public void GetCompletionsForContext_ScoreBucketEqualsQuote_ReturnsBuckets()
{
// Arrange
var context = new DslCompletionContext("when score.bucket == \"");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().HaveCount(4);
}
[Fact]
public void GetCompletionsForContext_ScoreFlagsContains_ReturnsFlags()
{
// Arrange
var context = new DslCompletionContext("when score.flags contains ");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "kev");
completions.Should().Contain(c => c.Label == "live-signal");
completions.Should().Contain(c => c.Label == "vendor-na");
}
[Fact]
public void GetCompletionsForContext_StatusEquals_ReturnsVexStatuses()
{
// Arrange
var context = new DslCompletionContext("status == ");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "affected");
completions.Should().Contain(c => c.Label == "not_affected");
completions.Should().Contain(c => c.Label == "fixed");
}
[Fact]
public void GetCompletionsForContext_JustificationEquals_ReturnsJustifications()
{
// Arrange
var context = new DslCompletionContext("justification == ");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "component_not_present");
completions.Should().Contain(c => c.Label == "vulnerable_code_not_present");
}
[Fact]
public void GetCompletionsForContext_AfterThen_ReturnsActions()
{
// Arrange
var context = new DslCompletionContext("when condition then");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "status :=");
completions.Should().Contain(c => c.Label == "ignore");
completions.Should().Contain(c => c.Label == "escalate");
}
[Fact]
public void GetCompletionsForContext_AfterElse_ReturnsActions()
{
// Arrange
var context = new DslCompletionContext("then action1 else");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "warn");
completions.Should().Contain(c => c.Label == "defer");
}
[Fact]
public void GetCompletionsForContext_EmptyContext_ReturnsAllTopLevel()
{
// Arrange
var context = new DslCompletionContext("");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
// Should include keywords
completions.Should().Contain(c => c.Label == "policy");
completions.Should().Contain(c => c.Label == "rule");
// Should include namespaces
completions.Should().Contain(c => c.Label == "score");
completions.Should().Contain(c => c.Label == "sbom");
// Should include functions
completions.Should().Contain(c => c.Label == "normalize_cvss");
}
#endregion
#region CompletionItem Tests
[Fact]
public void ScoreValueField_HasCorrectDocumentation()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Act
var valueField = catalog.ScoreFields.First(f => f.Label == "value");
// Assert
valueField.Documentation.Should().Contain("0-100");
valueField.Documentation.Should().Contain("score.value >= 80");
valueField.Kind.Should().Be(DslCompletionKind.Field);
}
[Fact]
public void ScoreBucketField_HasCorrectDocumentation()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Act
var bucketField = catalog.ScoreFields.First(f => f.Label == "bucket");
// Assert
bucketField.Documentation.Should().Contain("ActNow");
bucketField.Documentation.Should().Contain("ScheduleNext");
bucketField.Documentation.Should().Contain("Investigate");
bucketField.Documentation.Should().Contain("Watchlist");
}
[Fact]
public void ScoreFlags_AllHaveQuotedInsertText()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert - all flags should be quoted for use in DSL
foreach (var flag in catalog.ScoreFlags)
{
flag.InsertText.Should().StartWith("\"");
flag.InsertText.Should().EndWith("\"");
}
}
[Fact]
public void ScoreBuckets_AllHaveQuotedInsertText()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert - all buckets should be quoted for use in DSL
foreach (var bucket in catalog.ScoreBuckets)
{
bucket.InsertText.Should().StartWith("\"");
bucket.InsertText.Should().EndWith("\"");
}
}
[Fact]
public void SnippetCompletions_HaveSnippetFlag()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert - items with placeholders should have IsSnippet = true
var policyKeyword = catalog.Keywords.First(k => k.Label == "policy");
policyKeyword.IsSnippet.Should().BeTrue();
policyKeyword.InsertText.Should().Contain("${1:");
}
[Fact]
public void SimpleFields_DoNotHaveSnippetFlag()
{
// Arrange
var catalog = DslCompletionProvider.GetCompletionCatalog();
// Assert - simple field completions should not be snippets
var valueField = catalog.ScoreFields.First(f => f.Label == "value");
valueField.IsSnippet.Should().BeFalse();
valueField.InsertText.Should().NotContain("${");
}
#endregion
#region Edge Cases
[Fact]
public void GetCompletionsForContext_NullContext_ThrowsArgumentNullException()
{
// Act & Assert
var action = () => DslCompletionProvider.GetCompletionsForContext(null!);
action.Should().Throw<ArgumentNullException>();
}
[Fact]
public void GetCompletionsForContext_CaseInsensitive_ScoreBucket()
{
// Arrange - mixed case
var context = new DslCompletionContext("when SCORE.BUCKET == ");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "ActNow");
}
[Fact]
public void GetCompletionsForContext_MultipleContextsInLine_ReturnsCorrectCompletions()
{
// Arrange - score.value already used, now typing score.bucket
var context = new DslCompletionContext("when score.value >= 80 and score.bucket == ");
// Act
var completions = DslCompletionProvider.GetCompletionsForContext(context);
// Assert
completions.Should().NotBeEmpty();
completions.Should().Contain(c => c.Label == "ActNow");
}
[Fact]
public void Catalog_IsSingleton()
{
// Act
var catalog1 = DslCompletionProvider.GetCompletionCatalog();
var catalog2 = DslCompletionProvider.GetCompletionCatalog();
// Assert
catalog1.Should().BeSameAs(catalog2);
}
#endregion
}