Files
natsdotnet/tests/NATS.Server.Tests/Configuration/ConfigReloadAdvancedTests.cs
Joseph Doherty ec1a9295f9 feat: add advanced config/reload tests (Go parity)
Ports 40 tests from Go's opts_test.go and reload_test.go covering:
NatsOptions defaults, ConfigProcessor parsing round-trips,
ConfigReloader diff/validate semantics, CLI override precedence,
and runtime reload rejection of host/cluster/JetStream changes.
2026-02-24 08:58:24 -05:00

631 lines
23 KiB
C#

// Advanced configuration and reload tests for full Go parity.
// Covers: CLI override precedence (opts_test.go TestMergeOverrides, TestConfigureOptions),
// configuration defaults (opts_test.go TestDefaultOptions), configuration validation
// (opts_test.go TestMalformedListenAddress, TestMaxClosedClients), NatsOptions model
// defaults, ConfigProcessor parsing, ConfigReloader diff/validate semantics, and
// reload scenarios not covered by ConfigReloadExtendedParityTests.
// Reference: golang/nats-server/server/opts_test.go, reload_test.go
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
namespace NATS.Server.Tests.Configuration;
/// <summary>
/// Advanced configuration model and hot-reload tests ported from Go's opts_test.go
/// and reload_test.go. Focuses on: NatsOptions defaults, ConfigProcessor parsing,
/// ConfigReloader diff/validate, CLI-override precedence, and reload-time validation
/// paths not exercised by the basic and extended parity suites.
/// </summary>
public class ConfigReloadAdvancedTests
{
// ─── Helpers ────────────────────────────────────────────────────────────
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static async Task<Socket> RawConnectAsync(int port)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, port);
var buf = new byte[4096];
await sock.ReceiveAsync(buf, SocketFlags.None);
return sock;
}
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var sb = new StringBuilder();
var buf = new byte[4096];
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
{
int n;
try { n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); }
catch (OperationCanceledException) { break; }
if (n == 0) break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
private static void WriteConfigAndReload(NatsServer server, string configPath, string configText)
{
File.WriteAllText(configPath, configText);
server.ReloadConfigOrThrow();
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts, string configPath)>
StartServerWithConfigAsync(string configContent)
{
var port = GetFreePort();
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-adv-{Guid.NewGuid():N}.conf");
var finalContent = configContent.Replace("{PORT}", port.ToString());
File.WriteAllText(configPath, finalContent);
var options = new NatsOptions { ConfigFile = configPath, Port = port };
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return (server, port, cts, configPath);
}
private static async Task CleanupAsync(NatsServer server, CancellationTokenSource cts, string configPath)
{
await cts.CancelAsync();
server.Dispose();
if (File.Exists(configPath)) File.Delete(configPath);
}
// ─── Tests: NatsOptions Default Values ──────────────────────────────────
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52
/// NatsOptions must be constructed with the correct NATS protocol defaults.
/// </summary>
[Fact]
public void NatsOptions_default_port_is_4222()
{
var opts = new NatsOptions();
opts.Port.ShouldBe(4222);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52
/// Default host must be the wildcard address to listen on all interfaces.
/// </summary>
[Fact]
public void NatsOptions_default_host_is_wildcard()
{
var opts = new NatsOptions();
opts.Host.ShouldBe("0.0.0.0");
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxConn = DEFAULT_MAX_CONNECTIONS = 65536)
/// </summary>
[Fact]
public void NatsOptions_default_max_connections_is_65536()
{
var opts = new NatsOptions();
opts.MaxConnections.ShouldBe(65536);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxPayload = MAX_PAYLOAD_SIZE = 1MB)
/// </summary>
[Fact]
public void NatsOptions_default_max_payload_is_1_megabyte()
{
var opts = new NatsOptions();
opts.MaxPayload.ShouldBe(1024 * 1024);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxControlLine = MAX_CONTROL_LINE_SIZE = 4096)
/// </summary>
[Fact]
public void NatsOptions_default_max_control_line_is_4096()
{
var opts = new NatsOptions();
opts.MaxControlLine.ShouldBe(4096);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (PingInterval = DEFAULT_PING_INTERVAL = 2m)
/// </summary>
[Fact]
public void NatsOptions_default_ping_interval_is_two_minutes()
{
var opts = new NatsOptions();
opts.PingInterval.ShouldBe(TimeSpan.FromMinutes(2));
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxPingsOut = DEFAULT_PING_MAX_OUT = 2)
/// </summary>
[Fact]
public void NatsOptions_default_max_pings_out_is_2()
{
var opts = new NatsOptions();
opts.MaxPingsOut.ShouldBe(2);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (AuthTimeout = AUTH_TIMEOUT = 2s)
/// </summary>
[Fact]
public void NatsOptions_default_auth_timeout_is_two_seconds()
{
var opts = new NatsOptions();
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (WriteDeadline = DEFAULT_FLUSH_DEADLINE = 10s)
/// </summary>
[Fact]
public void NatsOptions_default_write_deadline_is_ten_seconds()
{
var opts = new NatsOptions();
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(10));
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (ConnectErrorReports = 3600)
/// </summary>
[Fact]
public void NatsOptions_default_connect_error_reports()
{
var opts = new NatsOptions();
opts.ConnectErrorReports.ShouldBe(3600);
}
// ─── Tests: ConfigProcessor Parsing ────────────────────────────────────
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — parsed config overrides default port.
/// </summary>
[Fact]
public void ConfigProcessor_parses_port()
{
var opts = ConfigProcessor.ProcessConfig("port: 14222");
opts.Port.ShouldBe(14222);
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — parsed config sets host.
/// </summary>
[Fact]
public void ConfigProcessor_parses_host()
{
var opts = ConfigProcessor.ProcessConfig("host: 127.0.0.1");
opts.Host.ShouldBe("127.0.0.1");
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — parsed config sets server_name.
/// </summary>
[Fact]
public void ConfigProcessor_parses_server_name()
{
var opts = ConfigProcessor.ProcessConfig("server_name: my-server");
opts.ServerName.ShouldBe("my-server");
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — debug/trace flags parsed from config.
/// </summary>
[Fact]
public void ConfigProcessor_parses_debug_and_trace()
{
var opts = ConfigProcessor.ProcessConfig("debug: true\ntrace: true");
opts.Debug.ShouldBeTrue();
opts.Trace.ShouldBeTrue();
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — max_payload parsed from config.
/// </summary>
[Fact]
public void ConfigProcessor_parses_max_payload()
{
var opts = ConfigProcessor.ProcessConfig("max_payload: 65536");
opts.MaxPayload.ShouldBe(65536);
}
/// <summary>
/// Go: TestPingIntervalNew opts_test.go:1369 — ping_interval parsed as duration string.
/// </summary>
[Fact]
public void ConfigProcessor_parses_ping_interval_duration_string()
{
var opts = ConfigProcessor.ProcessConfig("ping_interval: \"60s\"");
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60));
}
/// <summary>
/// Go: TestParseWriteDeadline opts_test.go:1187 — write_deadline as "Xs" duration string.
/// </summary>
[Fact]
public void ConfigProcessor_parses_write_deadline_duration_string()
{
var opts = ConfigProcessor.ProcessConfig("write_deadline: \"3s\"");
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(3));
}
/// <summary>
/// Go: TestMalformedListenAddress opts_test.go:1314
/// A malformed listen address must produce a parsing exception.
/// </summary>
[Fact]
public void ConfigProcessor_rejects_malformed_listen_address()
{
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfig("listen: \":not-a-port\""));
}
/// <summary>
/// Go: TestEmptyConfig opts_test.go:1302
/// An empty config file must produce options with all default values.
/// </summary>
[Fact]
public void ConfigProcessor_empty_config_produces_defaults()
{
var opts = ConfigProcessor.ProcessConfig("");
opts.Port.ShouldBe(4222);
opts.Host.ShouldBe("0.0.0.0");
opts.MaxPayload.ShouldBe(1024 * 1024);
opts.MaxConnections.ShouldBe(65536);
}
// ─── Tests: ConfigReloader Diff / Validate ──────────────────────────────
/// <summary>
/// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180
/// ConfigReloader.Diff must detect port change as non-reloadable.
/// </summary>
[Fact]
public void ConfigReloader_diff_detects_port_change_as_non_reloadable()
{
var oldOpts = new NatsOptions { Port = 4222 };
var newOpts = new NatsOptions { Port = 5555 };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var portChange = changes.FirstOrDefault(c => c.Name == "Port");
portChange.ShouldNotBeNull();
portChange!.IsNonReloadable.ShouldBeTrue();
}
/// <summary>
/// Go: TestConfigReload reload_test.go:251 — debug flag diff correctly categorised.
/// ConfigReloader.Diff must categorise debug change as a logging change.
/// </summary>
[Fact]
public void ConfigReloader_diff_categorises_debug_as_logging_change()
{
var oldOpts = new NatsOptions { Debug = false };
var newOpts = new NatsOptions { Debug = true };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var debugChange = changes.FirstOrDefault(c => c.Name == "Debug");
debugChange.ShouldNotBeNull();
debugChange!.IsLoggingChange.ShouldBeTrue();
debugChange.IsNonReloadable.ShouldBeFalse();
}
/// <summary>
/// Go: TestConfigReloadRotateUserAuthentication reload_test.go:658
/// ConfigReloader.Diff must categorise username/password change as an auth change.
/// </summary>
[Fact]
public void ConfigReloader_diff_categorises_username_as_auth_change()
{
var oldOpts = new NatsOptions { Username = "alice" };
var newOpts = new NatsOptions { Username = "bob" };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var usernameChange = changes.FirstOrDefault(c => c.Name == "Username");
usernameChange.ShouldNotBeNull();
usernameChange!.IsAuthChange.ShouldBeTrue();
usernameChange.IsNonReloadable.ShouldBeFalse();
}
/// <summary>
/// Go: TestConfigReload reload_test.go:251
/// ConfigReloader.Diff on identical options must return an empty change list.
/// </summary>
[Fact]
public void ConfigReloader_diff_on_identical_options_returns_empty()
{
var opts = new NatsOptions { Port = 4222, Debug = false, MaxPayload = 1024 * 1024 };
var same = new NatsOptions { Port = 4222, Debug = false, MaxPayload = 1024 * 1024 };
var changes = ConfigReloader.Diff(opts, same);
changes.ShouldBeEmpty();
}
/// <summary>
/// Go: TestConfigReloadClusterPortUnsupported reload_test.go:1394
/// ConfigReloader.Diff must detect cluster port change as non-reloadable.
/// </summary>
[Fact]
public void ConfigReloader_diff_detects_cluster_port_change_as_non_reloadable()
{
var oldOpts = new NatsOptions { Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 6222 } };
var newOpts = new NatsOptions { Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 7777 } };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var clusterChange = changes.FirstOrDefault(c => c.Name == "Cluster");
clusterChange.ShouldNotBeNull();
clusterChange!.IsNonReloadable.ShouldBeTrue();
}
/// <summary>
/// Go: reload_test.go — JetStream.StoreDir change must be non-reloadable.
/// </summary>
[Fact]
public void ConfigReloader_diff_detects_jetstream_store_dir_change_as_non_reloadable()
{
var oldOpts = new NatsOptions { JetStream = new JetStreamOptions { StoreDir = "/tmp/js1" } };
var newOpts = new NatsOptions { JetStream = new JetStreamOptions { StoreDir = "/tmp/js2" } };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var jsDirChange = changes.FirstOrDefault(c => c.Name == "JetStream.StoreDir");
jsDirChange.ShouldNotBeNull();
jsDirChange!.IsNonReloadable.ShouldBeTrue();
}
/// <summary>
/// ConfigReloader.Validate must return errors for all non-reloadable changes.
/// </summary>
[Fact]
public void ConfigReloader_validate_returns_errors_for_non_reloadable_changes()
{
var oldOpts = new NatsOptions { Port = 4222 };
var newOpts = new NatsOptions { Port = 9999 };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var errors = ConfigReloader.Validate(changes);
errors.ShouldNotBeEmpty();
errors.ShouldContain(e => e.Contains("Port", StringComparison.OrdinalIgnoreCase));
}
// ─── Tests: CLI Override Precedence ────────────────────────────────────
/// <summary>
/// Go: TestMergeOverrides opts_test.go:264
/// ConfigReloader.MergeCliOverrides must restore the CLI port value after a
/// config reload that tries to set a different port.
/// </summary>
[Fact]
public void ConfigReloader_merge_cli_overrides_restores_port()
{
// Simulate: CLI sets port=14222; config file says port=9999.
var cliValues = new NatsOptions { Port = 14222 };
var cliFlags = new HashSet<string> { "Port" };
var fromConfig = new NatsOptions { Port = 9999 };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
fromConfig.Port.ShouldBe(14222);
}
/// <summary>
/// Go: TestMergeOverrides opts_test.go:264
/// CLI debug=true must override config debug=false after merge.
/// </summary>
[Fact]
public void ConfigReloader_merge_cli_overrides_restores_debug_flag()
{
var cliValues = new NatsOptions { Debug = true };
var cliFlags = new HashSet<string> { "Debug" };
var fromConfig = new NatsOptions { Debug = false };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
fromConfig.Debug.ShouldBeTrue();
}
/// <summary>
/// Go: TestMergeOverrides opts_test.go:264
/// A flag not present in cliFlags must not override the config value.
/// </summary>
[Fact]
public void ConfigReloader_merge_cli_overrides_ignores_non_cli_fields()
{
var cliValues = new NatsOptions { MaxPayload = 512 };
// MaxPayload is NOT in cliFlags — it came from config, not CLI.
var cliFlags = new HashSet<string> { "Port" };
var fromConfig = new NatsOptions { MaxPayload = 1024 * 1024 };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
// MaxPayload should remain the config-file value, not the CLI stub value.
fromConfig.MaxPayload.ShouldBe(1024 * 1024);
}
// ─── Tests: Config File Parsing Round-Trip ──────────────────────────────
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — max_connections parsed and accessible.
/// </summary>
[Fact]
public void ConfigProcessor_parses_max_connections()
{
var opts = ConfigProcessor.ProcessConfig("max_connections: 100");
opts.MaxConnections.ShouldBe(100);
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — lame_duck_duration parsed from config.
/// </summary>
[Fact]
public void ConfigProcessor_parses_lame_duck_duration()
{
var opts = ConfigProcessor.ProcessConfig("lame_duck_duration: \"4m\"");
opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(4));
}
/// <summary>
/// Go: TestMaxClosedClients opts_test.go:1340 — max_closed_clients parsed.
/// </summary>
[Fact]
public void ConfigProcessor_parses_max_closed_clients()
{
var opts = ConfigProcessor.ProcessConfig("max_closed_clients: 500");
opts.MaxClosedClients.ShouldBe(500);
}
// ─── Tests: Reload Host Change Rejected ────────────────────────────────
/// <summary>
/// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180
/// Changing the listen host must be rejected at reload time.
/// </summary>
[Fact]
public async Task Reload_host_change_rejected()
{
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
try
{
File.WriteAllText(configPath, $"port: {port}\nhost: 127.0.0.1");
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
.Message.ShouldContain("Host");
}
finally
{
await CleanupAsync(server, cts, configPath);
}
}
// ─── Tests: Reload TLS Settings ────────────────────────────────────────
/// <summary>
/// Reloading with allow_non_tls must succeed and not disconnect existing clients.
/// </summary>
[Fact]
public async Task Reload_allow_non_tls_setting()
{
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
try
{
WriteConfigAndReload(server, configPath, $"port: {port}\nallow_non_tls: true");
await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" });
await client.ConnectAsync();
await client.PingAsync();
}
finally
{
await CleanupAsync(server, cts, configPath);
}
}
// ─── Tests: Reload Cluster Name Change ─────────────────────────────────
/// <summary>
/// Go: TestConfigReloadClusterName reload_test.go:1893
/// Adding a cluster block for the first time is a non-reloadable change.
/// </summary>
[Fact]
public async Task Reload_adding_cluster_block_rejected()
{
var clusterPort = GetFreePort();
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
try
{
File.WriteAllText(configPath,
$"port: {port}\ncluster {{\n name: new-cluster\n host: 127.0.0.1\n port: {clusterPort}\n}}");
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
.Message.ShouldContain("Cluster");
}
finally
{
await CleanupAsync(server, cts, configPath);
}
}
// ─── Tests: JetStream Options Model ────────────────────────────────────
/// <summary>
/// JetStreamOptions must have sensible defaults (StoreDir empty, all limits 0).
/// Go: server/opts.go JetStreamConfig defaults.
/// </summary>
[Fact]
public void JetStreamOptions_defaults_are_empty_and_unlimited()
{
var jsOpts = new JetStreamOptions();
jsOpts.StoreDir.ShouldBe(string.Empty);
jsOpts.MaxMemoryStore.ShouldBe(0L);
jsOpts.MaxFileStore.ShouldBe(0L);
jsOpts.MaxStreams.ShouldBe(0);
jsOpts.MaxConsumers.ShouldBe(0);
}
/// <summary>
/// ConfigProcessor must correctly parse a jetstream block with store_dir.
/// Go: server/opts.go parseJetStream.
/// </summary>
[Fact]
public void ConfigProcessor_parses_jetstream_store_dir()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-parse-{Guid.NewGuid():N}");
var opts = ConfigProcessor.ProcessConfig(
$"jetstream {{\n store_dir: \"{storeDir.Replace("\\", "\\\\")}\"\n}}");
opts.JetStream.ShouldNotBeNull();
opts.JetStream!.StoreDir.ShouldBe(storeDir);
}
// ─── Tests: Reload max_sub_tokens Validation ────────────────────────────
/// <summary>
/// Go: opts_test.go (max_sub_tokens validation) — ConfigProcessor must reject
/// max_sub_tokens values that exceed 256.
/// </summary>
[Fact]
public void ConfigProcessor_rejects_max_sub_tokens_above_256()
{
Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("max_sub_tokens: 300"));
}
/// <summary>
/// ConfigProcessor must accept max_sub_tokens values of exactly 256.
/// </summary>
[Fact]
public void ConfigProcessor_accepts_max_sub_tokens_at_boundary_256()
{
var opts = ConfigProcessor.ProcessConfig("max_sub_tokens: 256");
opts.MaxSubTokens.ShouldBe(256);
}
// ─── Tests: server_name with spaces ────────────────────────────────────
/// <summary>
/// Go: opts_test.go server_name validation — server names containing spaces
/// must be rejected by the config processor.
/// </summary>
[Fact]
public void ConfigProcessor_rejects_server_name_with_spaces()
{
Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("server_name: \"my server\""));
}
}