Files
wwtools/mbproxy/tests/Mbproxy.Tests/Proxy/ProxyForwardingTests.cs
T
Joseph Doherty 1a2856526a mbproxy: strip historical phase/wave/plan references from source comments
Comments described the *history* of how the code arrived (phase numbers,
wave IDs, review IDs, dated TODOs) instead of what it does today. That
scaffolding rotted as the codebase evolved. Cleaned 60 source files +
.gitignore; behaviour unchanged (387/387 tests still pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:04:30 -04:00

390 lines
16 KiB
C#

using System.Net;
using System.Net.Sockets;
using Mbproxy;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NModbus;
using Serilog;
using Xunit;
namespace Mbproxy.Tests.Proxy;
/// <summary>
/// End-to-end proxy forwarding tests.
/// Each test:
/// 1. Starts the proxy host in-process, configured with one PLC pointing at the simulator.
/// 2. Connects NModbus to the proxy's listen port.
/// 3. Asserts the proxy forwards bytes transparently (NoopPduPipeline — no BCD rewriting).
/// </summary>
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")]
public sealed class ProxyForwardingTests
{
private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
public ProxyForwardingTests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
{
_sim = sim;
}
// ── 1. FC03 read HR0 — expect 0xCAFE ───────────────────────────────────────────────
[Fact(Timeout = 5_000)]
public async Task Forward_FC03_HR0_Returns_SimulatorRawValue_0xCAFE()
{
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
var (proxyPort, host, cts) = await StartProxyAsync();
await using var _ = new AsyncHostDispose(host, cts);
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 0, numberOfPoints: 1);
Assert.Equal(0xCAFE, regs[0]);
}
// ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ──────────────────────
[Fact(Timeout = 5_000)]
public async Task Forward_FC03_HR1072_Returns_Decoded_1234()
{
// BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured, the proxy
// decodes the raw 0x1234 nibbles and the client receives binary 1234.
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
int proxyPort = PickFreePort();
var config = new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "8080",
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
// Configure address 1072 as a 16-bit BCD tag.
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
["Mbproxy:BcdTags:Global:0:Width"] = "16",
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var host = BuildBcdProxyHost(config);
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await host.StartAsync(startCts.Token);
await using var _ = new AsyncHostDispose(host, cts);
await Task.Delay(150, TestContext.Current.CancellationToken);
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
// BCD decoded: 0x1234 → binary 1234.
Assert.Equal(1234, regs[0]);
}
// ── 2b. FC03 read HR1072 — without BCD configured → raw 0x1234 ─────────────────────
[Fact(Timeout = 5_000)]
public async Task Forward_FC03_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234()
{
// When no BCD tag is configured at address 1072, the proxy passes bytes through
// unmodified. Client receives raw BCD nibbles 0x1234 (= 4660 decimal).
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
var (proxyPort, host, cts) = await StartProxyAsync();
await using var _ = new AsyncHostDispose(host, cts);
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);
// No BCD tag configured: raw BCD nibbles pass through.
Assert.Equal(0x1234, regs[0]);
}
// ── 3. FC06 write single register then read back ────────────────────────────────────
[Fact(Timeout = 5_000)]
public async Task Forward_FC06_WriteHR200_ThenReadBack_RoundTrips()
{
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
var (proxyPort, host, cts) = await StartProxyAsync();
await using var _ = new AsyncHostDispose(host, cts);
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
const ushort writeValue = 0xABCD;
master.WriteSingleRegister(slaveAddress: 1, registerAddress: 200, value: writeValue);
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 200, numberOfPoints: 1);
Assert.Equal(writeValue, regs[0]);
}
// ── 4. FC16 write multiple registers then read back ──────────────────────────────────
[Fact(Timeout = 5_000)]
public async Task Forward_FC16_WriteMultipleHR201_203_ThenReadBack_RoundTrips()
{
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
var (proxyPort, host, cts) = await StartProxyAsync();
await using var _ = new AsyncHostDispose(host, cts);
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
var master = new ModbusFactory().CreateMaster(client);
ushort[] writeValues = [0x0010, 0x0020, 0x0030];
master.WriteMultipleRegisters(slaveAddress: 1, startAddress: 201, data: writeValues);
ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 201, numberOfPoints: 3);
Assert.Equal(writeValues, regs);
}
// ── 5. MBAP TxId preserved end-to-end ────────────────────────────────────────────────
[Fact(Timeout = 5_000)]
public async Task MbapTxId_IsPreservedEndToEnd()
{
// Issue 20 back-to-back FC03 reads with manually-incrementing TxIds (via raw sockets)
// and verify every response carries the matching TxId.
// This verifies no mid-stream frame split causes a parse failure under stress.
if (_sim.SkipReason is not null)
Assert.Skip(_sim.SkipReason);
var (proxyPort, host, cts) = await StartProxyAsync();
await using var _ = new AsyncHostDispose(host, cts);
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.NoDelay = true;
await socket.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
const int count = 20;
byte[] reqBuf = new byte[12]; // FC03 request frame
byte[] rspBuf = new byte[260];
for (ushort txId = 1; txId <= count; txId++)
{
// Build FC03 request: read 1 register at address 0.
// [TxId(2), ProtocolId(2)=0, Length(2)=6, UnitId=1, FC=03, Start(2)=0, Qty(2)=1]
reqBuf[0] = (byte)(txId >> 8);
reqBuf[1] = (byte)(txId & 0xFF);
reqBuf[2] = 0x00; // ProtocolId high
reqBuf[3] = 0x00; // ProtocolId low
reqBuf[4] = 0x00; // Length high
reqBuf[5] = 0x06; // Length low (6 bytes: UnitId + FC + 4 PDU bytes)
reqBuf[6] = 0x01; // UnitId
reqBuf[7] = 0x03; // FC03
reqBuf[8] = 0x00; // Start addr high
reqBuf[9] = 0x00; // Start addr low
reqBuf[10] = 0x00; // Qty high
reqBuf[11] = 0x01; // Qty low
await socket.SendAsync(reqBuf.AsMemory(), SocketFlags.None, TestContext.Current.CancellationToken);
// Read response header (7 bytes), then body.
int read = 0;
while (read < 7)
read += await socket.ReceiveAsync(rspBuf.AsMemory(read, 7 - read), SocketFlags.None, TestContext.Current.CancellationToken);
// Parse response TxId.
ushort rspTxId = (ushort)((rspBuf[0] << 8) | rspBuf[1]);
ushort rspLength = (ushort)((rspBuf[4] << 8) | rspBuf[5]);
Assert.Equal(txId, rspTxId);
// Drain the response body.
int bodyLen = rspLength - 1; // length covers UnitId + PDU; we already read UnitId
if (bodyLen > 0)
{
int bodyRead = 0;
while (bodyRead < bodyLen)
bodyRead += await socket.ReceiveAsync(rspBuf.AsMemory(7 + bodyRead, bodyLen - bodyRead), SocketFlags.None, TestContext.Current.CancellationToken);
}
}
}
// ── 6. Backend connect failure — upstream socket closes cleanly ───────────────────────
[Fact(Timeout = 5_000)]
public async Task BackendConnectFailure_ClosesUpstreamCleanly()
{
// Point the proxy at port 1 on loopback — guaranteed unreachable.
// The multiplexer lazily connects to the backend on the first upstream PDU, so
// we have to actually send a request before the proxy attempts the (failing)
// backend connect that closes the upstream.
const int badBackendPort = 1;
const int backendTimeoutMs = 500; // short timeout for test speed
int proxyPort = PickFreePort();
var config = new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "8080",
[$"Mbproxy:Plcs:0:Name"] = "BadPLC",
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
[$"Mbproxy:Plcs:0:Host"] = "127.0.0.1",
[$"Mbproxy:Plcs:0:Port"] = badBackendPort.ToString(),
["Mbproxy:Connection:BackendConnectTimeoutMs"] = backendTimeoutMs.ToString(),
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var host = BuildProxyHost(config);
await host.StartAsync(cts.Token);
// Give the proxy a moment to bind.
await Task.Delay(150, TestContext.Current.CancellationToken);
using var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
// Send a Modbus request so the multiplexer attempts the backend connect.
byte[] req =
[
0x00, 0x01, // TxId
0x00, 0x00, // ProtocolId
0x00, 0x06, // Length
0x01, // UnitId
0x03, // FC03
0x00, 0x00, // Start
0x00, 0x01, // Qty
];
await client.GetStream().WriteAsync(req, TestContext.Current.CancellationToken);
// Wait up to BackendConnectTimeoutMs + 600ms for the upstream socket to close.
// Polly default retry adds extra time, so we account for it in the deadline.
var deadline = DateTime.UtcNow.AddMilliseconds(backendTimeoutMs + 1500);
bool closed = false;
while (DateTime.UtcNow < deadline)
{
try
{
// A 0-byte receive returns 0 when the remote end closed the socket.
var buf = new byte[1];
int n = await client.GetStream()
.ReadAsync(buf.AsMemory(), TestContext.Current.CancellationToken);
if (n == 0) { closed = true; break; }
}
catch
{
closed = true;
break;
}
await Task.Delay(50, TestContext.Current.CancellationToken);
}
await host.StopAsync(cts.Token);
Assert.True(closed, "Upstream socket should have been closed by the proxy after backend connect failure.");
}
// ── Helpers ──────────────────────────────────────────────────────────────────────────
private async Task<(int proxyPort, IHost host, CancellationTokenSource cts)> StartProxyAsync()
{
int proxyPort = PickFreePort();
var config = new Dictionary<string, string?>
{
["Mbproxy:AdminPort"] = "8080",
[$"Mbproxy:Plcs:0:Name"] = "TestPLC",
[$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
[$"Mbproxy:Plcs:0:Host"] = _sim.Host,
[$"Mbproxy:Plcs:0:Port"] = _sim.Port.ToString(),
["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
};
using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var host = BuildProxyHost(config);
await host.StartAsync(startCts.Token);
// Give the proxy time to bind.
await Task.Delay(150, TestContext.Current.CancellationToken);
var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
return (proxyPort, host, runCts);
}
private static IHost BuildProxyHost(Dictionary<string, string?> config)
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(config);
// Suppress verbose logging in tests.
builder.Services.AddSerilog(
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
dispose: false);
builder.AddMbproxyOptions();
// Tests in ProxyForwardingTests use NoopPduPipeline to verify raw passthrough
// (baseline behaviour independent of BCD configuration).
builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
builder.Services.AddHostedService<ProxyWorker>();
return builder.Build();
}
private static IHost BuildBcdProxyHost(Dictionary<string, string?> config)
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(config);
builder.Services.AddSerilog(
new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
dispose: false);
builder.AddMbproxyOptions();
// BCD rewriter pipeline — used by the BCD-decode tests in this file.
builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
builder.Services.AddHostedService<ProxyWorker>();
return builder.Build();
}
private static int PickFreePort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
int port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
/// <summary>Disposes the host and CTS when the test finishes.</summary>
private sealed class AsyncHostDispose : IAsyncDisposable
{
private readonly IHost _host;
private readonly CancellationTokenSource _cts;
public AsyncHostDispose(IHost host, CancellationTokenSource cts)
{
_host = host;
_cts = cts;
}
public async ValueTask DisposeAsync()
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try { await _host.StopAsync(stopCts.Token); } catch { /* best effort */ }
_host.Dispose();
_cts.Dispose();
}
}
}