Files
git.stella-ops.org/src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/PolicyEngineEvaluationServiceTests.cs
master 9253620833
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
feat: Implement Policy Engine Evaluation Service and Cache with unit tests
Temp commit to debug
2025-11-05 09:44:37 +02:00

187 lines
7.1 KiB
C#

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<PolicyEvaluationCache>.Instance);
var inline = new InlinePolicyEvaluationService(NullLogger<InlinePolicyEvaluationService>.Instance);
var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger<PolicyEngineEvaluationService>.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<string>());
Assert.Equal(1, handler.CallCount); // cached second call
Assert.Equal("affected", second.Status);
Assert.Equal("policy://explain/123", second.Rationale[0]?.GetValue<string>());
}
[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<PolicyEvaluationCache>.Instance);
var inline = new InlinePolicyEvaluationService(NullLogger<InlinePolicyEvaluationService>.Instance);
var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger<PolicyEngineEvaluationService>.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<PolicyEvaluationCache>.Instance);
var inline = new InlinePolicyEvaluationService(NullLogger<InlinePolicyEvaluationService>.Instance);
var service = new PolicyEngineEvaluationService(factory, inline, cache, Microsoft.Extensions.Options.Options.Create(options), NullLogger<PolicyEngineEvaluationService>.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<HttpRequestMessage, HttpResponseMessage> _handler;
public StubHttpHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
}
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> 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);
}
}
}