sprints enhancements
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025 StellaOps
|
||||
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
|
||||
// Task: PINT-8200-041 - Determinism test: same finding + policy → same EWS in verdict
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism tests verifying that same finding + policy → same EWS in verdict.
|
||||
/// These tests ensure that EWS calculation is fully deterministic and produces
|
||||
/// identical results across multiple evaluations.
|
||||
/// </summary>
|
||||
[Trait("Category", "Determinism")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "8200.0012.0003")]
|
||||
[Trait("Task", "PINT-8200-041")]
|
||||
public sealed class EwsVerdictDeterminismTests
|
||||
{
|
||||
private static ServiceCollection CreateServicesWithConfiguration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection()
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
return services;
|
||||
}
|
||||
|
||||
#region Score Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "Same finding evidence produces identical EWS across multiple calculations")]
|
||||
public void SameFindingEvidence_ProducesIdenticalEws_AcrossMultipleCalculations()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = CreateTestInput("determinism-test-001");
|
||||
|
||||
// Act - Calculate 100 times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results should be byte-identical
|
||||
var firstScore = results[0].Score;
|
||||
var firstBucket = results[0].Bucket;
|
||||
var firstDimensions = results[0].Dimensions;
|
||||
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Score.Should().Be(firstScore, "score must be deterministic");
|
||||
r.Bucket.Should().Be(firstBucket, "bucket must be deterministic");
|
||||
r.Dimensions.Should().BeEquivalentTo(firstDimensions, "dimensions must be deterministic");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Same finding produces identical EWS through enricher pipeline")]
|
||||
public void SameFinding_ProducesIdenticalEws_ThroughEnricherPipeline()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.EnableCaching = false; // Disable caching to test actual calculation determinism
|
||||
});
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
var evidence = CreateTestEvidence("pipeline-determinism-test");
|
||||
|
||||
// Act - Enrich 50 times
|
||||
var results = Enumerable.Range(0, 50)
|
||||
.Select(_ => enricher.Enrich(evidence))
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
var firstResult = results[0];
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Score!.Score.Should().Be(firstResult.Score!.Score, "enriched score must be deterministic");
|
||||
r.Score!.Bucket.Should().Be(firstResult.Score!.Bucket, "enriched bucket must be deterministic");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Floating point precision is maintained across calculations")]
|
||||
public void FloatingPointPrecision_IsMaintained_AcrossCalculations()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
|
||||
// Input with fractional values that could cause floating point issues
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "float-precision-test",
|
||||
Rch = 0.333333333333333,
|
||||
Rts = 0.666666666666666,
|
||||
Bkp = 0.111111111111111,
|
||||
Xpl = 0.777777777777777,
|
||||
Src = 0.222222222222222,
|
||||
Mit = 0.888888888888888
|
||||
};
|
||||
|
||||
// Act - Calculate many times
|
||||
var results = Enumerable.Range(0, 100)
|
||||
.Select(_ => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction))
|
||||
.ToList();
|
||||
|
||||
// Assert - All scores should be exactly equal (not just approximately)
|
||||
var firstScore = results[0].Score;
|
||||
results.Should().AllSatisfy(r => r.Score.Should().Be(firstScore));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Policy Variation Tests
|
||||
|
||||
[Fact(DisplayName = "Same evidence with same policy produces identical EWS")]
|
||||
public void SameEvidenceAndPolicy_ProducesIdenticalEws()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = CreateTestInput("policy-consistency-test");
|
||||
var policy = EvidenceWeightPolicy.DefaultProduction;
|
||||
|
||||
// Act - Multiple calculations with same policy
|
||||
var result1 = calculator.Calculate(input, policy);
|
||||
var result2 = calculator.Calculate(input, policy);
|
||||
var result3 = calculator.Calculate(input, policy);
|
||||
|
||||
// Assert
|
||||
result1.Score.Should().Be(result2.Score);
|
||||
result2.Score.Should().Be(result3.Score);
|
||||
result1.Bucket.Should().Be(result2.Bucket);
|
||||
result2.Bucket.Should().Be(result3.Bucket);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Different policies produce different EWS for same evidence")]
|
||||
public void DifferentPolicies_ProduceDifferentEws_ForSameEvidence()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = CreateTestInput("multi-policy-test");
|
||||
|
||||
// Custom policy with different weights
|
||||
var customPolicy = new EvidenceWeightPolicy
|
||||
{
|
||||
PolicyId = "custom-test-policy",
|
||||
Version = "1.0",
|
||||
Weights = new EvidenceWeights
|
||||
{
|
||||
Reachability = 0.50, // Much higher weight on reachability
|
||||
Runtime = 0.10,
|
||||
Backport = 0.05,
|
||||
Exploit = 0.20,
|
||||
Source = 0.10,
|
||||
Mitigation = 0.05
|
||||
},
|
||||
Buckets = EvidenceWeightPolicy.DefaultProduction.Buckets
|
||||
};
|
||||
|
||||
// Act
|
||||
var defaultResult = calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction);
|
||||
var customResult = calculator.Calculate(input, customPolicy);
|
||||
|
||||
// Assert - Different policies should produce different scores
|
||||
// (unless the evidence happens to result in same weighted sum)
|
||||
// The test validates that policy changes affect output
|
||||
(defaultResult.Score == customResult.Score &&
|
||||
defaultResult.Bucket == customResult.Bucket)
|
||||
.Should().BeFalse("different weight distributions should generally produce different scores");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "EWS JSON serialization is deterministic")]
|
||||
public void EwsJsonSerialization_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = CreateTestInput("serialization-test");
|
||||
var result = calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction);
|
||||
|
||||
// Act - Serialize multiple times
|
||||
var serializations = Enumerable.Range(0, 10)
|
||||
.Select(_ => System.Text.Json.JsonSerializer.Serialize(result))
|
||||
.ToList();
|
||||
|
||||
// Assert - All serializations should be identical
|
||||
var first = serializations[0];
|
||||
serializations.Should().AllBeEquivalentTo(first);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "EWS round-trips correctly through JSON")]
|
||||
public void EwsRoundTrip_ThroughJson_IsCorrect()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = CreateTestInput("roundtrip-test");
|
||||
var original = calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction);
|
||||
|
||||
// Act - Round-trip through JSON
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(original);
|
||||
var deserialized = System.Text.Json.JsonSerializer.Deserialize<EvidenceWeightedScoreResult>(json);
|
||||
|
||||
// Assert
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.Score.Should().Be(original.Score);
|
||||
deserialized.Bucket.Should().Be(original.Bucket);
|
||||
deserialized.FindingId.Should().Be(original.FindingId);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Case Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "Zero values produce deterministic EWS")]
|
||||
public void ZeroValues_ProduceDeterministicEws()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "zero-test",
|
||||
Rch = 0.0,
|
||||
Rts = 0.0,
|
||||
Bkp = 0.0,
|
||||
Xpl = 0.0,
|
||||
Src = 0.0,
|
||||
Mit = 0.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 20)
|
||||
.Select(_ => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction))
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
var first = results[0];
|
||||
results.Should().AllSatisfy(r => r.Score.Should().Be(first.Score));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Maximum values produce deterministic EWS")]
|
||||
public void MaximumValues_ProduceDeterministicEws()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "max-test",
|
||||
Rch = 1.0,
|
||||
Rts = 1.0,
|
||||
Bkp = 1.0,
|
||||
Xpl = 1.0,
|
||||
Src = 1.0,
|
||||
Mit = 1.0
|
||||
};
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 20)
|
||||
.Select(_ => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction))
|
||||
.ToList();
|
||||
|
||||
// Assert
|
||||
var first = results[0];
|
||||
results.Should().AllSatisfy(r => r.Score.Should().Be(first.Score));
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Boundary values produce deterministic EWS")]
|
||||
public void BoundaryValues_ProduceDeterministicEws()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
|
||||
// Values at bucket boundaries
|
||||
var inputs = new[]
|
||||
{
|
||||
new EvidenceWeightedScoreInput { FindingId = "boundary-0", Rch = 0.0, Rts = 0.0, Bkp = 0.0, Xpl = 0.0, Src = 0.0, Mit = 0.0 },
|
||||
new EvidenceWeightedScoreInput { FindingId = "boundary-25", Rch = 0.25, Rts = 0.25, Bkp = 0.25, Xpl = 0.25, Src = 0.25, Mit = 0.25 },
|
||||
new EvidenceWeightedScoreInput { FindingId = "boundary-50", Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.5 },
|
||||
new EvidenceWeightedScoreInput { FindingId = "boundary-75", Rch = 0.75, Rts = 0.75, Bkp = 0.75, Xpl = 0.75, Src = 0.75, Mit = 0.75 },
|
||||
new EvidenceWeightedScoreInput { FindingId = "boundary-100", Rch = 1.0, Rts = 1.0, Bkp = 1.0, Xpl = 1.0, Src = 1.0, Mit = 1.0 }
|
||||
};
|
||||
|
||||
foreach (var input in inputs)
|
||||
{
|
||||
// Act - Calculate same input multiple times
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results for same input should be identical
|
||||
var first = results[0];
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Score.Should().Be(first.Score, $"boundary input {input.FindingId} must be deterministic");
|
||||
r.Bucket.Should().Be(first.Bucket, $"boundary input {input.FindingId} must be deterministic");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Concurrent Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "Concurrent calculations produce identical results")]
|
||||
public async Task ConcurrentCalculations_ProduceIdenticalResults()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = CreateTestInput("concurrent-test");
|
||||
|
||||
// Act - Calculate concurrently
|
||||
var tasks = Enumerable.Range(0, 100)
|
||||
.Select(_ => Task.Run(() => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction)))
|
||||
.ToArray();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
var first = results[0];
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Score.Should().Be(first.Score, "concurrent calculations must be deterministic");
|
||||
r.Bucket.Should().Be(first.Bucket, "concurrent calculations must be deterministic");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Concurrent enricher calls produce identical results")]
|
||||
public async Task ConcurrentEnricherCalls_ProduceIdenticalResults()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.EnableCaching = false; // Test actual calculation, not cache
|
||||
});
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
var evidence = CreateTestEvidence("concurrent-enricher-test");
|
||||
|
||||
// Act - Enrich concurrently
|
||||
var tasks = Enumerable.Range(0, 50)
|
||||
.Select(_ => Task.Run(() => enricher.Enrich(evidence)))
|
||||
.ToArray();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert
|
||||
var first = results[0];
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Score!.Score.Should().Be(first.Score!.Score, "concurrent enrichments must be deterministic");
|
||||
r.Score!.Bucket.Should().Be(first.Score!.Bucket, "concurrent enrichments must be deterministic");
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hash Determinism Tests
|
||||
|
||||
[Fact(DisplayName = "Finding hash is deterministic")]
|
||||
public void FindingHash_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = CreateTestInput("hash-test");
|
||||
|
||||
// Act
|
||||
var results = Enumerable.Range(0, 20)
|
||||
.Select(_ => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction))
|
||||
.ToList();
|
||||
|
||||
// Assert - If FindingId is the same, results should be consistent
|
||||
results.Should().AllSatisfy(r => r.FindingId.Should().Be("hash-test"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static EvidenceWeightedScoreInput CreateTestInput(string findingId)
|
||||
{
|
||||
return new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = findingId,
|
||||
Rch = 0.75,
|
||||
Rts = 0.60,
|
||||
Bkp = 0.40,
|
||||
Xpl = 0.55,
|
||||
Src = 0.65,
|
||||
Mit = 0.20
|
||||
};
|
||||
}
|
||||
|
||||
private static FindingEvidence CreateTestEvidence(string findingId)
|
||||
{
|
||||
return new FindingEvidence
|
||||
{
|
||||
FindingId = findingId,
|
||||
Reachability = new ReachabilityInput
|
||||
{
|
||||
State = StellaOps.Signals.EvidenceWeightedScore.ReachabilityState.DynamicReachable,
|
||||
Confidence = 0.85
|
||||
},
|
||||
Runtime = new RuntimeInput
|
||||
{
|
||||
Posture = StellaOps.Signals.EvidenceWeightedScore.RuntimePosture.ActiveTracing,
|
||||
ObservationCount = 3,
|
||||
RecencyFactor = 0.75
|
||||
},
|
||||
Exploit = new ExploitInput
|
||||
{
|
||||
EpssScore = 0.45,
|
||||
EpssPercentile = 75,
|
||||
KevStatus = KevStatus.NotInKev,
|
||||
PublicExploitAvailable = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright © 2025 StellaOps
|
||||
// Sprint: SPRINT_8200_0012_0003_policy_engine_integration
|
||||
// Task: PINT-8200-040 - Integration tests for full policy→EWS pipeline
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Policy.Confidence.Models;
|
||||
using StellaOps.Policy.Engine.Scoring.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.EvidenceWeightedScore.Normalizers;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for the full policy evaluation → EWS calculation pipeline.
|
||||
/// Tests DI wiring and component integration.
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Sprint", "8200.0012.0003")]
|
||||
[Trait("Task", "PINT-8200-040")]
|
||||
public sealed class PolicyEwsPipelineIntegrationTests
|
||||
{
|
||||
private static ServiceCollection CreateServicesWithConfiguration()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection()
|
||||
.Build();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
return services;
|
||||
}
|
||||
|
||||
#region DI Wiring Tests
|
||||
|
||||
[Fact(DisplayName = "AddEvidenceWeightedScore registers all required services")]
|
||||
public void AddEvidenceWeightedScore_RegistersAllServices()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
|
||||
// Act
|
||||
services.AddLogging();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert: All services should be resolvable
|
||||
provider.GetService<IEvidenceWeightedScoreCalculator>().Should().NotBeNull();
|
||||
provider.GetService<IFindingScoreEnricher>().Should().NotBeNull();
|
||||
provider.GetService<IScoreEnrichmentCache>().Should().NotBeNull();
|
||||
provider.GetService<IDualEmitVerdictEnricher>().Should().NotBeNull();
|
||||
provider.GetService<IMigrationTelemetryService>().Should().NotBeNull();
|
||||
provider.GetService<IEwsTelemetryService>().Should().NotBeNull();
|
||||
provider.GetService<ConfidenceToEwsAdapter>().Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "AddEvidenceWeightedScore with configure action applies options")]
|
||||
public void AddEvidenceWeightedScore_WithConfigure_AppliesOptions()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.EnableCaching = true;
|
||||
});
|
||||
|
||||
// Act
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<PolicyEvidenceWeightedScoreOptions>>();
|
||||
|
||||
// Assert
|
||||
options.Value.Enabled.Should().BeTrue();
|
||||
options.Value.EnableCaching.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Services are registered as singletons")]
|
||||
public void Services_AreRegisteredAsSingletons()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var enricher1 = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
var enricher2 = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
|
||||
// Assert: Same instance (singleton)
|
||||
enricher1.Should().BeSameAs(enricher2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Calculator Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Calculator produces valid EWS result from normalized inputs")]
|
||||
public void Calculator_ProducesValidResult_FromNormalizedInputs()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-CALC@pkg:test/calc@1.0",
|
||||
Rch = 0.8,
|
||||
Rts = 0.7,
|
||||
Bkp = 0.3,
|
||||
Xpl = 0.6,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Score.Should().BeInRange(0, 100);
|
||||
result.Bucket.Should().BeDefined();
|
||||
result.FindingId.Should().Be("CVE-2024-CALC@pkg:test/calc@1.0");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Calculator is deterministic for same inputs")]
|
||||
public void Calculator_IsDeterministic_ForSameInputs()
|
||||
{
|
||||
// Arrange
|
||||
var calculator = new EvidenceWeightedScoreCalculator();
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "determinism-test",
|
||||
Rch = 0.75, Rts = 0.60, Bkp = 0.40, Xpl = 0.55, Src = 0.65, Mit = 0.20
|
||||
};
|
||||
|
||||
// Act - Calculate multiple times
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => calculator.Calculate(input, EvidenceWeightPolicy.DefaultProduction))
|
||||
.ToList();
|
||||
|
||||
// Assert - All results should be identical
|
||||
var firstScore = results[0].Score;
|
||||
results.Should().AllSatisfy(r => r.Score.Should().Be(firstScore));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enricher Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Enricher with enabled feature calculates scores")]
|
||||
public void Enricher_WithEnabledFeature_CalculatesScores()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts => opts.Enabled = true);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
var evidence = new FindingEvidence
|
||||
{
|
||||
FindingId = "CVE-2024-TEST@pkg:test/enricher@1.0",
|
||||
Reachability = new ReachabilityInput
|
||||
{
|
||||
State = StellaOps.Signals.EvidenceWeightedScore.ReachabilityState.DynamicReachable,
|
||||
Confidence = 0.85
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Score.Should().NotBeNull();
|
||||
result.Score!.Score.Should().BeInRange(0, 100);
|
||||
result.FindingId.Should().Be("CVE-2024-TEST@pkg:test/enricher@1.0");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Enricher with disabled feature returns skipped")]
|
||||
public void Enricher_WithDisabledFeature_ReturnsSkipped()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts => opts.Enabled = false);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
var evidence = new FindingEvidence { FindingId = "test-finding" };
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Score.Should().BeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Caching Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Cache returns cached result on second call")]
|
||||
public void Cache_ReturnsCachedResult_OnSecondCall()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.EnableCaching = true;
|
||||
});
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
var evidence = new FindingEvidence { FindingId = "cache-test" };
|
||||
|
||||
// Act
|
||||
var result1 = enricher.Enrich(evidence);
|
||||
var result2 = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result1.FromCache.Should().BeFalse();
|
||||
result2.FromCache.Should().BeTrue();
|
||||
result1.Score!.Score.Should().Be(result2.Score!.Score);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Cache stores different findings separately")]
|
||||
public void Cache_StoresDifferentFindings_Separately()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.EnableCaching = true;
|
||||
});
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
var evidence1 = new FindingEvidence
|
||||
{
|
||||
FindingId = "finding-A",
|
||||
Reachability = new ReachabilityInput
|
||||
{
|
||||
State = StellaOps.Signals.EvidenceWeightedScore.ReachabilityState.DynamicReachable,
|
||||
Confidence = 0.9
|
||||
}
|
||||
};
|
||||
var evidence2 = new FindingEvidence
|
||||
{
|
||||
FindingId = "finding-B",
|
||||
Reachability = new ReachabilityInput
|
||||
{
|
||||
State = StellaOps.Signals.EvidenceWeightedScore.ReachabilityState.Unknown,
|
||||
Confidence = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result1 = enricher.Enrich(evidence1);
|
||||
var result2 = enricher.Enrich(evidence2);
|
||||
|
||||
// Assert
|
||||
result1.FromCache.Should().BeFalse();
|
||||
result2.FromCache.Should().BeFalse();
|
||||
result1.FindingId.Should().Be("finding-A");
|
||||
result2.FindingId.Should().Be("finding-B");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Adapter Integration Tests
|
||||
|
||||
[Fact(DisplayName = "Adapter converts Confidence to EWS")]
|
||||
public void Adapter_ConvertsConfidenceToEws()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new ConfidenceToEwsAdapter();
|
||||
var confidence = new ConfidenceScore
|
||||
{
|
||||
Value = 0.35m, // Lower confidence = higher risk
|
||||
Factors =
|
||||
[
|
||||
new ConfidenceFactor
|
||||
{
|
||||
Type = ConfidenceFactorType.Reachability,
|
||||
Weight = 0.5m,
|
||||
RawValue = 0.35m,
|
||||
Reason = "Test"
|
||||
}
|
||||
],
|
||||
Explanation = "Test confidence score"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = adapter.Adapt(confidence, "adapter-test-finding");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.EwsResult.Should().NotBeNull();
|
||||
result.OriginalConfidence.Should().Be(confidence);
|
||||
// Low confidence → High EWS (inverted scale)
|
||||
result.EwsResult.Score.Should().BeGreaterThan(50);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Adapter preserves ranking relationship")]
|
||||
public void Adapter_PreservesRankingRelationship()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new ConfidenceToEwsAdapter();
|
||||
|
||||
// Higher confidence = safer = lower EWS
|
||||
var highConfidence = new ConfidenceScore
|
||||
{
|
||||
Value = 0.85m,
|
||||
Factors = [],
|
||||
Explanation = "High confidence"
|
||||
};
|
||||
|
||||
// Lower confidence = riskier = higher EWS
|
||||
var lowConfidence = new ConfidenceScore
|
||||
{
|
||||
Value = 0.25m,
|
||||
Factors = [],
|
||||
Explanation = "Low confidence"
|
||||
};
|
||||
|
||||
// Act
|
||||
var highResult = adapter.Adapt(highConfidence, "high-conf");
|
||||
var lowResult = adapter.Adapt(lowConfidence, "low-conf");
|
||||
|
||||
// Assert - Ranking should be preserved (inverted): low confidence = higher risk = higher or equal EWS
|
||||
lowResult.EwsResult.Score.Should().BeGreaterThanOrEqualTo(highResult.EwsResult.Score,
|
||||
"lower confidence should produce equal or higher EWS (inverted scale)");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region End-to-End Pipeline Tests
|
||||
|
||||
[Fact(DisplayName = "Full pipeline produces actionable results")]
|
||||
public void FullPipeline_ProducesActionableResults()
|
||||
{
|
||||
// Arrange - Build a complete pipeline via DI
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts =>
|
||||
{
|
||||
opts.Enabled = true;
|
||||
opts.EnableCaching = true;
|
||||
});
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
|
||||
// Simulate real finding evidence
|
||||
var evidence = new FindingEvidence
|
||||
{
|
||||
FindingId = "CVE-2024-12345@pkg:npm/vulnerable-lib@1.0.0",
|
||||
Reachability = new ReachabilityInput
|
||||
{
|
||||
State = StellaOps.Signals.EvidenceWeightedScore.ReachabilityState.DynamicReachable,
|
||||
Confidence = 0.90
|
||||
},
|
||||
Runtime = new RuntimeInput
|
||||
{
|
||||
Posture = StellaOps.Signals.EvidenceWeightedScore.RuntimePosture.ActiveTracing,
|
||||
ObservationCount = 5,
|
||||
RecencyFactor = 0.85
|
||||
},
|
||||
Exploit = new ExploitInput
|
||||
{
|
||||
EpssScore = 0.75,
|
||||
EpssPercentile = 90,
|
||||
KevStatus = KevStatus.InKev,
|
||||
PublicExploitAvailable = true
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Score.Should().NotBeNull();
|
||||
result.Score!.Score.Should().BeGreaterThan(50, "high-risk evidence should produce elevated EWS");
|
||||
result.FindingId.Should().Be("CVE-2024-12345@pkg:npm/vulnerable-lib@1.0.0");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Pipeline handles missing evidence gracefully")]
|
||||
public void Pipeline_HandlesMissingEvidence_Gracefully()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServicesWithConfiguration();
|
||||
services.AddEvidenceWeightedScoring();
|
||||
services.AddEvidenceNormalizers();
|
||||
services.AddEvidenceWeightedScore(opts => opts.Enabled = true);
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var enricher = provider.GetRequiredService<IFindingScoreEnricher>();
|
||||
|
||||
// Minimal evidence - only finding ID
|
||||
var evidence = new FindingEvidence { FindingId = "minimal-finding" };
|
||||
|
||||
// Act
|
||||
var result = enricher.Enrich(evidence);
|
||||
|
||||
// Assert - Should still produce a valid result with defaults
|
||||
result.Should().NotBeNull();
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Score.Should().NotBeNull();
|
||||
result.Score!.Score.Should().BeInRange(0, 100);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user