test: prove SH-133 webhook and admin boundaries

This commit is contained in:
Alexandre Brandizzi 2026-07-25 15:44:34 -03:00
parent e3c37e54b4
commit 8a2e260177
10 changed files with 437 additions and 55 deletions

View file

@ -2,6 +2,7 @@ using Api.SeaHavenIndustries.Controllers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
@ -43,6 +44,7 @@ public sealed class WorkOrderReconciliationControllerTests
public Guid LastRunId { get; private set; }
public Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
ClaimsPrincipal user,
string reason,
CancellationToken cancellationToken)
{
@ -53,6 +55,7 @@ public sealed class WorkOrderReconciliationControllerTests
}
public Task<WorkOrderReconciliationStatus> GetStatusAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken) =>
Task.FromResult(new WorkOrderReconciliationStatus(
_runId,
@ -64,8 +67,5 @@ public sealed class WorkOrderReconciliationControllerTests
0,
0,
null));
public Task<bool> RunPendingAsync(CancellationToken cancellationToken) =>
Task.FromResult(false);
}
}

View file

@ -0,0 +1,116 @@
using Api.SeaHavenIndustries.Controllers;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Framework-backed route contract tests that prove the public procurement webhook endpoint is
/// exactly <c>POST api/webhooks/work-orders</c> exposed by
/// <see cref="WorkOrderWebhookController.Receive"/>, that it is anonymous (no JWT required), and
/// that it is constrained to <c>application/json</c>. Routes and metadata are read from the
/// ASP.NET Core action descriptor provider so the EXACT templates and attributes the framework
/// will dispatch are compared.
/// </summary>
public class WorkOrderWebhookRouteContractTests
{
private readonly ITestOutputHelper _output;
public WorkOrderWebhookRouteContractTests(ITestOutputHelper output)
{
_output = output;
}
private static IReadOnlyList<ControllerActionDescriptor> WebhookActions()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore()
.AddApplicationPart(typeof(WorkOrderWebhookController).Assembly);
using var provider = services.BuildServiceProvider();
var actionProvider = provider.GetRequiredService<IActionDescriptorCollectionProvider>();
return actionProvider.ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderWebhookController))
.ToList();
}
private static IReadOnlyList<string> VerbAndTemplates(ControllerActionDescriptor cad)
{
var template = cad.AttributeRouteInfo?.Template?.Trim('/');
var methods = (cad.ActionConstraints ?? Array.Empty<IActionConstraintMetadata>())
.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(m => m.ToUpperInvariant());
return methods.Select(m => $"{m} {template}").ToList();
}
[Fact]
public void WorkOrderWebhook_exposes_exactly_post_api_webhooks_work_orders()
{
var actions = WebhookActions();
actions.Should().ContainSingle(
"the webhook controller must expose exactly one action");
var receive = actions.Single();
receive.ActionName.Should().Be(
nameof(WorkOrderWebhookController.Receive),
"the single webhook action must be Receive");
var endpoints = VerbAndTemplates(receive);
Dump(endpoints);
endpoints.Should().BeEquivalentTo(
new[] { "POST api/webhooks/work-orders" },
"the procurement webhook must be reachable only as POST api/webhooks/work-orders");
}
[Fact]
public void WorkOrderWebhook_Receive_is_anonymous()
{
var receive = WebhookActions().Single(a =>
a.ActionName == nameof(WorkOrderWebhookController.Receive));
var hasAllowAnonymous = receive.ControllerTypeInfo
.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Any()
|| receive.MethodInfo
.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Any();
hasAllowAnonymous.Should().BeTrue(
"the webhook must accept anonymous delivery and must not require a JWT bearer token");
}
[Fact]
public void WorkOrderWebhook_Receive_consumes_application_json_only()
{
var receive = WebhookActions().Single(a =>
a.ActionName == nameof(WorkOrderWebhookController.Receive));
var contentTypes = receive.MethodInfo
.GetCustomAttributes(typeof(ConsumesAttribute), inherit: true)
.Cast<ConsumesAttribute>()
.SelectMany(a => a.ContentTypes)
.Select(c => c.ToString())
.ToList();
contentTypes.Should().BeEquivalentTo(
new[] { "application/json" },
"the webhook must be constrained to application/json payloads");
}
private void Dump(IEnumerable<string> endpoints)
{
_output.WriteLine("WorkOrderWebhook public endpoints (verb + route):");
foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal))
_output.WriteLine(" " + endpoint);
}
}

