554b05d28c
Reviewed the new SignalR dashboard and fixed its two top findings: a stored XSS on the connection-detail page (unescaped tag name / direction / timestamp rendered into innerHTML) and FC03/FC04 cache hits bypassing the debug-view capture, which left cached tags frozen while their age climbed. Also adds an optional human-friendly Name to BCD tags surfaced on the debug view, and loads the real fleet config from tags.txt (12 named BCD tags, PLC Z28061) so the published appsettings.json is deploy-ready. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
10 KiB
C#
234 lines
10 KiB
C#
using Mbproxy.Options;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace Mbproxy.Tests.Options;
|
|
|
|
/// <summary>
|
|
/// Verifies that <see cref="MbproxyOptions"/> binds correctly from
|
|
/// <see cref="IConfiguration"/> and that schema-level validation fires.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class MbproxyOptionsBindingTests
|
|
{
|
|
// -------------------------------------------------------------------------
|
|
// Helper: build MbproxyOptions directly from an in-memory configuration.
|
|
// We configure the DI container with IConfiguration so BindConfiguration works.
|
|
// -------------------------------------------------------------------------
|
|
private static MbproxyOptions BindOptions(Dictionary<string, string?> values)
|
|
{
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(values)
|
|
.Build();
|
|
|
|
var services = new ServiceCollection();
|
|
// Register IConfiguration so BindConfiguration("Mbproxy") can resolve it.
|
|
services.AddSingleton<IConfiguration>(config);
|
|
services
|
|
.AddOptions<MbproxyOptions>()
|
|
.BindConfiguration("Mbproxy");
|
|
|
|
var provider = services.BuildServiceProvider();
|
|
return provider.GetRequiredService<IOptionsMonitor<MbproxyOptions>>().CurrentValue;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 1 — global BCD tags bind correctly
|
|
// -------------------------------------------------------------------------
|
|
[Fact]
|
|
public void MbproxyOptionsBinding_BindsGlobalBcdTags_From_appsettings()
|
|
{
|
|
var options = BindOptions(new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
|
["Mbproxy:BcdTags:Global:0:Width"] = "16",
|
|
["Mbproxy:BcdTags:Global:0:Name"] = "Left AirSP",
|
|
["Mbproxy:BcdTags:Global:1:Address"] = "1080",
|
|
["Mbproxy:BcdTags:Global:1:Width"] = "32",
|
|
});
|
|
|
|
options.BcdTags.Global.Count.ShouldBe(2);
|
|
options.BcdTags.Global[0].Address.ShouldBe((ushort)1072);
|
|
options.BcdTags.Global[0].Width.ShouldBe((byte)16);
|
|
options.BcdTags.Global[0].Name.ShouldBe("Left AirSP");
|
|
options.BcdTags.Global[1].Address.ShouldBe((ushort)1080);
|
|
options.BcdTags.Global[1].Width.ShouldBe((byte)32);
|
|
options.BcdTags.Global[1].Name.ShouldBeNull("Name is optional — an omitted entry binds to null");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 2 — per-PLC Add and Remove override lists bind correctly
|
|
// -------------------------------------------------------------------------
|
|
[Fact]
|
|
public void MbproxyOptionsBinding_BindsPerPlcAddAndRemove()
|
|
{
|
|
var options = BindOptions(new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:Plcs:0:Name"] = "Line1-Mixer",
|
|
["Mbproxy:Plcs:0:ListenPort"] = "5020",
|
|
["Mbproxy:Plcs:0:Host"] = "10.0.1.1",
|
|
["Mbproxy:Plcs:0:BcdTags:Add:0:Address"] = "1200",
|
|
["Mbproxy:Plcs:0:BcdTags:Add:0:Width"] = "32",
|
|
["Mbproxy:Plcs:0:BcdTags:Remove:0"] = "1080",
|
|
});
|
|
|
|
options.Plcs.Count.ShouldBe(1);
|
|
var plc = options.Plcs[0];
|
|
plc.Name.ShouldBe("Line1-Mixer");
|
|
plc.ListenPort.ShouldBe(5020);
|
|
plc.Host.ShouldBe("10.0.1.1");
|
|
plc.BcdTags.ShouldNotBeNull();
|
|
plc.BcdTags!.Add.Count.ShouldBe(1);
|
|
plc.BcdTags.Add[0].Address.ShouldBe((ushort)1200);
|
|
plc.BcdTags.Add[0].Width.ShouldBe((byte)32);
|
|
plc.BcdTags.Remove.Count.ShouldBe(1);
|
|
plc.BcdTags.Remove[0].ShouldBe((ushort)1080);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 3 — defaults apply when the "Mbproxy" section is absent
|
|
// -------------------------------------------------------------------------
|
|
[Fact]
|
|
public void MbproxyOptionsBinding_DefaultsAreApplied_WhenSectionMissing()
|
|
{
|
|
var options = BindOptions(new Dictionary<string, string?>());
|
|
|
|
options.AdminPort.ShouldBe(8080);
|
|
options.Connection.BackendConnectTimeoutMs.ShouldBe(3000);
|
|
options.Connection.BackendRequestTimeoutMs.ShouldBe(3000);
|
|
options.Resilience.BackendConnect.MaxAttempts.ShouldBe(3);
|
|
options.Resilience.BackendConnect.BackoffMs.ShouldBe([100, 500, 2000]);
|
|
options.Resilience.ListenerRecovery.SteadyStateMs.ShouldBe(30000);
|
|
options.Resilience.ListenerRecovery.InitialBackoffMs.ShouldBe([1000, 2000, 5000, 15000, 30000]);
|
|
options.Plcs.ShouldBeEmpty();
|
|
options.BcdTags.Global.ShouldBeEmpty();
|
|
|
|
// Keepalive defaults — enabled, with the documented timer values.
|
|
options.Connection.Keepalive.Enabled.ShouldBeTrue();
|
|
options.Connection.Keepalive.TcpIdleTimeMs.ShouldBe(30000);
|
|
options.Connection.Keepalive.TcpProbeIntervalMs.ShouldBe(5000);
|
|
options.Connection.Keepalive.TcpProbeCount.ShouldBe(4);
|
|
options.Connection.Keepalive.BackendHeartbeatIdleMs.ShouldBe(30000);
|
|
options.Connection.Keepalive.BackendHeartbeatProbeAddress.ShouldBe(0);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 5 — the Connection:Keepalive block binds from configuration
|
|
// -------------------------------------------------------------------------
|
|
[Fact]
|
|
public void MbproxyOptionsBinding_BindsKeepaliveBlock()
|
|
{
|
|
var options = BindOptions(new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:Connection:Keepalive:Enabled"] = "false",
|
|
["Mbproxy:Connection:Keepalive:TcpIdleTimeMs"] = "45000",
|
|
["Mbproxy:Connection:Keepalive:TcpProbeIntervalMs"] = "7000",
|
|
["Mbproxy:Connection:Keepalive:TcpProbeCount"] = "6",
|
|
["Mbproxy:Connection:Keepalive:BackendHeartbeatIdleMs"] = "20000",
|
|
["Mbproxy:Connection:Keepalive:BackendHeartbeatProbeAddress"] = "1024",
|
|
});
|
|
|
|
var ka = options.Connection.Keepalive;
|
|
ka.Enabled.ShouldBeFalse();
|
|
ka.TcpIdleTimeMs.ShouldBe(45000);
|
|
ka.TcpProbeIntervalMs.ShouldBe(7000);
|
|
ka.TcpProbeCount.ShouldBe(6);
|
|
ka.BackendHeartbeatIdleMs.ShouldBe(20000);
|
|
ka.BackendHeartbeatProbeAddress.ShouldBe(1024);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 4 — validator rejects Width != 16 && != 32 (schema-level only)
|
|
// -------------------------------------------------------------------------
|
|
[Fact]
|
|
public void MbproxyOptionsBinding_RejectsInvalidWidth()
|
|
{
|
|
// Build options with an invalid Width=8.
|
|
var config = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:BcdTags:Global:0:Address"] = "1072",
|
|
["Mbproxy:BcdTags:Global:0:Width"] = "8", // invalid: not 16 or 32
|
|
})
|
|
.Build();
|
|
|
|
// Get<T> creates a new instance and populates it — works with init-only properties.
|
|
var options = config.GetSection("Mbproxy").Get<MbproxyOptions>() ?? new MbproxyOptions();
|
|
|
|
// Call the validator directly to check schema-level rejection.
|
|
var validator = new MbproxyOptionsValidator();
|
|
var result = validator.Validate(null, options);
|
|
|
|
result.Failed.ShouldBeTrue("Width=8 should fail schema validation");
|
|
result.Failures.ShouldNotBeEmpty();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 6 — every shipped install template (Windows + Linux) loads as JSONC,
|
|
// binds to MbproxyOptions, and passes schema validation. This catches
|
|
// a malformed template at build time and keeps the two platform
|
|
// variants in lockstep.
|
|
// -------------------------------------------------------------------------
|
|
[Theory]
|
|
[InlineData("mbproxy.config.template.json")]
|
|
[InlineData("mbproxy.linux.config.template.json")]
|
|
public void MbproxyOptionsBinding_ShippedInstallTemplate_BindsAndValidates(string templateFileName)
|
|
{
|
|
var templatePath = ResolveInstallFile(templateFileName);
|
|
|
|
// The templates are JSONC; the .NET JSON config provider skips // and /* */
|
|
// comments and allows trailing commas, so AddJsonFile loads them directly.
|
|
var config = new ConfigurationBuilder()
|
|
.AddJsonFile(templatePath, optional: false)
|
|
.Build();
|
|
|
|
var options = config.GetSection("Mbproxy").Get<MbproxyOptions>() ?? new MbproxyOptions();
|
|
|
|
var result = new MbproxyOptionsValidator().Validate(null, options);
|
|
result.Succeeded.ShouldBeTrue(
|
|
$"{templateFileName} must pass schema validation — failures: " +
|
|
string.Join("; ", result.Failures ?? []));
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Test 7 — AdminPushIntervalMs (SignalR dashboard push cadence)
|
|
// -------------------------------------------------------------------------
|
|
[Fact]
|
|
public void MbproxyOptionsBinding_AdminPushIntervalMs_DefaultsTo1000()
|
|
{
|
|
var options = BindOptions(new Dictionary<string, string?>());
|
|
|
|
options.AdminPushIntervalMs.ShouldBe(1000);
|
|
}
|
|
|
|
[Fact]
|
|
public void MbproxyOptionsBinding_AdminPushIntervalMs_BindsConfiguredValue()
|
|
{
|
|
var options = BindOptions(new Dictionary<string, string?>
|
|
{
|
|
["Mbproxy:AdminPushIntervalMs"] = "250",
|
|
});
|
|
|
|
options.AdminPushIntervalMs.ShouldBe(250);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves an <c>install/</c> file by walking up from the test assembly directory.
|
|
/// Works from both the Windows dev box and the Linux test box.
|
|
/// </summary>
|
|
private static string ResolveInstallFile(string fileName)
|
|
{
|
|
for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent)
|
|
{
|
|
var candidate = Path.Combine(dir.FullName, "install", fileName);
|
|
if (File.Exists(candidate))
|
|
return candidate;
|
|
}
|
|
throw new FileNotFoundException(
|
|
$"Could not locate install/{fileName} above {AppContext.BaseDirectory}");
|
|
}
|
|
}
|