// 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; using NATS.Server.TestUtilities; namespace NATS.Server.Core.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 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 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 = TestPortAllocator.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 = TestPortAllocator.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\"")); } }