View file

@ -19,7 +19,7 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost]
public async Task<IActionResult> Trigger(CancellationToken cancellationToken)
{
var result = await _service.TriggerAsync("admin", cancellationToken);
var result = await _service.TriggerAsync(User, "admin", cancellationToken);
if (!result.Queued && result.RunId == Guid.Empty)
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "disabled" });
@ -28,6 +28,6 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet]
public async Task<IActionResult> Status(CancellationToken cancellationToken) =>
Ok(await _service.GetStatusAsync(cancellationToken));
Ok(await _service.GetStatusAsync(User, cancellationToken));
}
}

View file

@ -56,8 +56,8 @@ namespace Api.SeaHavenIndustries.HostedServices
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationService>();
await service.TriggerAsync(reason, cancellationToken);
var runner = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationRunner>();
await runner.TriggerAsync(reason, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@ -70,8 +70,8 @@ namespace Api.SeaHavenIndustries.HostedServices
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationService>();
await service.RunPendingAsync(cancellationToken);
var runner = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationRunner>();
await runner.RunPendingAsync(cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{

View file

@ -90,40 +90,6 @@ builder.Services.AddHttpClient<
builder.Services.Configure<WorkOrderJobsOptions>(
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
builder.Services.AddOptions<SeaHaven.Services.Configuration.WorkOrderWebhookOptions>()
.Bind(builder.Configuration.GetSection(SeaHaven.Services.Configuration.WorkOrderWebhookOptions.SectionName))
.Validate(
o => o.MaxBodyBytes is > 0 and <= 1_048_576
&& o.AllowedClockSkewSeconds is >= 0 and <= 3600
&& o.SecretCacheSeconds is > 0 and <= 3600,
"WorkOrderWebhook size, clock-skew, and cache settings are out of range.")
.Validate(
o => !o.Enabled || (o.KeySecrets.Count > 0
&& o.KeySecrets.All(kvp =>
!string.IsNullOrWhiteSpace(kvp.Key)
&& kvp.Key.Length <= 128
&& !string.IsNullOrWhiteSpace(kvp.Value))),
"WorkOrderWebhook requires a non-empty key ID to secret ARN map when enabled.")
.ValidateOnStart();
builder.Services.AddOptions<SeaHaven.Services.Configuration.WorkOrderReconciliationOptions>()
.Bind(builder.Configuration.GetSection(
SeaHaven.Services.Configuration.WorkOrderReconciliationOptions.SectionName))
.Validate(
o => !o.Enabled
|| (o.BaseUrl == "https://procurement-api.seahaven.com"
&& o.Region == "us-east-1"
&& o.PageSize is >= 1 and <= 500
&& o.MaxPages is >= 1 and <= 100_000
&& o.MaxCursorLength is >= 1 and <= 16_384
&& o.RequestTimeoutSeconds is >= 1 and <= 120
&& o.MaxRetries is >= 0 and <= 8
&& o.RetryBaseDelayMilliseconds is >= 0 and <= 10_000
&& o.MaxResponseBytes is >= 1 and <= 16_777_216
&& o.PollSeconds is >= 1 and <= 300
&& o.ScheduleMinutes is >= 1 and <= 10_080
&& o.LeaseSeconds is >= 30 and <= 3_600),
"WorkOrderReconciliation configuration is invalid.")
.ValidateOnStart();
builder.Services.AddHostedService<WorkOrderReconciliationHostedService>();
builder.Services.AddOptions<WorkOrderIngestOptions>()
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))

View file

@ -2,6 +2,7 @@ using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Interfaces;
using System.Reflection;
namespace SeaHaven.Services.DependencyInjection
@ -18,6 +19,40 @@ namespace SeaHaven.Services.DependencyInjection
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
services.Configure<VendorDocumentsOptions>(configuration.GetSection(VendorDocumentsOptions.SectionName));
services.AddOptions<WorkOrderWebhookOptions>()
.Bind(configuration.GetSection(WorkOrderWebhookOptions.SectionName))
.Validate(
o => o.MaxBodyBytes is > 0 and <= 1_048_576
&& o.AllowedClockSkewSeconds is >= 0 and <= 3600
&& o.SecretCacheSeconds is > 0 and <= 3600,
"WorkOrderWebhook size, clock-skew, and cache settings are out of range.")
.Validate(
o => !o.Enabled || (o.KeySecrets.Count > 0
&& o.KeySecrets.All(kvp =>
!string.IsNullOrWhiteSpace(kvp.Key)
&& kvp.Key.Length <= 128
&& !string.IsNullOrWhiteSpace(kvp.Value))),
"WorkOrderWebhook requires a non-empty key ID to secret ARN map when enabled.")
.ValidateOnStart();
services.AddOptions<WorkOrderReconciliationOptions>()
.Bind(configuration.GetSection(WorkOrderReconciliationOptions.SectionName))
.Validate(
o => !o.Enabled
|| (o.BaseUrl == "https://procurement-api.seahaven.com"
&& o.Region == "us-east-1"
&& o.PageSize is >= 1 and <= 500
&& o.MaxPages is >= 1 and <= 100_000
&& o.MaxCursorLength is >= 1 and <= 16_384
&& o.RequestTimeoutSeconds is >= 1 and <= 120
&& o.MaxRetries is >= 0 and <= 8
&& o.RetryBaseDelayMilliseconds is >= 0 and <= 10_000
&& o.MaxResponseBytes is >= 1 and <= 16_777_216
&& o.PollSeconds is >= 1 and <= 300
&& o.ScheduleMinutes is >= 1 and <= 10_080
&& o.LeaseSeconds is >= 30 and <= 3_600),
"WorkOrderReconciliation configuration is invalid.")
.ValidateOnStart();
var assembly = Assembly.GetExecutingAssembly();
var allClasses = assembly.GetTypes()
@ -40,6 +75,9 @@ namespace SeaHaven.Services.DependencyInjection
services.AddValidatorsFromAssembly(assembly);
services.AddScoped<IWorkOrderReconciliationRunner>(
sp => (IWorkOrderReconciliationRunner)sp.GetRequiredService<IWorkOrderReconciliationService>());
return services;
}
}

