1a2856526a
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>
390 lines
16 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|