// Port of Go server/reload_test.go — Go-parity config reload tests. // Covers: TestConfigReloadTLS, TestConfigReloadClusterAuthorization, // TestConfigReloadClusterRoutes, TestConfigReloadAccountNKeyUsers, // TestConfigReloadAccountExportImport, TestConfigReloadRoutePool, // TestConfigReloadPerAccountRoutes, TestConfigReloadCompression, // TestConfigReloadMaxControlLine, TestConfigReloadMaxPayload, // TestConfigReloadWriteDeadline, TestConfigReloadPingInterval, // TestConfigReloadMaxConnections, TestConfigReloadMaxSubscriptions, // and additional reload_test.go coverage. // Reference: golang/nats-server/server/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; /// /// Go-parity tests for config hot-reload behaviour ported from Go's reload_test.go. /// Each test writes a temporary config file, starts the server with it, triggers a /// config reload (optionally changing the file first), and asserts the correct /// runtime behaviour after the reload. /// public class ReloadGoParityTests { // ─── 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-reload-goparity-{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); } private static bool ContainsInChain(Exception ex, string substring) { Exception? current = ex; while (current != null) { if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase)) return true; current = current.InnerException; } return false; } // ─── Tests: Max Connections ────────────────────────────────────────────── /// /// Go: TestConfigReloadMaxConnections (reload_test.go:1978) /// Reducing max_connections below current client count causes the server /// to reject new connections that would exceed the limit. /// [Fact] public async Task ReloadGoParityTests_MaxConnections_ReduceRejectsNew() { var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nmax_connections: 65536"); try { using var c1 = await RawConnectAsync(port); using var c2 = await RawConnectAsync(port); server.ClientCount.ShouldBe(2); // Reduce max_connections to 2 (equal to current count). WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 2"); // A third connection should be rejected. using var c3 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await c3.ConnectAsync(IPAddress.Loopback, port); var response = await SocketTestHelper.ReadUntilAsync(c3, "-ERR", timeoutMs: 5000); response.ShouldContain("maximum connections exceeded"); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadMaxConnections (reload_test.go:1978) — increase. /// After increasing max_connections new connections should be accepted again. /// Uses NatsConnection clients (which send CONNECT) so they are stably registered /// in _clients and don't get cleaned up before the limit-test connections run. /// [Fact] public async Task ReloadGoParityTests_MaxConnections_IncreaseAllowsNew() { // Start with a high limit so c1 and c2 can connect, then reload down to 2. var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nmax_connections: 65536"); try { // Use NatsConnection so they send CONNECT and are stably tracked in _clients. await using var c1 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await c1.ConnectAsync(); await c1.PingAsync(); await using var c2 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await c2.ConnectAsync(); await c2.PingAsync(); server.ClientCount.ShouldBe(2); // Reload to limit=2 (equal to current count); further connections should be rejected. WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 2"); // A third connection should be rejected (limit=2, count=2). using var c3reject = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await c3reject.ConnectAsync(IPAddress.Loopback, port); var r1 = await SocketTestHelper.ReadUntilAsync(c3reject, "-ERR", timeoutMs: 5000); r1.ShouldContain("maximum connections exceeded"); // Increase limit to 10. WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 10"); // Now a new connection should succeed. await using var c4 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await c4.ConnectAsync(); await c4.PingAsync(); server.ClientCount.ShouldBeGreaterThanOrEqualTo(3); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Max Payload ────────────────────────────────────────────────── /// /// Go: TestConfigReloadMaxPayload (reload_test.go:2032) /// Reducing max_payload causes the server to reject oversized PUBs on new /// connections. The INFO after reload advertises the new limit. /// [Fact] public async Task ReloadGoParityTests_MaxPayload_ReducedRejectsOversizedPub() { var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nmax_payload: 1048576"); try { // Publish a 5-byte message before reload — must succeed. using var sock = await RawConnectAsync(port); await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false,\"pedantic\":false}\r\n"), SocketFlags.None); await sock.SendAsync(Encoding.ASCII.GetBytes("SUB foo 1\r\n"), SocketFlags.None); await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"), SocketFlags.None); await SocketTestHelper.ReadUntilAsync(sock, "PONG"); await sock.SendAsync(Encoding.ASCII.GetBytes("PUB foo 5\r\nhello\r\n"), SocketFlags.None); await sock.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"), SocketFlags.None); var beforeResponse = await SocketTestHelper.ReadUntilAsync(sock, "PONG"); beforeResponse.ShouldContain("MSG foo"); // Reduce max_payload to 2 bytes. WriteConfigAndReload(server, configPath, $"port: {port}\nmax_payload: 2"); // On a NEW connection, a 5-byte publish must be rejected. using var sock2 = await RawConnectAsync(port); await sock2.SendAsync(Encoding.ASCII.GetBytes("CONNECT {\"verbose\":false,\"pedantic\":false}\r\n"), SocketFlags.None); await sock2.SendAsync(Encoding.ASCII.GetBytes("PUB foo 5\r\nhello\r\n"), SocketFlags.None); var errResponse = await SocketTestHelper.ReadUntilAsync(sock2, "-ERR", timeoutMs: 5000); errResponse.ShouldContain("-ERR"); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Max Control Line ───────────────────────────────────────────── /// /// Go: TestConfigReloadMaxControlLineWithClients (reload_test.go:3946) /// Reload must update max_control_line without disconnecting existing clients. /// [Fact] public async Task ReloadGoParityTests_MaxControlLine_ReloadTakesEffect() { var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nmax_control_line: 4096"); try { await using var client1 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client1.ConnectAsync(); await client1.PingAsync(); // Reduce max_control_line. WriteConfigAndReload(server, configPath, $"port: {port}\nmax_control_line: 512"); // Existing connection must remain alive. await client1.PingAsync(); // New connections must also work. await using var client2 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client2.ConnectAsync(); await client2.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Max Subscriptions ──────────────────────────────────────────── /// /// Go: TestConfigReloadMaxSubsUnsupported (reload_test.go:1917) /// max_subs can be changed via reload; new value takes effect on new subscriptions. /// [Fact] public async Task ReloadGoParityTests_MaxSubs_ReloadTakesEffect() { var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nmax_subs: 0"); try { WriteConfigAndReload(server, configPath, $"port: {port}\nmax_subs: 20"); 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: Write Deadline ─────────────────────────────────────────────── /// /// Go: TestConfigReload (reload_test.go:251) — write_deadline portion. /// Changing write_deadline via reload must not disrupt existing connections. /// [Fact] public async Task ReloadGoParityTests_WriteDeadline_ReloadTakesEffect() { var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nwrite_deadline: \"10s\""); try { await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client.ConnectAsync(); await client.PingAsync(); WriteConfigAndReload(server, configPath, $"port: {port}\nwrite_deadline: \"3s\""); await client.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Ping Interval ──────────────────────────────────────────────── /// /// Go: TestConfigReload (reload_test.go:251) — ping_interval portion. /// Changing ping_interval via reload must not disrupt existing connections. /// [Fact] public async Task ReloadGoParityTests_PingInterval_ReloadTakesEffect() { var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nping_interval: 120"); try { await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client.ConnectAsync(); await client.PingAsync(); WriteConfigAndReload(server, configPath, $"port: {port}\nping_interval: 5"); await client.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: TLS (simulated — no actual TLS certs in this test class) ──── /// /// Go: TestConfigReloadEnableTLS (reload_test.go:446) — basic scenario. /// Enabling then disabling TLS-related flags via reload must not crash the server. /// Full TLS cert rotation is covered in TlsReloadTests.cs. /// [Fact] public async Task ReloadGoParityTests_TlsFlags_ToggleViaReload() { var (server, port, cts, configPath) = await StartServerWithConfigAsync( "port: {PORT}\ntls_timeout: 2"); try { await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client.ConnectAsync(); await client.PingAsync(); // Increase TLS timeout via reload. WriteConfigAndReload(server, configPath, $"port: {port}\ntls_timeout: 5"); await client.PingAsync(); // Revert. WriteConfigAndReload(server, configPath, $"port: {port}\ntls_timeout: 2"); await client.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Cluster Authorization ──────────────────────────────────────── /// /// Go: TestConfigReloadEnableClusterAuthorization (reload_test.go:1411) — stub. /// Changing cluster authorization settings is non-reloadable (cluster section /// is treated as immutable). Verify reload fails when cluster options change. /// [Fact] public async Task ReloadGoParityTests_ClusterAuthorization_ChangedClusterIsRejected() { // Go: TestConfigReloadEnableClusterAuthorization (reload_test.go:1411) var clusterPort = TestPortAllocator.GetFreePort(); var (server, port, cts, configPath) = await StartServerWithConfigAsync( $"port: {{PORT}}\ncluster {{\n name: mycluster\n host: 127.0.0.1\n port: {clusterPort}\n}}"); try { var newClusterPort = TestPortAllocator.GetFreePort(); // Changing cluster host/port must be rejected. File.WriteAllText(configPath, $"port: {port}\ncluster {{\n name: mycluster\n host: 127.0.0.1\n port: {newClusterPort}\n}}"); Should.Throw(() => server.ReloadConfigOrThrow()) .Message.ShouldContain("Cluster"); // Server must still accept client connections after failed reload. 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: Cluster Routes ─────────────────────────────────────────────── /// /// Go: TestConfigReloadClusterRoutes (reload_test.go:1586) — simplified. /// Changing cluster routes config is non-reloadable (entire cluster block /// is immutable); the server must reject the reload with an appropriate error. /// [Fact] public async Task ReloadGoParityTests_ClusterRoutes_ChangeIsRejected() { // Go: TestConfigReloadClusterRoutes (reload_test.go:1586) var clusterPort = TestPortAllocator.GetFreePort(); var (server, port, cts, configPath) = await StartServerWithConfigAsync( $"port: {{PORT}}\ncluster {{\n name: testcluster\n host: 127.0.0.1\n port: {clusterPort}\n}}"); try { var otherPort = TestPortAllocator.GetFreePort(); // Adding routes also changes the cluster block. File.WriteAllText(configPath, $"port: {port}\ncluster {{\n name: testcluster\n host: 127.0.0.1\n port: {clusterPort}\n routes: [\"nats://127.0.0.1:{otherPort}\"]\n}}"); Should.Throw(() => server.ReloadConfigOrThrow()); // Server must remain operational. 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: Account NKey Users ─────────────────────────────────────────── /// /// Go: TestConfigReloadAccountNKeyUsers (reload_test.go:2966) — simplified. /// Adding/removing accounts and nkey users via reload must take effect. /// NKey support is not yet in .NET; this test verifies password-based account users. /// Adding a new user to an account must allow that user to connect after reload. /// [Fact] public async Task ReloadGoParityTests_AccountNKeyUsers_ReloadTakesEffect() { // Go: TestConfigReloadAccountNKeyUsers (reload_test.go:2966) // Simplified: test user password-based accounts reload (nkey infra not yet in .NET). var (server, port, cts, configPath) = await StartServerWithConfigAsync( "port: {PORT}\naccounts {\n acctA {\n users = [\n {user: derek, password: derek}\n ]\n }\n}"); try { await using var derek = new NatsConnection(new NatsOpts { Url = $"nats://derek:derek@127.0.0.1:{port}", }); await derek.ConnectAsync(); await derek.PingAsync(); // Reload: add ivan to acctA (keep derek for compatibility with current .NET server). WriteConfigAndReload(server, configPath, $"port: {port}\naccounts {{\n acctA {{\n users = [\n {{user: derek, password: derek}}\n {{user: ivan, password: ivan}}\n ]\n }}\n}}"); // derek must still connect. await derek.PingAsync(); // ivan (new user) must now connect. await using var ivan = new NatsConnection(new NatsOpts { Url = $"nats://ivan:ivan@127.0.0.1:{port}", }); await ivan.ConnectAsync(); await ivan.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Account Export/Import ──────────────────────────────────────── /// /// Go: TestConfigReloadAccountStreamsImportExport (reload_test.go:3100) — simplified. /// Adding a new account with separate users after reload must take effect. /// [Fact] public async Task ReloadGoParityTests_AccountExportImport_NewAccountAfterReload() { // Go: TestConfigReloadAccountStreamsImportExport (reload_test.go:3100) var (server, port, cts, configPath) = await StartServerWithConfigAsync( "port: {PORT}\naccounts {\n alpha {\n users = [{user: alice, password: pass1}]\n }\n}"); try { await using var alice = new NatsConnection(new NatsOpts { Url = $"nats://alice:pass1@127.0.0.1:{port}", }); await alice.ConnectAsync(); await alice.PingAsync(); // Add a new account via reload. WriteConfigAndReload(server, configPath, $"port: {port}\naccounts {{\n alpha {{\n users = [{{user: alice, password: pass1}}]\n }}\n beta {{\n users = [{{user: bob, password: pass2}}]\n }}\n}}"); // alice must still connect. await alice.PingAsync(); // bob (new account) must now connect. await using var bob = new NatsConnection(new NatsOpts { Url = $"nats://bob:pass2@127.0.0.1:{port}", }); await bob.ConnectAsync(); await bob.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadAccountServicesImportExport (reload_test.go:3294) — simplified. /// Adding a user to an existing account via reload must take effect. /// The test verifies the basic account user reload mechanism works. /// [Fact] public async Task ReloadGoParityTests_AccountExportImport_AccountUserReloadTakesEffect() { // Go: TestConfigReloadAccountServicesImportExport (reload_test.go:3294) var (server, port, cts, configPath) = await StartServerWithConfigAsync( "port: {PORT}\naccounts {\n alpha {\n users = [{user: alice, password: pass1}]\n }\n beta {\n users = [{user: bob, password: pass2}]\n }\n}"); try { await using var alice = new NatsConnection(new NatsOpts { Url = $"nats://alice:pass1@127.0.0.1:{port}", }); await alice.ConnectAsync(); await alice.PingAsync(); await using var bob = new NatsConnection(new NatsOpts { Url = $"nats://bob:pass2@127.0.0.1:{port}", }); await bob.ConnectAsync(); await bob.PingAsync(); // Add charlie to alpha account. WriteConfigAndReload(server, configPath, $"port: {port}\naccounts {{\n alpha {{\n users = [{{user: alice, password: pass1}}, {{user: charlie, password: pass3}}]\n }}\n beta {{\n users = [{{user: bob, password: pass2}}]\n }}\n}}"); // alice still connects. await alice.PingAsync(); // charlie (new) must now connect. await using var charlie = new NatsConnection(new NatsOpts { Url = $"nats://charlie:pass3@127.0.0.1:{port}", }); await charlie.ConnectAsync(); await charlie.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Route Pool (stub — no multi-server cluster in unit tests) ──── /// /// Go: TestConfigReloadRoutePoolAndPerAccount (reload_test.go:5148) — stub. /// Route pool changes are only meaningful in multi-server clusters. /// This test verifies that the config parser accepts pool_size in the /// cluster section and that a reload with a changed pool_size is rejected /// (cluster block is immutable at runtime). /// [Fact] public async Task ReloadGoParityTests_RoutePool_ClusterBlockIsImmutable() { // Go: TestConfigReloadRoutePoolAndPerAccount (reload_test.go:5148) var clusterPort = TestPortAllocator.GetFreePort(); var (server, port, cts, configPath) = await StartServerWithConfigAsync( $"port: {{PORT}}\ncluster {{\n name: local\n host: 127.0.0.1\n port: {clusterPort}\n pool_size: 3\n}}"); try { await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client.ConnectAsync(); await client.PingAsync(); // Changing pool_size changes the cluster block — must be rejected. File.WriteAllText(configPath, $"port: {port}\ncluster {{\n name: local\n host: 127.0.0.1\n port: {clusterPort}\n pool_size: 5\n}}"); Should.Throw(() => server.ReloadConfigOrThrow()) .Message.ShouldContain("Cluster"); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadPerAccountRoutes (reload_test.go:5148) — stub. /// Per-account routes are part of the cluster block; changing them is non-reloadable. /// [Fact] public async Task ReloadGoParityTests_PerAccountRoutes_ClusterBlockIsImmutable() { // Go: TestConfigReloadRoutePoolAndPerAccount (reload_test.go:5148) var clusterPort = TestPortAllocator.GetFreePort(); var (server, port, cts, configPath) = await StartServerWithConfigAsync( $"port: {{PORT}}\naccounts {{\n A {{ users: [{{user: u1, password: pwd}}] }}\n}}\ncluster {{\n name: local\n host: 127.0.0.1\n port: {clusterPort}\n}}"); try { // Adding per-account routes is a cluster-block change — must be rejected. File.WriteAllText(configPath, $"port: {port}\naccounts {{\n A {{ users: [{{user: u1, password: pwd}}] }}\n}}\ncluster {{\n name: local\n host: 127.0.0.1\n port: {clusterPort}\n accounts: [\"A\"]\n}}"); Should.Throw(() => server.ReloadConfigOrThrow()) .Message.ShouldContain("Cluster"); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Route Compression ──────────────────────────────────────────── /// /// Go: TestConfigReloadRouteCompression (reload_test.go:5877) — stub. /// Route compression is part of the cluster block; changing it is non-reloadable. /// (Full compression integration with actual routes requires multi-server setup.) /// [Fact] public async Task ReloadGoParityTests_RouteCompression_ClusterBlockIsImmutable() { // Go: TestConfigReloadRouteCompression (reload_test.go:5877) var clusterPort = TestPortAllocator.GetFreePort(); var (server, port, cts, configPath) = await StartServerWithConfigAsync( $"port: {{PORT}}\ncluster {{\n name: local\n host: 127.0.0.1\n port: {clusterPort}\n}}"); try { // Adding compression to the cluster block changes it — must be rejected. File.WriteAllText(configPath, $"port: {port}\ncluster {{\n name: local\n host: 127.0.0.1\n port: {clusterPort}\n compression: s2_better\n}}"); Should.Throw(() => server.ReloadConfigOrThrow()) .Message.ShouldContain("Cluster"); } finally { await CleanupAsync(server, cts, configPath); } } // ─── Tests: Misc reload scenarios from reload_test.go ─────────────────── /// /// Go: TestConfigReloadChangePermissions (reload_test.go:1146) — simplified. /// Users with different permissions can all connect after a permission reload. /// [Fact] public async Task ReloadGoParityTests_ChangePermissions_UsersStillConnect() { // Go: TestConfigReloadChangePermissions (reload_test.go:1146) var (server, port, cts, configPath) = await StartServerWithConfigAsync( "port: {PORT}\nauthorization {\n users = [\n {user: alice, password: foo}\n {user: bob, password: bar}\n ]\n}"); try { await using var alice = new NatsConnection(new NatsOpts { Url = $"nats://alice:foo@127.0.0.1:{port}", }); await alice.ConnectAsync(); await alice.PingAsync(); // Reload with updated permissions (password change for alice). WriteConfigAndReload(server, configPath, $"port: {port}\nauthorization {{\n users = [\n {{user: alice, password: newpwd}}\n {{user: bob, password: bar}}\n ]\n}}"); // bob still connects with unchanged password. await using var bob = new NatsConnection(new NatsOpts { Url = $"nats://bob:bar@127.0.0.1:{port}", }); await bob.ConnectAsync(); await bob.PingAsync(); // alice with new password must connect. await using var aliceNew = new NatsConnection(new NatsOpts { Url = $"nats://alice:newpwd@127.0.0.1:{port}", }); await aliceNew.ConnectAsync(); await aliceNew.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadAndVarz (reload_test.go:4144) — simplified. /// Reload does not break message delivery after many connections have been served. /// [Fact] public async Task ReloadGoParityTests_PubSubAfterManyConnectionsAndReload() { // Go: TestConfigReloadAndVarz (reload_test.go:4144) var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nmax_connections: 65536"); try { // Serve several short-lived connections. for (int i = 0; i < 5; i++) { await using var conn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await conn.ConnectAsync(); await conn.PingAsync(); } // Reload with a different limit. WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 100"); // Pub/sub must still work after reload. await using var subConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await subConn.ConnectAsync(); await using var subscription = await subConn.SubscribeCoreAsync("test.goparity"); await subConn.PingAsync(); await using var pubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await pubConn.ConnectAsync(); await pubConn.PublishAsync("test.goparity", "after-reload"); using var msgCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var msg = await subscription.Msgs.ReadAsync(msgCts.Token); msg.Data.ShouldBe("after-reload"); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadLogging (reload_test.go:4377) — multiple sequential reloads. /// Verifies that many repeated logging reloads leave the server stable. /// [Fact] public async Task ReloadGoParityTests_MultipleLoggingReloads_ServerStaysStable() { // Go: TestConfigReloadLogging (reload_test.go:4377) var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\ndebug: false\ntrace: false"); try { for (int i = 0; i < 10; i++) { WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: {(i % 2 == 0 ? "true" : "false")}\ntrace: {(i % 3 == 0 ? "true" : "false")}"); } 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); } } /// /// Go: TestConfigReloadNoPanicOnShutdown (reload_test.go:6358) — simplified. /// Verifies that simultaneous reload and shutdown do not panic. /// [Fact] public async Task ReloadGoParityTests_NoPanicOnShutdown() { // Go: TestConfigReloadNoPanicOnShutdown (reload_test.go:6358) var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-shutdown-{Guid.NewGuid():N}.conf"); var port = TestPortAllocator.GetFreePort(); try { for (int iter = 0; iter < 5; iter++) { File.WriteAllText(configPath, $"port: {port}\ndebug: false"); 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(); // Trigger a reload and a cancel simultaneously. var reloadTask = Task.Run(() => { try { File.WriteAllText(configPath, $"port: {port}\ndebug: true"); server.ReloadConfigOrThrow(); } catch { // Ignored: reload may race with shutdown. } }); await Task.Delay(5); await cts.CancelAsync(); server.Dispose(); await reloadTask; // Reuse the same port for the next iteration. } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Go: TestConfigReloadValidate (reload_test.go:4504) — simplified. /// Reload of a config with a type error in a non-reloadable field must be rejected /// and the server must remain fully operational. /// [Fact] public async Task ReloadGoParityTests_Validate_BadConfigRejected() { // Go: TestConfigReloadValidate (reload_test.go:4504) var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\ndebug: false"); try { // Write a config with a parse error. File.WriteAllText(configPath, $"port: {port}\nauthorization {{\n user: test\n"); Should.Throw(() => server.ReloadConfigOrThrow()); // Server must still accept connections. 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); } } /// /// Go: TestConfigReloadAccountWithNoChanges (reload_test.go:2887) — reload no-op. /// Reloading the same config must succeed as a no-op. /// [Fact] public async Task ReloadGoParityTests_AccountWithNoChanges_NoOpReload() { // Go: TestConfigReloadAccountWithNoChanges (reload_test.go:2887) var (server, port, cts, configPath) = await StartServerWithConfigAsync( "port: {PORT}\naccounts {\n acctA {\n users = [\n {user: derek, password: pass}\n ]\n }\n}"); try { // Reload the SAME config — must succeed. server.ReloadConfigOrThrow(); await using var derek = new NatsConnection(new NatsOpts { Url = $"nats://derek:pass@127.0.0.1:{port}", }); await derek.ConnectAsync(); await derek.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadAccounts (reload_test.go:4537) — adding/modifying accounts. /// A user can be moved between accounts via reload. /// [Fact] public async Task ReloadGoParityTests_Accounts_UserMovedBetweenAccounts() { // Go: TestConfigReloadAccounts (reload_test.go:4537) var (server, port, cts, configPath) = await StartServerWithConfigAsync( "port: {PORT}\naccounts {\n acctA {\n users = [{user: alice, password: pass}]\n }\n acctB {\n users = [{user: bob, password: pass}]\n }\n}"); try { await using var alice = new NatsConnection(new NatsOpts { Url = $"nats://alice:pass@127.0.0.1:{port}", }); await alice.ConnectAsync(); await alice.PingAsync(); // Move alice to acctB. WriteConfigAndReload(server, configPath, $"port: {port}\naccounts {{\n acctA {{\n users = []\n }}\n acctB {{\n users = [{{user: alice, password: pass}}]\n }}\n}}"); // alice still connects (same credentials, now in acctB). await using var aliceNew = new NatsConnection(new NatsOpts { Url = $"nats://alice:pass@127.0.0.1:{port}", }); await aliceNew.ConnectAsync(); await aliceNew.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadDefaultSystemAccount (reload_test.go:4694) — simplified. /// The system_account field can be changed via reload (it's a reloadable option). /// [Fact] public async Task ReloadGoParityTests_DefaultSystemAccount_Reload() { // Go: TestConfigReloadDefaultSystemAccount (reload_test.go:4694) var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}\nno_sys_acc: true"); try { await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client.ConnectAsync(); await client.PingAsync(); // Toggle no_sys_acc (reloadable). WriteConfigAndReload(server, configPath, $"port: {port}\nno_sys_acc: false"); await client.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadAccountMappings (reload_test.go:4746) — simplified. /// Accounts can be added and subject mappings take effect after reload. /// [Fact] public async Task ReloadGoParityTests_AccountMappings_ReloadTakesEffect() { // Go: TestConfigReloadAccountMappings (reload_test.go:4746) var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}"); try { await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client.ConnectAsync(); await client.PingAsync(); // Add an account with a mappings block via reload. WriteConfigAndReload(server, configPath, $"port: {port}\naccounts {{\n main {{\n users = [{{user: alice, password: pass}}]\n mappings = {{\n src: dest\n }}\n }}\n}}"); await using var alice = new NatsConnection(new NatsOpts { Url = $"nats://alice:pass@127.0.0.1:{port}", }); await alice.ConnectAsync(); await alice.PingAsync(); } finally { await CleanupAsync(server, cts, configPath); } } /// /// Go: TestConfigReloadNotPreventedByGateways (reload_test.go:3445) — simplified. /// A reload that does not change the gateway config must succeed even when a /// gateway block is present. /// [Fact] public async Task ReloadGoParityTests_GatewayPresent_ReloadSucceeds() { // Go: TestConfigReloadNotPreventedByGateways (reload_test.go:3445) // Note: server_name is non-reloadable, so we do not include it to avoid // conflicts between the parsed config value and the Options default. var gatewayPort = TestPortAllocator.GetFreePort(); var (server, port, cts, configPath) = await StartServerWithConfigAsync( $"port: {{PORT}}\ngateway {{\n name: local\n port: {gatewayPort}\n}}"); try { // A reload that only changes debug (reloadable) while gateway block stays the same // must succeed. WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: true\ngateway {{\n name: local\n port: {gatewayPort}\n}}"); 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); } } /// /// Go: TestConfigReloadBoolFlags (reload_test.go:3480) — CLI override preservation. /// When debug was set via CLI flag, a config reload that sets debug: false must not /// override the CLI-set value. /// [Fact] public async Task ReloadGoParityTests_BoolFlags_CliOverridePreserved() { // Go: TestConfigReloadBoolFlags (reload_test.go:3480) var port = TestPortAllocator.GetFreePort(); var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-boolflags-{Guid.NewGuid():N}.conf"); File.WriteAllText(configPath, $"port: {port}\ndebug: false"); var options = new NatsOptions { ConfigFile = configPath, Port = port, Debug = true }; var server = new NatsServer(options, NullLoggerFactory.Instance); var cliSnapshot = new NatsOptions { Debug = true }; server.SetCliSnapshot(cliSnapshot, new HashSet { "Debug" }); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { // Reload with debug: false in config, but CLI said true. // The CLI override should win. WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: false"); await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}" }); await client.ConnectAsync(); await client.PingAsync(); } finally { await cts.CancelAsync(); server.Dispose(); if (File.Exists(configPath)) File.Delete(configPath); } } }