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