using System.Net; using System.Net.Http; using System.Text.Json.Nodes; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Infrastructure.Policy; using StellaOps.Findings.Ledger.Options; using Xunit; namespace StellaOps.Findings.Ledger.Tests; public sealed class PolicyEngineEvaluationServiceTests { private const string TenantId = "tenant-1"; private static readonly DateTimeOffset Now = DateTimeOffset.UtcNow; [Fact] public async Task EvaluateAsync_UsesPolicyEngineAndCachesResult() { var handler = new StubHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent( """ { "items": [ { "findingId": "finding-1", "status": "affected", "severity": 7.5, "labels": { "exposure": "runtime" }, "explainRef": "policy://explain/123", "rationale": ["policy://explain/123"] } ] } """, System.Text.Encoding.UTF8, "application/json") }); var factory = new TestHttpClientFactory(handler); var options = CreateOptions(new Uri("https://policy.example/")); using var cache = new PolicyEvaluationCache(options.PolicyEngine, NullLogger.Instance); var inline = new InlinePolicyEvaluationService(NullLogger.Instance); var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); var record = CreateRecord(); var first = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); var second = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); Assert.Equal("affected", first.Status); Assert.Equal(7.5m, first.Severity); Assert.Equal("policy://explain/123", first.ExplainRef); Assert.Equal("runtime", first.Labels?["exposure"]?.GetValue()); Assert.Equal(1, handler.CallCount); // cached second call Assert.Equal("affected", second.Status); Assert.Equal("policy://explain/123", second.Rationale[0]?.GetValue()); } [Fact] public async Task EvaluateAsync_FallsBackToInlineWhenRequestFails() { var handler = new StubHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); var factory = new TestHttpClientFactory(handler); var options = CreateOptions(new Uri("https://policy.example/")); using var cache = new PolicyEvaluationCache(options.PolicyEngine, NullLogger.Instance); var inline = new InlinePolicyEvaluationService(NullLogger.Instance); var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); var record = CreateRecord(payloadStatus: "investigating", payloadSeverity: 4.2m); var result = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); Assert.Equal("investigating", result.Status); Assert.Equal(4.2m, result.Severity); Assert.Equal(1, handler.CallCount); } [Fact] public async Task EvaluateAsync_UsesInlineWhenNoBaseAddressConfigured() { var handler = new StubHttpHandler(_ => throw new InvalidOperationException("Handler should not be invoked.")); var factory = new TestHttpClientFactory(handler); var options = CreateOptions(baseAddress: null); using var cache = new PolicyEvaluationCache(options.PolicyEngine, NullLogger.Instance); var inline = new InlinePolicyEvaluationService(NullLogger.Instance); var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); var record = CreateRecord(payloadStatus: "accepted_risk", payloadSeverity: 1.0m); var result = await service.EvaluateAsync(record, existingProjection: null, CancellationToken.None); Assert.Equal("accepted_risk", result.Status); Assert.Equal(1.0m, result.Severity); Assert.Equal(0, handler.CallCount); } private static LedgerServiceOptions CreateOptions(Uri? baseAddress) { return new LedgerServiceOptions { PolicyEngine = new LedgerServiceOptions.PolicyEngineOptions { BaseAddress = baseAddress, Cache = new LedgerServiceOptions.PolicyEngineCacheOptions { SizeLimit = 16, EntryLifetime = TimeSpan.FromMinutes(5) } } }; } private static LedgerEventRecord CreateRecord(string payloadStatus = "affected", decimal? payloadSeverity = 5.0m) { var payload = new JsonObject { ["status"] = payloadStatus, ["severity"] = payloadSeverity, ["labels"] = new JsonObject { ["source"] = "policy" } }; var envelope = new JsonObject { ["event"] = new JsonObject { ["payload"] = payload } }; return new LedgerEventRecord( TenantId, Guid.NewGuid(), 1, Guid.NewGuid(), LedgerEventConstants.EventFindingStatusChanged, "policy/v1", "finding-1", "artifact-1", null, "actor", "service", Now, Now, envelope, "hash", "prev", "leaf", envelope.ToJsonString()); } private sealed class StubHttpHandler : HttpMessageHandler { private readonly Func _handler; public StubHttpHandler(Func handler) { _handler = handler ?? throw new ArgumentNullException(nameof(handler)); } public int CallCount { get; private set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { CallCount++; return Task.FromResult(_handler(request)); } } private sealed class TestHttpClientFactory : IHttpClientFactory { private readonly HttpMessageHandler _handler; public TestHttpClientFactory(HttpMessageHandler handler) { _handler = handler; } public HttpClient CreateClient(string name) { return new HttpClient(_handler, disposeHandler: false); } } }