feat(localdb): dedicated h2c listener + MapZbLocalDbSync gated on LocalDb:SyncListenPort

This commit is contained in:
Joseph Doherty
2026-07-20 10:39:34 -04:00
parent 6aff9a8332
commit b9ddf20edd
6 changed files with 986 additions and 0 deletions
@@ -0,0 +1,197 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// LocalDb Phase 1 (Task 5) — the dedicated h2c sync listener and, more importantly, the
/// re-binding of the endpoints the host was already serving.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this file exists.</b> The host binds exclusively via <c>ASPNETCORE_URLS</c> and has
/// no <c>ConfigureKestrel</c> call. Any explicit <c>Listen*</c> makes Kestrel discard that
/// configuration wholesale — it logs "Overriding address(es)" and serves only what was
/// listed explicitly. Getting this wrong unbinds the AdminUI and the deploy API behind
/// Traefik, and it fails silently: the process starts, logs look normal, and nothing answers.
/// </para>
/// <para>
/// These tests drive a minimal <c>WebApplication</c> shaped exactly like <c>Program.cs</c>'s
/// block rather than booting the real host, which needs SQL Server, an Akka mesh and LDAP.
/// What is under test is the Kestrel binding contract, and that is fully reproduced here.
/// </para>
/// </remarks>
public sealed class LocalDbSyncListenerTests
{
// ---------------------------------------------------------------------------------------
// KestrelHttpBinding.Parse
// ---------------------------------------------------------------------------------------
[Fact]
public void Parse_NullOrEmpty_YieldsNoBindings()
{
// "Nothing configured" is a real supported state: Install-Services.ps1 sets ASPNETCORE_URLS
// only for admin nodes, so a driver-only service genuinely has none.
KestrelHttpBinding.Parse(null).ShouldBeEmpty();
KestrelHttpBinding.Parse("").ShouldBeEmpty();
KestrelHttpBinding.Parse(" ").ShouldBeEmpty();
}
[Theory]
[InlineData("http://+:9000", "+", 9000)]
[InlineData("http://*:9000", "*", 9000)]
[InlineData("http://localhost:9000", "localhost", 9000)]
[InlineData("http://0.0.0.0:8080", "0.0.0.0", 8080)]
[InlineData("http://127.0.0.1:5001", "127.0.0.1", 5001)]
public void Parse_SingleUrl_ExtractsHostAndPort(string url, string expectedHost, int expectedPort)
{
// The "+" and "*" wildcards are Kestrel-isms that System.Uri cannot parse — the docker-dev
// rig uses "http://+:9000", so mishandling them would break every rig node.
var bindings = KestrelHttpBinding.Parse(url);
bindings.Count.ShouldBe(1);
bindings[0].Host.ShouldBe(expectedHost);
bindings[0].Port.ShouldBe(expectedPort);
bindings[0].IsSecure.ShouldBeFalse();
}
[Fact]
public void Parse_MultipleUrls_PreservesAllOfThem()
{
// Dropping one of several configured endpoints is the exact silent-unbind failure this
// whole mechanism exists to avoid.
var bindings = KestrelHttpBinding.Parse("http://+:9000;http://localhost:9100");
bindings.Count.ShouldBe(2);
bindings[0].Port.ShouldBe(9000);
bindings[1].Port.ShouldBe(9100);
}
[Fact]
public void Parse_HttpsUrl_IsFlaggedSecure()
{
// Program.cs refuses to take over Kestrel when any endpoint is HTTPS, because replaying
// certificate configuration is not modelled here. This flag is what drives that refusal.
KestrelHttpBinding.Parse("https://+:443")[0].IsSecure.ShouldBeTrue();
}
[Fact]
public void Parse_MalformedEntry_IsSkipped_NotThrown()
{
// A typo'd URL must not take the process down at startup.
KestrelHttpBinding.Parse("not a url;http://+:9000").ShouldHaveSingleItem().Port.ShouldBe(9000);
}
// ---------------------------------------------------------------------------------------
// The real Kestrel contract
// ---------------------------------------------------------------------------------------
[Fact]
public async Task ExplicitListen_ReBindsTheExistingSurface_AndAddsAnH2cListener()
{
// THE test. Both ports must answer simultaneously: the re-bound HTTP/1.1 surface (proving
// the ASPNETCORE_URLS override was compensated for) and the HTTP/2-only sync port (proving
// prior-knowledge h2c works, which a cleartext Http1AndHttp2 endpoint cannot do).
var httpPort = GetFreePort();
var syncPort = GetFreePort();
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(LocalDbSyncListenerTests).Assembly.GetName().Name!;
builder.Services.AddGrpc();
// Exactly the shape Program.cs applies.
foreach (var binding in KestrelHttpBinding.Parse($"http://localhost:{httpPort}"))
{
var captured = binding;
builder.WebHost.ConfigureKestrel(k => captured.Apply(k));
}
builder.WebHost.ConfigureKestrel(k =>
k.ListenAnyIP(syncPort, o => o.Protocols = HttpProtocols.Http2));
await using var app = builder.Build();
app.MapGet("/healthz", () => "ok");
await app.StartAsync(TestContext.Current.CancellationToken);
try
{
using var http1 = new HttpClient();
var body = await http1.GetStringAsync(
$"http://localhost:{httpPort}/healthz", TestContext.Current.CancellationToken);
body.ShouldBe("ok");
// Prior-knowledge h2c against the sync port. A 404 is a perfectly good result — it
// proves the HTTP/2 connection was established and the request was routed, which is
// the only thing in question here.
using var http2 = new HttpClient
{
DefaultRequestVersion = HttpVersion.Version20,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using var response = await http2.GetAsync(
$"http://localhost:{syncPort}/", TestContext.Current.CancellationToken);
response.Version.ShouldBe(HttpVersion.Version20);
}
finally
{
await app.StopAsync(TestContext.Current.CancellationToken);
}
}
[Fact]
public async Task ExplicitListen_WithoutReBinding_SilentlyDiscardsTheConfiguredUrls()
{
// POSITIVE CONTROL for the whole task. If Kestrel did NOT override configured URLs, the
// re-binding above would be pointless ceremony. This pins the actual behaviour: a single
// explicit Listen* call makes the ASPNETCORE_URLS port stop answering entirely — no
// exception, no failed startup, just silence. That is the regression being guarded against.
var configuredPort = GetFreePort();
var explicitPort = GetFreePort();
var builder = WebApplication.CreateBuilder();
builder.Environment.ApplicationName = typeof(LocalDbSyncListenerTests).Assembly.GetName().Name!;
builder.WebHost.UseUrls($"http://localhost:{configuredPort}");
// Deliberately NOT re-binding configuredPort — this is the mistake being demonstrated.
builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(explicitPort));
await using var app = builder.Build();
app.MapGet("/healthz", () => "ok");
await app.StartAsync(TestContext.Current.CancellationToken);
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
// The explicitly listed port serves.
(await client.GetStringAsync(
$"http://localhost:{explicitPort}/healthz",
TestContext.Current.CancellationToken)).ShouldBe("ok");
// The configured one does not — nothing is listening there at all.
await Should.ThrowAsync<HttpRequestException>(() => client.GetStringAsync(
$"http://localhost:{configuredPort}/healthz",
TestContext.Current.CancellationToken));
}
finally
{
await app.StopAsync(TestContext.Current.CancellationToken);
}
}
private static int GetFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
}