mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-02 12:03:21 +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.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -43,6 +44,7 @@ public sealed class WorkOrderReconciliationControllerTests
|
||||||
public Guid LastRunId { get; private set; }
|
public Guid LastRunId { get; private set; }
|
||||||
|
|
||||||
public Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
public Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
string reason,
|
string reason,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
@ -53,6 +55,7 @@ public sealed class WorkOrderReconciliationControllerTests
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<WorkOrderReconciliationStatus> GetStatusAsync(
|
public Task<WorkOrderReconciliationStatus> GetStatusAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
Task.FromResult(new WorkOrderReconciliationStatus(
|
Task.FromResult(new WorkOrderReconciliationStatus(
|
||||||
_runId,
|
_runId,
|
||||||
|
|
@ -64,8 +67,5 @@ public sealed class WorkOrderReconciliationControllerTests
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
null));
|
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]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Trigger(CancellationToken cancellationToken)
|
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)
|
if (!result.Queued && result.RunId == Guid.Empty)
|
||||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "disabled" });
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "disabled" });
|
||||||
|
|
||||||
|
|
@ -28,6 +28,6 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Status(CancellationToken cancellationToken) =>
|
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
|
try
|
||||||
{
|
{
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationService>();
|
var runner = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationRunner>();
|
||||||
await service.TriggerAsync(reason, cancellationToken);
|
await runner.TriggerAsync(reason, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|
@ -70,8 +70,8 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var scope = _scopeFactory.CreateAsyncScope();
|
await using var scope = _scopeFactory.CreateAsyncScope();
|
||||||
var service = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationService>();
|
var runner = scope.ServiceProvider.GetRequiredService<IWorkOrderReconciliationRunner>();
|
||||||
await service.RunPendingAsync(cancellationToken);
|
await runner.RunPendingAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -90,40 +90,6 @@ builder.Services.AddHttpClient<
|
||||||
|
|
||||||
builder.Services.Configure<WorkOrderJobsOptions>(
|
builder.Services.Configure<WorkOrderJobsOptions>(
|
||||||
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
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.AddHostedService<WorkOrderReconciliationHostedService>();
|
||||||
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
||||||
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ using FluentValidation;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using SeaHaven.Services.Configuration;
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace SeaHaven.Services.DependencyInjection
|
namespace SeaHaven.Services.DependencyInjection
|
||||||
|
|
@ -18,6 +19,40 @@ namespace SeaHaven.Services.DependencyInjection
|
||||||
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
|
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
|
||||||
services.Configure<VendorDocumentsOptions>(configuration.GetSection(VendorDocumentsOptions.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 assembly = Assembly.GetExecutingAssembly();
|
||||||
|
|
||||||
var allClasses = assembly.GetTypes()
|
var allClasses = assembly.GetTypes()
|
||||||
|
|
@ -40,6 +75,9 @@ namespace SeaHaven.Services.DependencyInjection
|
||||||
|
|
||||||
services.AddValidatorsFromAssembly(assembly);
|
services.AddValidatorsFromAssembly(assembly);
|
||||||
|
|
||||||
|
services.AddScoped<IWorkOrderReconciliationRunner>(
|
||||||
|
sp => (IWorkOrderReconciliationRunner)sp.GetRequiredService<IWorkOrderReconciliationService>());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
@ -9,7 +10,7 @@ using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.Services.Implementation
|
namespace SeaHaven.Services.Implementation
|
||||||
{
|
{
|
||||||
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService
|
public sealed class WorkOrderReconciliationService : IWorkOrderReconciliationService, IWorkOrderReconciliationRunner
|
||||||
{
|
{
|
||||||
private const string Source = "procurement";
|
private const string Source = "procurement";
|
||||||
private readonly IProcurementWorkOrderClient _client;
|
private readonly IProcurementWorkOrderClient _client;
|
||||||
|
|
@ -36,22 +37,19 @@ namespace SeaHaven.Services.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
public async Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
string reason,
|
string reason,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_options.CurrentValue.Enabled)
|
RequireAdmin(user);
|
||||||
return new WorkOrderReconciliationTriggerResult(false, Guid.Empty);
|
return await TriggerAsync(reason, cancellationToken);
|
||||||
|
|
||||||
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<WorkOrderReconciliationStatus> GetStatusAsync(
|
public async Task<WorkOrderReconciliationStatus> GetStatusAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
RequireAdmin(user);
|
||||||
var status = await _jobs.GetStatusAsync(cancellationToken);
|
var status = await _jobs.GetStatusAsync(cancellationToken);
|
||||||
return new WorkOrderReconciliationStatus(
|
return new WorkOrderReconciliationStatus(
|
||||||
status.RunId,
|
status.RunId,
|
||||||
|
|
@ -65,6 +63,20 @@ namespace SeaHaven.Services.Implementation
|
||||||
status.ErrorCode);
|
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)
|
public async Task<bool> RunPendingAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var options = _options.CurrentValue;
|
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(
|
private async Task<(int WorkOrders, int Comments)> ReconcileAsync(
|
||||||
ReconciliationLease lease,
|
ReconciliationLease lease,
|
||||||
WorkOrderReconciliationOptions options,
|
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
|
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
|
public interface IWorkOrderReconciliationService
|
||||||
{
|
{
|
||||||
Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
Task<WorkOrderReconciliationTriggerResult> TriggerAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
string reason,
|
string reason,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<WorkOrderReconciliationStatus> GetStatusAsync(CancellationToken cancellationToken);
|
Task<WorkOrderReconciliationStatus> GetStatusAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
Task<bool> RunPendingAsync(CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record WorkOrderReconciliationTriggerResult(bool Queued, Guid RunId);
|
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