using System.Collections.Immutable; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Policy.Engine.Caching; using StellaOps.Policy.Engine.Compilation; using StellaOps.Policy.Engine.Domain; using StellaOps.Policy.Engine.Evaluation; using StellaOps.Policy.Engine.Options; using StellaOps.Policy.Engine.Services; using StellaOps.PolicyDsl; using Xunit; namespace StellaOps.Policy.Engine.Tests; public sealed class PolicyRuntimeEvaluationServiceTests { private const string TestPolicy = """ policy "Test Policy" syntax "stella-dsl@1" { rule block_critical priority 10 { when severity.normalized == "Critical" then status := "blocked" because "Block critical findings" } rule warn_high priority 20 { when severity.normalized == "High" then status := "warn" because "Warn on high severity findings" } rule allow_default priority 100 { when true then status := "affected" because "Default affected status" } } """; [Fact] public async Task EvaluateAsync_ReturnsDecisionFromCompiledPolicy() { var harness = CreateHarness(); await harness.StoreTestPolicyAsync("pack-1", 1, TestPolicy); var request = CreateRequest("pack-1", 1, severity: "Critical"); var response = await harness.Service.EvaluateAsync(request, CancellationToken.None); Assert.Equal("pack-1", response.PackId); Assert.Equal(1, response.Version); Assert.NotNull(response.PolicyDigest); Assert.False(response.Cached); } [Fact] public async Task EvaluateAsync_UsesCacheOnSecondCall() { var harness = CreateHarness(); await harness.StoreTestPolicyAsync("pack-1", 1, TestPolicy); var request = CreateRequest("pack-1", 1, severity: "High"); // First call - cache miss var response1 = await harness.Service.EvaluateAsync(request, CancellationToken.None); Assert.False(response1.Cached); // Second call - cache hit var response2 = await harness.Service.EvaluateAsync(request, CancellationToken.None); Assert.True(response2.Cached); Assert.Equal(CacheSource.InMemory, response2.CacheSource); Assert.Equal(response1.Status, response2.Status); Assert.Equal(response1.CorrelationId, response2.CorrelationId); } [Fact] public async Task EvaluateAsync_BypassCacheWhenRequested() { var harness = CreateHarness(); await harness.StoreTestPolicyAsync("pack-1", 1, TestPolicy); var request = CreateRequest("pack-1", 1, severity: "Medium"); // First call var response1 = await harness.Service.EvaluateAsync(request, CancellationToken.None); Assert.False(response1.Cached); // Second call with bypass var bypassRequest = request with { BypassCache = true }; var response2 = await harness.Service.EvaluateAsync(bypassRequest, CancellationToken.None); Assert.False(response2.Cached); } [Fact] public async Task EvaluateAsync_ThrowsOnMissingBundle() { var harness = CreateHarness(); var request = CreateRequest("non-existent", 1, severity: "Low"); await Assert.ThrowsAsync( () => harness.Service.EvaluateAsync(request, CancellationToken.None)); } [Fact] public async Task EvaluateAsync_GeneratesDeterministicCorrelationId() { var harness = CreateHarness(); await harness.StoreTestPolicyAsync("pack-1", 1, TestPolicy); var request = CreateRequest("pack-1", 1, severity: "High"); var response1 = await harness.Service.EvaluateAsync(request, CancellationToken.None); // Create a new harness with fresh cache var harness2 = CreateHarness(); await harness2.StoreTestPolicyAsync("pack-1", 1, TestPolicy); var response2 = await harness2.Service.EvaluateAsync(request, CancellationToken.None); // Same inputs should produce same correlation ID Assert.Equal(response1.CorrelationId, response2.CorrelationId); } [Fact] public async Task EvaluateBatchAsync_ReturnsMultipleResults() { var harness = CreateHarness(); await harness.StoreTestPolicyAsync("pack-1", 1, TestPolicy); var requests = new[] { CreateRequest("pack-1", 1, severity: "Critical", subjectPurl: "pkg:npm/lodash@4.17.0"), CreateRequest("pack-1", 1, severity: "High", subjectPurl: "pkg:npm/express@4.18.0"), CreateRequest("pack-1", 1, severity: "Medium", subjectPurl: "pkg:npm/axios@1.0.0"), }; var responses = await harness.Service.EvaluateBatchAsync(requests, CancellationToken.None); Assert.Equal(3, responses.Count); } [Fact] public async Task EvaluateBatchAsync_UsesCacheForDuplicates() { var harness = CreateHarness(); await harness.StoreTestPolicyAsync("pack-1", 1, TestPolicy); // Pre-populate cache var request = CreateRequest("pack-1", 1, severity: "Critical"); await harness.Service.EvaluateAsync(request, CancellationToken.None); var requests = new[] { request, // Should be cached CreateRequest("pack-1", 1, severity: "High"), // New }; var responses = await harness.Service.EvaluateBatchAsync(requests, CancellationToken.None); Assert.Equal(2, responses.Count); Assert.Contains(responses, r => r.Cached); Assert.Contains(responses, r => !r.Cached); } [Fact] public async Task EvaluateAsync_DifferentContextsGetDifferentCacheKeys() { var harness = CreateHarness(); await harness.StoreTestPolicyAsync("pack-1", 1, TestPolicy); var request1 = CreateRequest("pack-1", 1, severity: "High"); var request2 = CreateRequest("pack-1", 1, severity: "Critical"); var response1 = await harness.Service.EvaluateAsync(request1, CancellationToken.None); var response2 = await harness.Service.EvaluateAsync(request2, CancellationToken.None); // Both should be cache misses (different severity = different context) Assert.False(response1.Cached); Assert.False(response2.Cached); // Different inputs = different correlation IDs Assert.NotEqual(response1.CorrelationId, response2.CorrelationId); } private static RuntimeEvaluationRequest CreateRequest( string packId, int version, string severity, string tenantId = "tenant-1", string subjectPurl = "pkg:npm/lodash@4.17.21", string advisoryId = "CVE-2024-0001") { return new RuntimeEvaluationRequest( packId, version, tenantId, subjectPurl, advisoryId, Severity: new PolicyEvaluationSeverity(severity, null), Advisory: new PolicyEvaluationAdvisory("NVD", ImmutableDictionary.Empty), Vex: PolicyEvaluationVexEvidence.Empty, Sbom: PolicyEvaluationSbom.Empty, Exceptions: PolicyEvaluationExceptions.Empty, Reachability: PolicyEvaluationReachability.Unknown, EvaluationTimestamp: new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero), BypassCache: false); } private static TestHarness CreateHarness() { var repository = new InMemoryPolicyPackRepository(); var cacheLogger = NullLogger.Instance; var serviceLogger = NullLogger.Instance; var options = Microsoft.Extensions.Options.Options.Create(new PolicyEngineOptions()); var cache = new InMemoryPolicyEvaluationCache(cacheLogger, TimeProvider.System, options); var evaluator = new PolicyEvaluator(); var compilationService = CreateCompilationService(); var service = new PolicyRuntimeEvaluationService( repository, cache, evaluator, TimeProvider.System, serviceLogger); return new TestHarness(service, repository, compilationService); } private static PolicyCompilationService CreateCompilationService() { var compiler = new PolicyCompiler(); var analyzer = new PolicyComplexityAnalyzer(); var options = new PolicyEngineOptions(); var optionsMonitor = new StaticOptionsMonitor(options); var metadataExtractor = new PolicyMetadataExtractor(); return new PolicyCompilationService(compiler, analyzer, metadataExtractor, optionsMonitor, TimeProvider.System); } private sealed record TestHarness( PolicyRuntimeEvaluationService Service, InMemoryPolicyPackRepository Repository, PolicyCompilationService CompilationService) { public async Task StoreTestPolicyAsync(string packId, int version, string dsl) { var bundleService = new PolicyBundleService(CompilationService, Repository, TimeProvider.System); var request = new PolicyBundleRequest(new PolicyDslPayload("stella-dsl@1", dsl), SigningKeyId: null); await bundleService.CompileAndStoreAsync(packId, version, request, CancellationToken.None); } } private sealed class StaticOptionsMonitor : IOptionsMonitor { private readonly PolicyEngineOptions _value; public StaticOptionsMonitor(PolicyEngineOptions value) => _value = value; public PolicyEngineOptions CurrentValue => _value; public PolicyEngineOptions Get(string? name) => _value; public IDisposable OnChange(Action listener) => NullDisposable.Instance; private sealed class NullDisposable : IDisposable { public static readonly NullDisposable Instance = new(); public void Dispose() { } } } }