diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs index cfaaab6d9..dcf60bee0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs @@ -43,9 +43,14 @@ public static class DeployApiEndpoints { var configuredKey = config[ConfigKey]; + // The optional body is read in the handler, NOT bound as an inferred `DeployRequest?` parameter + // (lmxopcua#761 gate run, 2026-11-12). An inferred JSON body stamps Accepts(application/json) + // metadata on the endpoint, and for a request with NO Content-Type the routing AcceptsMatcherPolicy + // prefers endpoints without consumes metadata — on a fused admin+driver host that is grpc-dotnet's + // `{unimplementedService}/{unimplementedMethod}` catch-all (mapped by MapOtOpcUaMeshGrpcServices), + // so the documented bodyless POST (docker-dev/deploy.sh) answered 404 and never reached this handler. app.MapPost("/api/deployments", async ( HttpContext http, - DeployRequest? body, IAdminOperationsClient adminOps, CancellationToken ct) => { @@ -57,6 +62,10 @@ public static class DeployApiEndpoints if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey)) return Results.Unauthorized(); + var (body, bodyError) = await ReadOptionalBodyAsync(http.Request, ct); + if (bodyError is not null) + return bodyError; + var createdBy = string.IsNullOrWhiteSpace(body?.CreatedBy) ? "rest-api" : body!.CreatedBy!.Trim(); var result = await adminOps.StartDeploymentAsync(createdBy, ct); return ToResult(result); @@ -151,6 +160,34 @@ public static class DeployApiEndpoints statusCode: StatusCodes.Status500InternalServerError), }; + /// Reads the POST's optional JSON body. No body (absent or zero-length) is the documented + /// headless form and yields (null, null); a non-JSON body answers 415 and malformed JSON 400, as + /// the framework's own body binding did before the body stopped being an inferred parameter. + /// The incoming request. + /// Request cancellation. + /// The parsed body (or null), or the error result to return instead. + internal static async Task<(DeployRequest? Body, IResult? Error)> ReadOptionalBodyAsync( + HttpRequest request, CancellationToken ct) + { + var hasBody = request.ContentLength is > 0 + || (request.ContentLength is null && request.Headers.TransferEncoding.Count > 0); + if (!hasBody) + return (null, null); + + if (!request.HasJsonContentType()) + return (null, Results.StatusCode(StatusCodes.Status415UnsupportedMediaType)); + + try + { + return (await request.ReadFromJsonAsync(ct), null); + } + catch (System.Text.Json.JsonException) + { + return (null, Results.Problem("The request body is not valid JSON.", + statusCode: StatusCodes.Status400BadRequest)); + } + } + /// Optional request body. CreatedBy is recorded as the deployment's initiator /// (audit); defaults to "rest-api" when absent. /// Who/what initiated the deploy (for the audit trail). diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiBodylessPostTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiBodylessPostTests.cs new file mode 100644 index 000000000..816f7ae0c --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiBodylessPostTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Api; +using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// lmxopcua#761 (2026-11-12 gate run): the headless deploy POST must accept a request with NO body, as +/// docker-dev/deploy.sh and the README document. It answered 404 on the rig: an inferred optional JSON +/// body put Accepts(application/json) metadata on the endpoint, and for a request with no Content-Type +/// the AcceptsMatcherPolicy routed to grpc-dotnet's unimplemented-service catch-all instead. The host is +/// composed like a fused admin+driver node (deploy API + mesh gRPC services) and driven over real HTTP, +/// so routing and the matcher policies all take part — the deploy API alone does not reproduce it. +/// +[Trait("Category", "Unit")] +public sealed class DeployApiBodylessPostTests +{ + private const string Key = "test-deploy-key"; + + [Theory] + [InlineData(false, false)] // no body, no Content-Length header at all + [InlineData(true, false)] // Content-Length: 0 + [InlineData(true, true)] // a JSON body (positive control) + public async Task Post_without_a_body_is_accepted(bool contentLengthZero, bool jsonBody) + { + var ct = TestContext.Current.CancellationToken; + var ops = new RecordingAdminOps(); + await using var app = await StartAsync(ops, ct); + using var http = new HttpClient { BaseAddress = new Uri(Address(app)) }; + + using var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments"); + req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, Key); + if (jsonBody) req.Content = JsonContent.Create(new { createdBy = "ci-bot" }); + else if (contentLengthZero) req.Content = new ByteArrayContent([]); + + using var resp = await http.SendAsync(req, ct); + + resp.StatusCode.ShouldBe(HttpStatusCode.Accepted, await resp.Content.ReadAsStringAsync(ct)); + ops.CreatedBy.ShouldBe(jsonBody ? "ci-bot" : "rest-api"); + } + + [Theory] + [InlineData("text/plain", "hello", HttpStatusCode.UnsupportedMediaType)] + [InlineData("application/json", "{not json", HttpStatusCode.BadRequest)] + public async Task Post_with_an_unusable_body_is_refused_without_deploying( + string contentType, string content, HttpStatusCode expected) + { + var ct = TestContext.Current.CancellationToken; + var ops = new RecordingAdminOps(); + await using var app = await StartAsync(ops, ct); + using var http = new HttpClient { BaseAddress = new Uri(Address(app)) }; + + using var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments") + { + Content = new StringContent(content, System.Text.Encoding.UTF8, contentType), + }; + req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, Key); + + using var resp = await http.SendAsync(req, ct); + + resp.StatusCode.ShouldBe(expected); + ops.CreatedBy.ShouldBeNull(); + } + + private static async Task StartAsync(IAdminOperationsClient ops, CancellationToken ct) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddSingleton(ops); + builder.Services.AddAuthorization(); + builder.Services.AddGrpc(); + builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase("deploy-api-bodyless")); + var app = builder.Build(); + app.UseAuthorization(); + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { [DeployApiEndpoints.ConfigKey] = Key }) + .Build(); + app.MapOtOpcUaDeployApi(config); + // Composed exactly as Program.cs does on a fused admin+driver node: the mesh gRPC services are + // mapped AFTER the deploy API, and every MapGrpcService adds grpc-dotnet catch-all routes. + app.MapOtOpcUaMeshGrpcServices(hasAdmin: true, hasDriver: true, syncListenPort: 9001, + configServeGrpcPort: 4055, telemetryListenPort: 4056); + await app.StartAsync(ct); + return app; + } + + private static string Address(WebApplication app) => + app.Services.GetRequiredService().Features.Get()!.Addresses.First(); + + private sealed class RecordingAdminOps : IAdminOperationsClient + { + public string? CreatedBy { get; private set; } + + public Task StartDeploymentAsync(string createdBy, CancellationToken ct) + { + CreatedBy = createdBy; + return Task.FromResult(new StartDeploymentResult( + StartDeploymentOutcome.Accepted, new DeploymentId(Guid.NewGuid()), new RevisionHash("rev"), null, + CorrelationId.NewId())); + } + + public Task AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct) => + throw new NotSupportedException(); + + public Task ShelveAlarmAsync(string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc, + string? comment, CancellationToken ct) => throw new NotSupportedException(); + + public Task AskAsync(object message, CancellationToken ct) => throw new NotSupportedException(); + } +}