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:
@@ -120,7 +120,11 @@ builder.Services.AddSingleton<ILedgerEventRepository, PostgresLedgerEventReposit
|
||||
builder.Services.AddSingleton<IMerkleAnchorScheduler, PostgresMerkleAnchorScheduler>();
|
||||
builder.Services.AddSingleton<ILedgerEventStream, PostgresLedgerEventStream>();
|
||||
builder.Services.AddSingleton<IFindingProjectionRepository, PostgresFindingProjectionRepository>();
|
||||
builder.Services.AddSingleton<IPolicyEvaluationService, InlinePolicyEvaluationService>();
|
||||
builder.Services.AddHttpClient("ledger-policy-engine");
|
||||
builder.Services.AddSingleton<InlinePolicyEvaluationService>();
|
||||
builder.Services.AddSingleton<PolicyEvaluationCache>();
|
||||
builder.Services.AddSingleton<PolicyEngineEvaluationService>();
|
||||
builder.Services.AddSingleton<IPolicyEvaluationService>(sp => sp.GetRequiredService<PolicyEngineEvaluationService>());
|
||||
builder.Services.AddSingleton<ILedgerEventWriteService, LedgerEventWriteService>();
|
||||
builder.Services.AddHostedService<LedgerMerkleAnchorWorker>();
|
||||
builder.Services.AddHostedService<LedgerProjectionWorker>();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
|
||||
internal sealed class PolicyEngineEvaluationService : IPolicyEvaluationService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
private readonly HttpClient? _httpClient;
|
||||
private readonly InlinePolicyEvaluationService _fallback;
|
||||
private readonly PolicyEvaluationCache _cache;
|
||||
private readonly LedgerServiceOptions.PolicyEngineOptions _options;
|
||||
private readonly ILogger<PolicyEngineEvaluationService> _logger;
|
||||
|
||||
public PolicyEngineEvaluationService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
InlinePolicyEvaluationService fallback,
|
||||
PolicyEvaluationCache cache,
|
||||
IOptions<LedgerServiceOptions> options,
|
||||
ILogger<PolicyEngineEvaluationService> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpClientFactory);
|
||||
_fallback = fallback ?? throw new ArgumentNullException(nameof(fallback));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.PolicyEngine;
|
||||
|
||||
if (_options.BaseAddress is not null)
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("ledger-policy-engine");
|
||||
client.BaseAddress = _options.BaseAddress;
|
||||
client.Timeout = _options.RequestTimeout;
|
||||
_httpClient = client;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PolicyEvaluationResult> EvaluateAsync(
|
||||
LedgerEventRecord record,
|
||||
FindingProjection? existingProjection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (record is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(record));
|
||||
}
|
||||
|
||||
if (_httpClient is null)
|
||||
{
|
||||
return await _fallback.EvaluateAsync(record, existingProjection, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var cacheKey = CreateCacheKey(record, existingProjection);
|
||||
if (_cache.TryGet(cacheKey, out var cachedResult))
|
||||
{
|
||||
return cachedResult;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var requestBody = CreateRequest(record, existingProjection);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "policy/eval/batch")
|
||||
{
|
||||
Content = JsonContent.Create(requestBody, options: SerializerOptions)
|
||||
};
|
||||
|
||||
request.Headers.TryAddWithoutValidation(_options.TenantHeaderName, record.TenantId);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Policy engine evaluation request failed with status {StatusCode}. Falling back to inline evaluator.",
|
||||
response.StatusCode);
|
||||
return await _fallback.EvaluateAsync(record, existingProjection, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var result = ParseResponse(document.RootElement, record);
|
||||
_cache.Set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Policy engine evaluation failed; falling back to inline evaluation.");
|
||||
return await _fallback.EvaluateAsync(record, existingProjection, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static PolicyEvaluationCacheKey CreateCacheKey(LedgerEventRecord record, FindingProjection? existingProjection)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var eventBytes = JsonSerializer.SerializeToUtf8Bytes(record.EventBody, SerializerOptions);
|
||||
var hashBytes = sha.ComputeHash(eventBytes);
|
||||
var projectionHash = existingProjection?.CycleHash;
|
||||
return new PolicyEvaluationCacheKey(record.TenantId, record.PolicyVersion, record.EventId, projectionHash ?? Convert.ToHexString(hashBytes));
|
||||
}
|
||||
|
||||
private static JsonObject CreateRequest(LedgerEventRecord record, FindingProjection? existingProjection)
|
||||
{
|
||||
var batchItem = new JsonObject
|
||||
{
|
||||
["findingId"] = record.FindingId,
|
||||
["eventId"] = record.EventId.ToString(),
|
||||
["event"] = record.EventBody.DeepClone()
|
||||
};
|
||||
|
||||
if (existingProjection is not null)
|
||||
{
|
||||
batchItem["currentProjection"] = new JsonObject
|
||||
{
|
||||
["status"] = existingProjection.Status,
|
||||
["severity"] = existingProjection.Severity,
|
||||
["labels"] = existingProjection.Labels.DeepClone(),
|
||||
["explainRef"] = existingProjection.ExplainRef,
|
||||
["rationale"] = existingProjection.PolicyRationale.DeepClone()
|
||||
};
|
||||
}
|
||||
|
||||
var request = new JsonObject
|
||||
{
|
||||
["tenantId"] = record.TenantId,
|
||||
["policyVersion"] = record.PolicyVersion,
|
||||
["items"] = new JsonArray { batchItem }
|
||||
};
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static PolicyEvaluationResult ParseResponse(JsonElement response, LedgerEventRecord record)
|
||||
{
|
||||
if (!response.TryGetProperty("items", out var itemsElement) || itemsElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new InvalidOperationException("Policy engine response missing 'items' array.");
|
||||
}
|
||||
|
||||
foreach (var item in itemsElement.EnumerateArray())
|
||||
{
|
||||
var findingId = item.GetPropertyOrDefault("findingId")?.GetString();
|
||||
if (!string.Equals(findingId, record.FindingId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var status = item.GetPropertyOrDefault("status")?.GetString();
|
||||
decimal? severity = null;
|
||||
var severityElement = item.GetPropertyOrDefault("severity");
|
||||
if (severityElement.HasValue && severityElement.Value.ValueKind == JsonValueKind.Number && severityElement.Value.TryGetDecimal(out var decimalSeverity))
|
||||
{
|
||||
severity = decimalSeverity;
|
||||
}
|
||||
|
||||
var labelsNode = new JsonObject();
|
||||
var labelsElement = item.GetPropertyOrDefault("labels");
|
||||
if (labelsElement.HasValue && labelsElement.Value.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
labelsNode = (JsonObject)labelsElement.Value.ToJsonNode()!;
|
||||
}
|
||||
var explainRef = item.GetPropertyOrDefault("explainRef")?.GetString();
|
||||
|
||||
JsonArray rationale;
|
||||
var rationaleElement = item.GetPropertyOrDefault("rationale");
|
||||
if (!rationaleElement.HasValue || rationaleElement.Value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
rationale = new JsonArray();
|
||||
if (!string.IsNullOrWhiteSpace(explainRef))
|
||||
{
|
||||
rationale.Add(explainRef);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
rationale = (JsonArray)rationaleElement.Value.ToJsonNode()!;
|
||||
}
|
||||
|
||||
return new PolicyEvaluationResult(status, severity, labelsNode, explainRef, rationale);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Policy engine response did not include evaluation for requested finding.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class JsonElementExtensions
|
||||
{
|
||||
public static JsonElement? GetPropertyOrDefault(this JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return element.TryGetProperty(propertyName, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public static JsonNode? ToJsonNode(this JsonElement element)
|
||||
{
|
||||
return JsonNode.Parse(element.GetRawText());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Findings.Ledger.Domain;
|
||||
using StellaOps.Findings.Ledger.Options;
|
||||
|
||||
namespace StellaOps.Findings.Ledger.Infrastructure.Policy;
|
||||
|
||||
internal sealed record PolicyEvaluationCacheKey(string TenantId, string PolicyVersion, Guid EventId, string? ProjectionHash);
|
||||
|
||||
internal sealed class PolicyEvaluationCache : IDisposable
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PolicyEvaluationCache> _logger;
|
||||
private bool _disposed;
|
||||
|
||||
public PolicyEvaluationCache(
|
||||
LedgerServiceOptions.PolicyEngineOptions options,
|
||||
ILogger<PolicyEvaluationCache> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_cache = new MemoryCache(new MemoryCacheOptions
|
||||
{
|
||||
SizeLimit = options.Cache.SizeLimit
|
||||
});
|
||||
|
||||
EntryLifetime = options.Cache.EntryLifetime;
|
||||
}
|
||||
|
||||
public TimeSpan EntryLifetime { get; }
|
||||
|
||||
public bool TryGet(PolicyEvaluationCacheKey key, out PolicyEvaluationResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
|
||||
if (_cache.TryGetValue(key, out PolicyEvaluationResult? cached) && cached is not null)
|
||||
{
|
||||
_logger.LogTrace("Policy evaluation cache hit for tenant {Tenant} finding {Finding} policy {Policy}", key.TenantId, key.EventId, key.PolicyVersion);
|
||||
result = Clone(cached);
|
||||
return true;
|
||||
}
|
||||
|
||||
result = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Set(PolicyEvaluationCacheKey key, PolicyEvaluationResult value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var entryOptions = new MemoryCacheEntryOptions()
|
||||
.SetSize(1)
|
||||
.SetAbsoluteExpiration(EntryLifetime);
|
||||
|
||||
_cache.Set(key, Clone(value), entryOptions);
|
||||
}
|
||||
|
||||
private static PolicyEvaluationResult Clone(PolicyEvaluationResult result)
|
||||
{
|
||||
var labelsClone = result.Labels is null ? new JsonObject() : (JsonObject)result.Labels.DeepClone();
|
||||
var rationaleClone = result.Rationale is null ? new JsonArray() : CloneArray(result.Rationale);
|
||||
|
||||
return new PolicyEvaluationResult(
|
||||
result.Status,
|
||||
result.Severity,
|
||||
labelsClone,
|
||||
result.ExplainRef,
|
||||
rationaleClone);
|
||||
}
|
||||
|
||||
private static JsonArray CloneArray(JsonArray source)
|
||||
{
|
||||
var clone = new JsonArray();
|
||||
foreach (var item in source)
|
||||
{
|
||||
clone.Add(item?.DeepClone());
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cache.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ public sealed class LedgerServiceOptions
|
||||
|
||||
public ProjectionOptions Projection { get; init; } = new();
|
||||
|
||||
public PolicyEngineOptions PolicyEngine { get; init; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Database.ConnectionString))
|
||||
@@ -43,6 +45,8 @@ public sealed class LedgerServiceOptions
|
||||
{
|
||||
throw new InvalidOperationException("Projection idle delay must be greater than zero.");
|
||||
}
|
||||
|
||||
PolicyEngine.Validate();
|
||||
}
|
||||
|
||||
public sealed class DatabaseOptions
|
||||
@@ -90,4 +94,53 @@ public sealed class LedgerServiceOptions
|
||||
|
||||
public TimeSpan IdleDelay { get; set; } = DefaultIdleDelay;
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineOptions
|
||||
{
|
||||
private const int DefaultCacheSizeLimit = 2048;
|
||||
private static readonly TimeSpan DefaultCacheEntryLifetime = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
public Uri? BaseAddress { get; set; }
|
||||
|
||||
public string TenantHeaderName { get; set; } = "X-Stella-Tenant";
|
||||
|
||||
public TimeSpan RequestTimeout { get; set; } = DefaultRequestTimeout;
|
||||
|
||||
public PolicyEngineCacheOptions Cache { get; init; } = new();
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (BaseAddress is not null && !BaseAddress.IsAbsoluteUri)
|
||||
{
|
||||
throw new InvalidOperationException("Policy engine base address must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (Cache.SizeLimit <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Policy engine cache size limit must be greater than zero.");
|
||||
}
|
||||
|
||||
if (Cache.EntryLifetime <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Policy engine cache entry lifetime must be greater than zero.");
|
||||
}
|
||||
|
||||
if (RequestTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException("Policy engine request timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PolicyEngineCacheOptions
|
||||
{
|
||||
private const int DefaultCacheSizeLimit = 2048;
|
||||
private static readonly TimeSpan DefaultCacheEntryLifetime = TimeSpan.FromMinutes(30);
|
||||
|
||||
public int SizeLimit { get; set; } = DefaultCacheSizeLimit;
|
||||
|
||||
public TimeSpan EntryLifetime { get; set; } = DefaultCacheEntryLifetime;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Findings.Ledger.Tests")]
|
||||
@@ -14,6 +14,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
| LEDGER-29-001 | DONE (2025-11-03) | Findings Ledger Guild | AUTH-POLICY-27-001 | Design ledger & projection schemas (tables/indexes), canonical JSON format, hashing strategy, and migrations. Publish schema doc + fixtures.<br>2025-11-03: Initial PostgreSQL migration added with partitions/enums, fixtures seeded with canonical hashes, schema doc aligned. | Schemas committed; migrations generated; hashing documented; fixtures seeded for CI. |
|
||||
| LEDGER-29-002 | DONE (2025-11-03) | Findings Ledger Guild | LEDGER-29-001 | Implement ledger write API (`POST /vuln/ledger/events`) with validation, idempotency, hash chaining, and Merkle root computation job.<br>2025-11-03: Minimal web service scaffolded with canonical hashing, in-memory repository, Merkle scheduler stub, request/response contracts, and unit tests for hashing + conflict flows. | Events persisted with chained hashes; Merkle job emits anchors; unit/integration tests cover happy/pathological cases. |
|
||||
| LEDGER-29-003 | DONE (2025-11-03) | Findings Ledger Guild, Scheduler Guild | LEDGER-29-001 | Build projector worker that derives `findings_projection` rows from ledger events + policy determinations; ensure idempotent replay keyed by `(tenant,finding_id,policy_version)`. | Postgres-backed projector worker and reducers landed with replay checkpointing, fixtures, and tests. |
|
||||
| LEDGER-29-004 | DOING (2025-11-03) | Findings Ledger Guild, Policy Guild | LEDGER-29-003, POLICY-ENGINE-27-001 | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.<br>2025-11-04: Projection reducer now consumes policy evaluation output with rationale arrays; Postgres migration + fixtures/tests updated, awaiting Policy Engine API wiring for batch fetch. | Projector fetches determinations efficiently; rationale stored for UI; regression tests cover version switches. |
|
||||
| LEDGER-29-004 | DONE (2025-11-04) | Findings Ledger Guild, Policy Guild | LEDGER-29-003, POLICY-ENGINE-27-001 | Integrate Policy Engine batch evaluation (baseline + simulate) with projector; cache rationale references.<br>2025-11-04: Remote evaluation service wired via typed HttpClient, cache, and fallback inline evaluator; `/api/policy/eval/batch` documented; `policy_rationale` persisted with deterministic hashing; ledger tests `dotnet test src/Findings/__Tests/StellaOps.Findings.Ledger.Tests/StellaOps.Findings.Ledger.Tests.csproj --no-restore` green. | Projector fetches determinations efficiently; rationale stored for UI; regression tests cover version switches. |
|
||||
| LEDGER-29-005 | TODO | Findings Ledger Guild | LEDGER-29-003 | Implement workflow mutation handlers (assign, comment, accept-risk, target-fix, verify-fix, reopen) producing ledger events with validation and attachments metadata. | API endpoints enforce business rules; attachments metadata stored; tests cover state machine transitions. |
|
||||
| LEDGER-29-006 | TODO | Findings Ledger Guild, Security Guild | LEDGER-29-002 | Integrate attachment encryption (KMS envelope), signed URL issuance, CSRF protection hooks for Console. | Attachments encrypted and accessible via signed URLs; security tests verify expiry + scope. |
|
||||
| LEDGER-29-007 | TODO | Findings Ledger Guild, Observability Guild | LEDGER-29-002..005 | Instrument metrics (`ledger_write_latency`, `projection_lag_seconds`, `ledger_events_total`), structured logs, and Merkle anchoring alerts; publish dashboards. | Metrics/traces emitted; dashboards live; alert thresholds documented. |
|
||||
|
||||
@@ -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