mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
test: prove SH-133 webhook and admin boundaries
This commit is contained in:
parent
e3c37e54b4
commit
8a2e260177
10 changed files with 437 additions and 55 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
215
SeaHavenIndustries.Tests/WorkOrderReconciliationAuthTests.cs
Normal file
215
SeaHavenIndustries.Tests/WorkOrderReconciliationAuthTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue