diff --git a/tests/NATS.Server.Tests/Configuration/ConfigReloadAdvancedTests.cs b/tests/NATS.Server.Tests/Configuration/ConfigReloadAdvancedTests.cs new file mode 100644 index 0000000..3e55c1e --- /dev/null +++ b/tests/NATS.Server.Tests/Configuration/ConfigReloadAdvancedTests.cs @@ -0,0 +1,630 @@ +// 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; + +/// +/// 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. +/// +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 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 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 ────────────────────────────────── + + /// + /// Go: TestDefaultOptions opts_test.go:52 + /// NatsOptions must be constructed with the correct NATS protocol defaults. + /// + [Fact] + public void NatsOptions_default_port_is_4222() + { + var opts = new NatsOptions(); + opts.Port.ShouldBe(4222); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 + /// Default host must be the wildcard address to listen on all interfaces. + /// + [Fact] + public void NatsOptions_default_host_is_wildcard() + { + var opts = new NatsOptions(); + opts.Host.ShouldBe("0.0.0.0"); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (MaxConn = DEFAULT_MAX_CONNECTIONS = 65536) + /// + [Fact] + public void NatsOptions_default_max_connections_is_65536() + { + var opts = new NatsOptions(); + opts.MaxConnections.ShouldBe(65536); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (MaxPayload = MAX_PAYLOAD_SIZE = 1MB) + /// + [Fact] + public void NatsOptions_default_max_payload_is_1_megabyte() + { + var opts = new NatsOptions(); + opts.MaxPayload.ShouldBe(1024 * 1024); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (MaxControlLine = MAX_CONTROL_LINE_SIZE = 4096) + /// + [Fact] + public void NatsOptions_default_max_control_line_is_4096() + { + var opts = new NatsOptions(); + opts.MaxControlLine.ShouldBe(4096); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (PingInterval = DEFAULT_PING_INTERVAL = 2m) + /// + [Fact] + public void NatsOptions_default_ping_interval_is_two_minutes() + { + var opts = new NatsOptions(); + opts.PingInterval.ShouldBe(TimeSpan.FromMinutes(2)); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (MaxPingsOut = DEFAULT_PING_MAX_OUT = 2) + /// + [Fact] + public void NatsOptions_default_max_pings_out_is_2() + { + var opts = new NatsOptions(); + opts.MaxPingsOut.ShouldBe(2); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (AuthTimeout = AUTH_TIMEOUT = 2s) + /// + [Fact] + public void NatsOptions_default_auth_timeout_is_two_seconds() + { + var opts = new NatsOptions(); + opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (WriteDeadline = DEFAULT_FLUSH_DEADLINE = 10s) + /// + [Fact] + public void NatsOptions_default_write_deadline_is_ten_seconds() + { + var opts = new NatsOptions(); + opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(10)); + } + + /// + /// Go: TestDefaultOptions opts_test.go:52 (ConnectErrorReports = 3600) + /// + [Fact] + public void NatsOptions_default_connect_error_reports() + { + var opts = new NatsOptions(); + opts.ConnectErrorReports.ShouldBe(3600); + } + + // ─── Tests: ConfigProcessor Parsing ──────────────────────────────────── + + /// + /// Go: TestConfigFile opts_test.go:97 — parsed config overrides default port. + /// + [Fact] + public void ConfigProcessor_parses_port() + { + var opts = ConfigProcessor.ProcessConfig("port: 14222"); + opts.Port.ShouldBe(14222); + } + + /// + /// Go: TestConfigFile opts_test.go:97 — parsed config sets host. + /// + [Fact] + public void ConfigProcessor_parses_host() + { + var opts = ConfigProcessor.ProcessConfig("host: 127.0.0.1"); + opts.Host.ShouldBe("127.0.0.1"); + } + + /// + /// Go: TestConfigFile opts_test.go:97 — parsed config sets server_name. + /// + [Fact] + public void ConfigProcessor_parses_server_name() + { + var opts = ConfigProcessor.ProcessConfig("server_name: my-server"); + opts.ServerName.ShouldBe("my-server"); + } + + /// + /// Go: TestConfigFile opts_test.go:97 — debug/trace flags parsed from config. + /// + [Fact] + public void ConfigProcessor_parses_debug_and_trace() + { + var opts = ConfigProcessor.ProcessConfig("debug: true\ntrace: true"); + opts.Debug.ShouldBeTrue(); + opts.Trace.ShouldBeTrue(); + } + + /// + /// Go: TestConfigFile opts_test.go:97 — max_payload parsed from config. + /// + [Fact] + public void ConfigProcessor_parses_max_payload() + { + var opts = ConfigProcessor.ProcessConfig("max_payload: 65536"); + opts.MaxPayload.ShouldBe(65536); + } + + /// + /// Go: TestPingIntervalNew opts_test.go:1369 — ping_interval parsed as duration string. + /// + [Fact] + public void ConfigProcessor_parses_ping_interval_duration_string() + { + var opts = ConfigProcessor.ProcessConfig("ping_interval: \"60s\""); + opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60)); + } + + /// + /// Go: TestParseWriteDeadline opts_test.go:1187 — write_deadline as "Xs" duration string. + /// + [Fact] + public void ConfigProcessor_parses_write_deadline_duration_string() + { + var opts = ConfigProcessor.ProcessConfig("write_deadline: \"3s\""); + opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(3)); + } + + /// + /// Go: TestMalformedListenAddress opts_test.go:1314 + /// A malformed listen address must produce a parsing exception. + /// + [Fact] + public void ConfigProcessor_rejects_malformed_listen_address() + { + Should.Throw(() => ConfigProcessor.ProcessConfig("listen: \":not-a-port\"")); + } + + /// + /// Go: TestEmptyConfig opts_test.go:1302 + /// An empty config file must produce options with all default values. + /// + [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 ────────────────────────────── + + /// + /// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180 + /// ConfigReloader.Diff must detect port change as non-reloadable. + /// + [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(); + } + + /// + /// Go: TestConfigReload reload_test.go:251 — debug flag diff correctly categorised. + /// ConfigReloader.Diff must categorise debug change as a logging change. + /// + [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(); + } + + /// + /// Go: TestConfigReloadRotateUserAuthentication reload_test.go:658 + /// ConfigReloader.Diff must categorise username/password change as an auth change. + /// + [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(); + } + + /// + /// Go: TestConfigReload reload_test.go:251 + /// ConfigReloader.Diff on identical options must return an empty change list. + /// + [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(); + } + + /// + /// Go: TestConfigReloadClusterPortUnsupported reload_test.go:1394 + /// ConfigReloader.Diff must detect cluster port change as non-reloadable. + /// + [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(); + } + + /// + /// Go: reload_test.go — JetStream.StoreDir change must be non-reloadable. + /// + [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(); + } + + /// + /// ConfigReloader.Validate must return errors for all non-reloadable changes. + /// + [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 ──────────────────────────────────── + + /// + /// 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. + /// + [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 { "Port" }; + var fromConfig = new NatsOptions { Port = 9999 }; + + ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags); + + fromConfig.Port.ShouldBe(14222); + } + + /// + /// Go: TestMergeOverrides opts_test.go:264 + /// CLI debug=true must override config debug=false after merge. + /// + [Fact] + public void ConfigReloader_merge_cli_overrides_restores_debug_flag() + { + var cliValues = new NatsOptions { Debug = true }; + var cliFlags = new HashSet { "Debug" }; + var fromConfig = new NatsOptions { Debug = false }; + + ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags); + + fromConfig.Debug.ShouldBeTrue(); + } + + /// + /// Go: TestMergeOverrides opts_test.go:264 + /// A flag not present in cliFlags must not override the config value. + /// + [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 { "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 ────────────────────────────── + + /// + /// Go: TestConfigFile opts_test.go:97 — max_connections parsed and accessible. + /// + [Fact] + public void ConfigProcessor_parses_max_connections() + { + var opts = ConfigProcessor.ProcessConfig("max_connections: 100"); + opts.MaxConnections.ShouldBe(100); + } + + /// + /// Go: TestConfigFile opts_test.go:97 — lame_duck_duration parsed from config. + /// + [Fact] + public void ConfigProcessor_parses_lame_duck_duration() + { + var opts = ConfigProcessor.ProcessConfig("lame_duck_duration: \"4m\""); + opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(4)); + } + + /// + /// Go: TestMaxClosedClients opts_test.go:1340 — max_closed_clients parsed. + /// + [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 ──────────────────────────────── + + /// + /// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180 + /// Changing the listen host must be rejected at reload time. + /// + [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(() => server.ReloadConfigOrThrow()) + .Message.ShouldContain("Host"); + } + finally + { + await CleanupAsync(server, cts, configPath); + } + } + + // ─── Tests: Reload TLS Settings ──────────────────────────────────────── + + /// + /// Reloading with allow_non_tls must succeed and not disconnect existing clients. + /// + [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 ───────────────────────────────── + + /// + /// Go: TestConfigReloadClusterName reload_test.go:1893 + /// Adding a cluster block for the first time is a non-reloadable change. + /// + [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(() => server.ReloadConfigOrThrow()) + .Message.ShouldContain("Cluster"); + } + finally + { + await CleanupAsync(server, cts, configPath); + } + } + + // ─── Tests: JetStream Options Model ──────────────────────────────────── + + /// + /// JetStreamOptions must have sensible defaults (StoreDir empty, all limits 0). + /// Go: server/opts.go JetStreamConfig defaults. + /// + [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); + } + + /// + /// ConfigProcessor must correctly parse a jetstream block with store_dir. + /// Go: server/opts.go parseJetStream. + /// + [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 ──────────────────────────── + + /// + /// Go: opts_test.go (max_sub_tokens validation) — ConfigProcessor must reject + /// max_sub_tokens values that exceed 256. + /// + [Fact] + public void ConfigProcessor_rejects_max_sub_tokens_above_256() + { + Should.Throw(() => + ConfigProcessor.ProcessConfig("max_sub_tokens: 300")); + } + + /// + /// ConfigProcessor must accept max_sub_tokens values of exactly 256. + /// + [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 ──────────────────────────────────── + + /// + /// Go: opts_test.go server_name validation — server names containing spaces + /// must be rejected by the config processor. + /// + [Fact] + public void ConfigProcessor_rejects_server_name_with_spaces() + { + Should.Throw(() => + ConfigProcessor.ProcessConfig("server_name: \"my server\"")); + } +}