diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs index eeababd..7ee4c23 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderReconciliationControllerTests.cs @@ -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 TriggerAsync( + ClaimsPrincipal user, string reason, CancellationToken cancellationToken) { @@ -53,6 +55,7 @@ public sealed class WorkOrderReconciliationControllerTests } public Task GetStatusAsync( + ClaimsPrincipal user, CancellationToken cancellationToken) => Task.FromResult(new WorkOrderReconciliationStatus( _runId, @@ -64,8 +67,5 @@ public sealed class WorkOrderReconciliationControllerTests 0, 0, null)); - - public Task RunPendingAsync(CancellationToken cancellationToken) => - Task.FromResult(false); } } diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderWebhookRouteContractTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderWebhookRouteContractTests.cs new file mode 100644 index 0000000..f7862f5 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderWebhookRouteContractTests.cs @@ -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; + +/// +/// Framework-backed route contract tests that prove the public procurement webhook endpoint is +/// exactly POST api/webhooks/work-orders exposed by +/// , that it is anonymous (no JWT required), and +/// that it is constrained to application/json. 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. +/// +public class WorkOrderWebhookRouteContractTests +{ + private readonly ITestOutputHelper _output; + + public WorkOrderWebhookRouteContractTests(ITestOutputHelper output) + { + _output = output; + } + + private static IReadOnlyList WebhookActions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore() + .AddApplicationPart(typeof(WorkOrderWebhookController).Assembly); + + using var provider = services.BuildServiceProvider(); + var actionProvider = provider.GetRequiredService(); + return actionProvider.ActionDescriptors.Items + .OfType() + .Where(cad => cad.ControllerTypeInfo == typeof(WorkOrderWebhookController)) + .ToList(); + } + + private static IReadOnlyList VerbAndTemplates(ControllerActionDescriptor cad) + { + var template = cad.AttributeRouteInfo?.Template?.Trim('/'); + var methods = (cad.ActionConstraints ?? Array.Empty()) + .OfType() + .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() + .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 endpoints) + { + _output.WriteLine("WorkOrderWebhook public endpoints (verb + route):"); + foreach (var endpoint in endpoints.OrderBy(x => x, StringComparer.Ordinal)) + _output.WriteLine(" " + endpoint); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderReconciliationController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderReconciliationController.cs index 196e7f8..a012d57 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderReconciliationController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderReconciliationController.cs @@ -19,7 +19,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] public async Task 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 Status(CancellationToken cancellationToken) => - Ok(await _service.GetStatusAsync(cancellationToken)); + Ok(await _service.GetStatusAsync(User, cancellationToken)); } } diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs index 2b48809..0e6cafa 100644 --- a/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderReconciliationHostedService.cs @@ -56,8 +56,8 @@ namespace Api.SeaHavenIndustries.HostedServices try { await using var scope = _scopeFactory.CreateAsyncScope(); - var service = scope.ServiceProvider.GetRequiredService(); - await service.TriggerAsync(reason, cancellationToken); + var runner = scope.ServiceProvider.GetRequiredService(); + 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(); - await service.RunPendingAsync(cancellationToken); + var runner = scope.ServiceProvider.GetRequiredService(); + await runner.RunPendingAsync(cancellationToken); } catch (Exception ex) when (ex is not OperationCanceledException) { diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 8b5afc2..d40fb01 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -90,40 +90,6 @@ builder.Services.AddHttpClient< builder.Services.Configure( builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); -builder.Services.AddOptions() - .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() - .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(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName)) diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index aa6f98f..8080130 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -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(configuration.GetSection(VendorPortalOptions.SectionName)); services.Configure(configuration.GetSection(VendorDocumentsOptions.SectionName)); + services.AddOptions() + .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() + .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( + sp => (IWorkOrderReconciliationRunner)sp.GetRequiredService()); + return services; } } diff --git a/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs b/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs index c217ddd..73e6609 100644 --- a/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderReconciliationService.cs @@ -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 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 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 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 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, diff --git a/SeaHaven.Services/Interfaces/IWorkOrderReconciliationRunner.cs b/SeaHaven.Services/Interfaces/IWorkOrderReconciliationRunner.cs new file mode 100644 index 0000000..1bd5c28 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderReconciliationRunner.cs @@ -0,0 +1,17 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// Trusted internal surface for work-order reconciliation, used by hosted services and other + /// server-derived callers. Unlike it does not + /// accept a because callers are already + /// server-side and never expose an HTTP principal. + /// + public interface IWorkOrderReconciliationRunner + { + Task TriggerAsync( + string reason, + CancellationToken cancellationToken); + + Task RunPendingAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderReconciliationService.cs b/SeaHaven.Services/Interfaces/IWorkOrderReconciliationService.cs index 85aed89..efa3550 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderReconciliationService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderReconciliationService.cs @@ -1,14 +1,22 @@ +using System.Security.Claims; + namespace SeaHaven.Services.Interfaces { + /// + /// 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. + /// public interface IWorkOrderReconciliationService { Task TriggerAsync( + ClaimsPrincipal user, string reason, CancellationToken cancellationToken); - Task GetStatusAsync(CancellationToken cancellationToken); - - Task RunPendingAsync(CancellationToken cancellationToken); + Task GetStatusAsync( + ClaimsPrincipal user, + CancellationToken cancellationToken); } public sealed record WorkOrderReconciliationTriggerResult(bool Queued, Guid RunId); diff --git a/SeaHavenIndustries.Tests/WorkOrderReconciliationAuthTests.cs b/SeaHavenIndustries.Tests/WorkOrderReconciliationAuthTests.cs new file mode 100644 index 0000000..e15201c --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderReconciliationAuthTests.cs @@ -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; + +/// +/// Public-interface authorization tests for . 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. +/// +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.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(() => + 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(() => + 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(() => + 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(() => + 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 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 TryAcquirePendingAsync( + DateTimeOffset now, TimeSpan leaseDuration, CancellationToken cancellationToken) => + throw new NotImplementedException(); + + public Task GetStatusAsync(CancellationToken cancellationToken) + { + GetStatusCalls++; + return Task.FromResult(new ReconciliationJobSnapshot( + Guid.NewGuid(), "Idle", null, null, null, null, 0, 0, null)); + } + + public Task 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> GetWorkOrdersAsync( + string? cursor, int limit, CancellationToken cancellationToken) => + throw new NotImplementedException(); + + public Task> GetCommentsAsync( + string workOrderId, string? cursor, int limit, + CancellationToken cancellationToken) => + throw new NotImplementedException(); + } + + private sealed class NoopWorkOrders : IWorkOrderWebhookDataService + { + public Task ApplyAsync( + WorkOrderWebhookMutation mutation, CancellationToken cancellationToken) => + throw new NotImplementedException(); + } + + private sealed class TestOptions : IOptionsMonitor + { + public TestOptions(WorkOrderReconciliationOptions value) => CurrentValue = value; + public WorkOrderReconciliationOptions CurrentValue { get; } + public WorkOrderReconciliationOptions Get(string? name) => CurrentValue; + public IDisposable? OnChange( + Action listener) => null; + } +}