feat(localdb): dedicated h2c listener + MapZbLocalDbSync gated on LocalDb:SyncListenPort
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user