feat: Implement Policy Engine Evaluation Service and Cache with unit tests
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Temp commit to debug
This commit is contained in:
@@ -65,6 +65,7 @@ public sealed class LedgerProjectionReducerTests
|
||||
new JsonObject(),
|
||||
Guid.NewGuid(),
|
||||
null,
|
||||
new JsonArray(),
|
||||
DateTimeOffset.UtcNow,
|
||||
string.Empty);
|
||||
var existingHash = ProjectionHashing.ComputeCycleHash(existing);
|
||||
@@ -112,6 +113,7 @@ public sealed class LedgerProjectionReducerTests
|
||||
labels,
|
||||
Guid.NewGuid(),
|
||||
null,
|
||||
new JsonArray(),
|
||||
DateTimeOffset.UtcNow,
|
||||
string.Empty);
|
||||
existing = existing with { CycleHash = ProjectionHashing.ComputeCycleHash(existing) };
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user