Files
Joseph Doherty 0868613890 mbproxy: add keepalive / connection monitoring
The DL205/DL260 ECOM emits no TCP keepalives, so an idle backend socket
can be silently dropped by a middlebox (switch, firewall, NAT) after
2-5 minutes. Enable OS SO_KEEPALIVE on backend and accepted upstream
sockets, and drive a periodic synthetic FC03 heartbeat on each idle
backend socket so a dead path is detected before a real client request
hits it. Controlled by Connection.Keepalive (ON by default).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:40:54 -04:00

129 lines
5.0 KiB
C#

using Mbproxy.Admin;
using Shouldly;
using Xunit;
namespace Mbproxy.Tests.Admin;
/// <summary>
/// Unit tests for <see cref="StatusHtmlRenderer"/>.
/// All tests are pure: no network, no host, no DI.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusHtmlRendererTests
{
// ── Helpers ───────────────────────────────────────────────────────────────
private static StatusResponse MakeStatus(
IReadOnlyList<PlcStatus>? plcs = null,
int uptimeSeconds = 42,
string version = "1.2.3")
{
var service = new ServiceFields(
UptimeSeconds: uptimeSeconds,
Version: version,
ConfigLastReloadUtc: null,
ConfigReloadCount: 0,
ConfigReloadRejectedCount: 0);
var listeners = new ListenersAggregate(Bound: plcs?.Count ?? 0, Configured: plcs?.Count ?? 0);
return new StatusResponse(service, listeners, plcs ?? []);
}
private static PlcStatus MakePlc(
string name = "PLC-A",
string state = "bound",
string? lastBindError = null,
int recoveryAttempts = 0,
IReadOnlyList<ClientSnapshot>? clients = null)
{
var noClients = (IReadOnlyList<ClientSnapshot>)[];
return new PlcStatus(
Name: name,
Host: "10.0.0.1",
ListenPort: 5020,
Listener: new PlcListenerStatus(state, lastBindError, recoveryAttempts),
Clients: new PlcClientsStatus(clients?.Count ?? 0, clients ?? noClients),
Pdus: new PlcPdusStatus(100, new FcCounts(50, 10, 20, 15, 5), 30, 2, 0),
Backend: new PlcBackendStatus(
ConnectsSuccess: 0, ConnectsFailed: 0,
ExceptionsByCode: new ExceptionCounts(1, 0, 0, 0, 0),
LastRoundTripMs: 3.5,
InFlight: 0, MaxInFlight: 0, TxIdWraps: 0,
DisconnectCascades: 0, QueueDepth: 0,
CoalescedHitCount: 0, CoalescedMissCount: 0,
CoalescedResponseToDeadUpstream: 0,
CacheHitCount: 0, CacheMissCount: 0,
CacheInvalidations: 0, CacheEntryCount: 0, CacheBytes: 0,
BackendHeartbeatsSent: 0, BackendHeartbeatsFailed: 0,
BackendIdleDisconnects: 0),
Bytes: new PlcBytesStatus(1024, 2048));
}
// ── 1. Valid HTML with meta-refresh for a single PLC ─────────────────────
[Fact]
public void Render_OnePlc_ProducesValidHtml_WithMetaRefresh()
{
var status = MakeStatus([MakePlc("PLC-A", "bound")]);
string html = StatusHtmlRenderer.Render(status);
html.ShouldContain("<meta http-equiv=\"refresh\" content=\"5\">");
html.ShouldContain("<!DOCTYPE html>");
html.ShouldContain("</html>");
html.ShouldContain("PLC-A");
html.ShouldContain("bound");
}
// ── 2. Recovering state highlights error ─────────────────────────────────
[Fact]
public void Render_RecoveringPlc_HighlightsState()
{
var plc = MakePlc("PLC-B", "recovering", lastBindError: "Address already in use", recoveryAttempts: 3);
var status = MakeStatus([plc]);
string html = StatusHtmlRenderer.Render(status);
// State should be orange.
html.ShouldContain("class=\"recovering\"");
html.ShouldContain("Address already in use");
html.ShouldContain("attempt 3");
}
// ── 3. Page weight under 50 KB for 54 PLCs ───────────────────────────────
[Fact]
public void Render_PageWeightUnder50KB_For54Plcs()
{
const int plcCount = 54;
// Build 54 realistic PLC rows with 2 clients each.
var plcs = new List<PlcStatus>(plcCount);
for (int i = 0; i < plcCount; i++)
{
var clients = new List<ClientSnapshot>
{
new ClientSnapshot($"10.0.0.{i + 1}:49123", DateTimeOffset.UtcNow, 42),
new ClientSnapshot($"10.0.0.{i + 1}:49124", DateTimeOffset.UtcNow, 17),
};
plcs.Add(MakePlc(
name: $"Line{i / 10 + 1}-Station{i % 10 + 1:D2}",
state: i % 5 == 0 ? "recovering" : "bound",
lastBindError: i % 5 == 0 ? "EADDRINUSE" : null,
recoveryAttempts: i % 5 == 0 ? 2 : 0,
clients: clients));
}
var status = MakeStatus(plcs);
string html = StatusHtmlRenderer.Render(status);
int byteCount = System.Text.Encoding.UTF8.GetByteCount(html);
// Assert ≤ 50 KB.
byteCount.ShouldBeLessThanOrEqualTo(50 * 1024,
$"Page weight {byteCount} bytes exceeds 50 KB limit for {plcCount} PLCs");
}
}