0308490aef
Resolves the remaining Minor items from the 2026-05-15 review so the web-UI dashboard work has no open follow-ups: a real-HubConnection end-to-end test for the SignalR feed, stable mbproxy.admin.broadcast.* log-event names, keyboard/aria accessibility on the fleet table, frontend JS hardening (URL-decode guard, NaN guards, shared util.js), reconciler<->capture-registry coverage, throwing-sink and embedded-asset tests, broadcaster polish, and a soft upper bound on AdminPushIntervalMs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
6.7 KiB
C#
161 lines
6.7 KiB
C#
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Sockets;
|
|
using System.Text.Json;
|
|
using Mbproxy.Options;
|
|
using Mbproxy.Proxy;
|
|
using Microsoft.AspNetCore.SignalR.Client;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Serilog;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Mbproxy.Tests.Admin;
|
|
|
|
/// <summary>
|
|
/// End-to-end test of the SignalR live feed: a real <see cref="HubConnection"/> against
|
|
/// the live Kestrel admin host. Exercises the whole push path that no other test covers —
|
|
/// <see cref="Mbproxy.Admin.StatusHub"/> group joins, the <c>MapHub</c> wiring, the
|
|
/// <see cref="Mbproxy.Admin.SignalRStatusPushSink"/>, and the broadcaster loop —
|
|
/// confirming that <c>SubscribeFleet</c> yields a <c>"fleet"</c> message and
|
|
/// <c>SubscribePlc</c> yields a <c>"plc"</c> message.
|
|
/// </summary>
|
|
[Trait("Category", "E2E")]
|
|
public sealed class HubStatusE2ETests
|
|
{
|
|
[Fact(Timeout = 15_000)]
|
|
public async Task SubscribeFleet_ReceivesFleetSnapshot()
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
await using var host = BuildHost(adminPort, proxyPort);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.Host.StartAsync(startCts.Token);
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
await using var connection = new HubConnectionBuilder()
|
|
.WithUrl($"http://127.0.0.1:{adminPort}/hub/status")
|
|
.Build();
|
|
|
|
var fleet = new TaskCompletionSource<JsonElement>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
connection.On<JsonElement>("fleet", payload => fleet.TrySetResult(payload));
|
|
|
|
await connection.StartAsync(TestContext.Current.CancellationToken);
|
|
await connection.InvokeAsync("SubscribeFleet", TestContext.Current.CancellationToken);
|
|
|
|
var snapshot = await fleet.Task.WaitAsync(
|
|
TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken);
|
|
|
|
// The fleet payload is a StatusResponse — assert a couple of its known fields.
|
|
snapshot.TryGetProperty("service", out _).ShouldBeTrue("fleet payload must carry 'service'");
|
|
snapshot.TryGetProperty("plcs", out var plcs).ShouldBeTrue("fleet payload must carry 'plcs'");
|
|
plcs.ValueKind.ShouldBe(JsonValueKind.Array);
|
|
}
|
|
|
|
[Fact(Timeout = 15_000)]
|
|
public async Task SubscribePlc_ReceivesDetailSnapshot()
|
|
{
|
|
int adminPort = PickFreePort();
|
|
int proxyPort = PickFreePort();
|
|
|
|
await using var host = BuildHost(adminPort, proxyPort);
|
|
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await host.Host.StartAsync(startCts.Token);
|
|
await WaitForAdminAsync(adminPort);
|
|
|
|
await using var connection = new HubConnectionBuilder()
|
|
.WithUrl($"http://127.0.0.1:{adminPort}/hub/status")
|
|
.Build();
|
|
|
|
var detail = new TaskCompletionSource<JsonElement>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
connection.On<JsonElement>("plc", payload => detail.TrySetResult(payload));
|
|
|
|
await connection.StartAsync(TestContext.Current.CancellationToken);
|
|
// tabId is a stable per-page-load identifier the real client generates.
|
|
await connection.InvokeAsync("SubscribePlc", "TestPLC", "tab-e2e",
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var snapshot = await detail.Task.WaitAsync(
|
|
TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken);
|
|
|
|
// The detail payload is a PlcDetailResponse { plc, debug }.
|
|
snapshot.TryGetProperty("debug", out var debug).ShouldBeTrue("detail payload must carry 'debug'");
|
|
debug.TryGetProperty("captureArmed", out _).ShouldBeTrue("debug must carry 'captureArmed'");
|
|
debug.TryGetProperty("tags", out var tags).ShouldBeTrue("debug must carry 'tags'");
|
|
tags.ValueKind.ShouldBe(JsonValueKind.Array);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
private static HostHandle BuildHost(int adminPort, int proxyPort)
|
|
{
|
|
var config = new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:AdminPort"] = adminPort.ToString(),
|
|
// Fast push cadence so the subscribed client sees a message promptly.
|
|
["Mbproxy:AdminPushIntervalMs"] = "100",
|
|
["Mbproxy:Plcs:0:Name"] = "TestPLC",
|
|
["Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
|
|
["Mbproxy:Plcs:0:Host"] = "127.0.0.1",
|
|
["Mbproxy:Plcs:0:Port"] = "502",
|
|
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "500",
|
|
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "500",
|
|
};
|
|
|
|
var builder = Host.CreateApplicationBuilder();
|
|
builder.Configuration.AddInMemoryCollection(config);
|
|
builder.Services.AddSerilog(
|
|
new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
|
|
builder.AddMbproxyOptions();
|
|
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
|
|
builder.Services.AddSingleton<ProxyWorker>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
|
|
builder.AddMbproxyAdmin();
|
|
|
|
return new HostHandle(builder.Build());
|
|
}
|
|
|
|
private static async Task WaitForAdminAsync(int adminPort)
|
|
{
|
|
using var http = new HttpClient();
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
while (!cts.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var r = await http.GetAsync($"http://127.0.0.1:{adminPort}/status.json", cts.Token);
|
|
if (r.StatusCode == HttpStatusCode.OK) return;
|
|
}
|
|
catch { }
|
|
await Task.Delay(100, cts.Token).ConfigureAwait(false);
|
|
}
|
|
throw new TimeoutException($"Admin endpoint on port {adminPort} did not start in time.");
|
|
}
|
|
|
|
private static int PickFreePort()
|
|
{
|
|
var l = new TcpListener(IPAddress.Loopback, 0);
|
|
l.Start();
|
|
int port = ((IPEndPoint)l.LocalEndpoint).Port;
|
|
l.Stop();
|
|
return port;
|
|
}
|
|
|
|
private sealed class HostHandle(IHost host) : IAsyncDisposable
|
|
{
|
|
public IHost Host { get; } = host;
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
try { await Host.StopAsync(cts.Token); } catch { }
|
|
Host.Dispose();
|
|
}
|
|
}
|
|
}
|