View file

@ -1,3 +1,4 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
@ -9,7 +10,7 @@ using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService, IWorkOrderReconciliationRunner
{
private const string Source = "procurement";
private readonly IProcurementWorkOrderClient _client;
@ -36,22 +37,19 @@ namespace SeaHaven.Services.Implementation
}
public async Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
ClaimsPrincipal user,
string reason,
CancellationToken cancellationToken)
{
if (!_options.CurrentValue.Enabled)
return new WorkOrderReconciliationTriggerResult(false, Guid.Empty);
var before = await _jobs.GetStatusAsync(cancellationToken);
var queued = await _jobs.EnqueueAsync(reason, _timeProvider.GetUtcNow(), cancellationToken);
return new WorkOrderReconciliationTriggerResult(
before.State is not ("Pending" or "Running"),
queued.RunId ?? Guid.Empty);
RequireAdmin(user);
return await TriggerAsync(reason, cancellationToken);
}
public async Task<WorkOrderReconciliationStatus> GetStatusAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
RequireAdmin(user);
var status = await _jobs.GetStatusAsync(cancellationToken);
return new WorkOrderReconciliationStatus(
status.RunId,
@ -65,6 +63,20 @@ namespace SeaHaven.Services.Implementation
status.ErrorCode);
}
public async Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
string reason,
CancellationToken cancellationToken)
{
if (!_options.CurrentValue.Enabled)
return new WorkOrderReconciliationTriggerResult(false, Guid.Empty);
var before = await _jobs.GetStatusAsync(cancellationToken);
var queued = await _jobs.EnqueueAsync(reason, _timeProvider.GetUtcNow(), cancellationToken);
return new WorkOrderReconciliationTriggerResult(
before.State is not ("Pending" or "Running"),
queued.RunId ?? Guid.Empty);
}
public async Task<bool> RunPendingAsync(CancellationToken cancellationToken)
{
var options = _options.CurrentValue;
@ -115,6 +127,16 @@ namespace SeaHaven.Services.Implementation
}
}
private static void RequireAdmin(ClaimsPrincipal user)
{
if (user is null
|| !(user.Identity?.IsAuthenticated ?? false)
|| !user.IsInRole("Admin"))
{
throw new UnauthorizedAccessException();
}
}
private async Task<(int WorkOrders, int Comments)> ReconcileAsync(
ReconciliationLease lease,
WorkOrderReconciliationOptions options,

View file

@ -0,0 +1,17 @@
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Trusted internal surface for work-order reconciliation, used by hosted services and other
/// server-derived callers. Unlike <see cref="IWorkOrderReconciliationService"/> it does not
/// accept a <see cref="System.Security.Claims.ClaimsPrincipal"/> because callers are already
/// server-side and never expose an HTTP principal.
/// </summary>
public interface IWorkOrderReconciliationRunner
{
Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
string reason,
CancellationToken cancellationToken);
Task<bool> RunPendingAsync(CancellationToken cancellationToken);
}
}

