feat: Implement Policy Engine Evaluation Service and Cache with unit tests
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

Temp commit to debug
This commit is contained in:
master
2025-11-05 07:35:53 +00:00
parent 40e7f827da
commit 9253620833
125 changed files with 18735 additions and 17215 deletions

View File

@@ -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>();

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using StellaOps.Findings.Ledger.Domain;

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Findings.Ledger.Tests")]

View File

@@ -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>

View File

@@ -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. |

View File

@@ -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) };

View File

@@ -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);
}
}
}