shoc-backend/Api.SeaHavenIndustries.Tests/WorkOrderWebhookRouteContractTests.cs

117 lines
4.5 KiB
C#
Raw Permalink Normal View History

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