View file

@ -1,14 +1,22 @@
using System.Security.Claims;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Controller-facing admin surface for work-order reconciliation. Every method enforces an
/// authenticated Admin principal at service entry before any data-service call, independent of
/// any HTTP-layer authorization filter.
/// </summary>
public interface IWorkOrderReconciliationService
{
Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
ClaimsPrincipal user,
string reason,
CancellationToken cancellationToken);
Task<WorkOrderReconciliationStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<bool> RunPendingAsync(CancellationToken cancellationToken);
Task<WorkOrderReconciliationStatus> GetStatusAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken);
}
public sealed record WorkOrderReconciliationTriggerResult(bool Queued, Guid RunId);

View file

@ -0,0 +1,215 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Public-interface authorization tests for <see cref="WorkOrderReconciliationService"/>. These
/// prove the controller-facing admin surface enforces an authenticated Admin principal at service
/// entry (defense in depth beyond the HTTP [Authorize] filter) and rejects non-Admin or
/// unauthenticated principals BEFORE any data-service call. The trusted runner surface is also
/// covered to confirm it is the path that performs the actual data work.
/// </summary>
public sealed class WorkOrderReconciliationAuthTests
{
private static ClaimsPrincipal Unauthenticated() => new(new ClaimsIdentity());
private static ClaimsPrincipal AuthenticatedNonAdmin() =>
new(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "vendor") },
"Test"));
private static ClaimsPrincipal AuthenticatedAdmin() =>
new(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.Name, "admin@seahavenind.com"),
new Claim(ClaimTypes.Role, "Admin")
},
"Test"));
private static WorkOrderReconciliationService CreateService(
SpyJobs jobs,
bool enabled) =>
new(
new NoopClient(),
new NoopWorkOrders(),
jobs,
new TestOptions(new WorkOrderReconciliationOptions
{
Enabled = enabled,
LeaseSeconds = 120
}),
TimeProvider.System,
NullLogger<WorkOrderReconciliationService>.Instance);
[Fact]
public async Task Unauthenticated_trigger_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.TriggerAsync(Unauthenticated(), "admin", CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Non_admin_trigger_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.TriggerAsync(AuthenticatedNonAdmin(), "admin", CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Unauthenticated_status_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.GetStatusAsync(Unauthenticated(), CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Non_admin_status_is_rejected_before_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
service.GetStatusAsync(AuthenticatedNonAdmin(), CancellationToken.None));
AssertDataAccessBlocked(jobs);
}
[Fact]
public async Task Admin_trigger_delegates_to_runner_with_admin_reason()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
var result = await service.TriggerAsync(AuthenticatedAdmin(), "admin", CancellationToken.None);
Assert.True(result.Queued);
Assert.NotEqual(Guid.Empty, result.RunId);
Assert.True(jobs.GetStatusCalls >= 1);
Assert.Equal(1, jobs.EnqueueCalls);
Assert.Equal("admin", jobs.LastEnqueuedReason);
}
[Fact]
public async Task Admin_status_returns_reconciliation_state_after_data_access()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: true);
var status = await service.GetStatusAsync(AuthenticatedAdmin(), CancellationToken.None);
Assert.Equal(1, jobs.GetStatusCalls);
Assert.NotNull(status);
Assert.Equal("Idle", status.State);
}
[Fact]
public async Task Admin_trigger_when_disabled_returns_not_queued_without_enqueue()
{
var jobs = new SpyJobs();
IWorkOrderReconciliationService service = CreateService(jobs, enabled: false);
var result = await service.TriggerAsync(AuthenticatedAdmin(), "admin", CancellationToken.None);
Assert.False(result.Queued);
Assert.Equal(Guid.Empty, result.RunId);
Assert.Equal(0, jobs.EnqueueCalls);
}
private static void AssertDataAccessBlocked(SpyJobs jobs)
{
Assert.Equal(0, jobs.GetStatusCalls);
Assert.Equal(0, jobs.EnqueueCalls);
}
private sealed class SpyJobs : IWorkOrderReconciliationDataService
{
public int GetStatusCalls { get; private set; }
public int EnqueueCalls { get; private set; }
public string? LastEnqueuedReason { get; private set; }
public Task<ReconciliationJobSnapshot> EnqueueAsync(
string reason, DateTimeOffset now, CancellationToken cancellationToken)
{
EnqueueCalls++;
LastEnqueuedReason = reason;
return Task.FromResult(new ReconciliationJobSnapshot(
Guid.NewGuid(), "Pending", reason, now, null, null, 0, 0, null));
}
public Task<ReconciliationLease?> TryAcquirePendingAsync(
DateTimeOffset now, TimeSpan leaseDuration, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task<ReconciliationJobSnapshot> GetStatusAsync(CancellationToken cancellationToken)
{
GetStatusCalls++;
return Task.FromResult(new ReconciliationJobSnapshot(
Guid.NewGuid(), "Idle", null, null, null, null, 0, 0, null));
}
public Task<bool> RenewLeaseAsync(
Guid runId, Guid fenceToken, DateTimeOffset now,
TimeSpan leaseDuration, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task CompleteAsync(
Guid runId, Guid fenceToken, DateTimeOffset completedAt,
int workOrders, int comments, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task FailAsync(
Guid runId, Guid fenceToken, DateTimeOffset completedAt,
string errorCode, CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class NoopClient : IProcurementWorkOrderClient
{
public Task<ProcurementPage<ProcurementWorkOrder>> GetWorkOrdersAsync(
string? cursor, int limit, CancellationToken cancellationToken) =>
throw new NotImplementedException();
public Task<ProcurementPage<ProcurementWorkOrderComment>> GetCommentsAsync(
string workOrderId, string? cursor, int limit,
CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class NoopWorkOrders : IWorkOrderWebhookDataService
{
public Task<WorkOrderWebhookPersistenceResult> ApplyAsync(
WorkOrderWebhookMutation mutation, CancellationToken cancellationToken) =>
throw new NotImplementedException();
}
private sealed class TestOptions : IOptionsMonitor<WorkOrderReconciliationOptions>
{
public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value;
public WorkOrderReconciliationOptions CurrentValue { get; }
public WorkOrderReconciliationOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(
Action<WorkOrderReconciliationOptions, string?> listener) => null;
}
}