// Port of Go server/reload_test.go — TestConfigReloadMaxConnections, // TestConfigReloadEnableUserAuthentication, TestConfigReloadDisableUserAuthentication, // and connection-survival during reload. // Reference: golang/nats-server/server/reload_test.go lines 1978, 720, 781. 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; /// /// Parity tests for config hot reload behaviour. /// Covers the three scenarios from Go's reload_test.go: /// - MaxConnections reduction takes effect on new connections /// - Enabling authentication rejects new unauthorised connections /// - Existing connections survive a benign (logging) config reload /// public class ConfigReloadParityTests { // ─── 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<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options) { var port = GetFreePort(); options.Port = port; var server = new NatsServer(options, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); return (server, port, cts); } /// /// Connects a raw TCP client and reads the initial INFO line. /// Returns the connected socket (caller owns disposal). /// private static async Task RawConnectAsync(int port) { var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, port); // Drain the INFO line so subsequent reads start at the NATS protocol layer. var buf = new byte[4096]; await sock.ReceiveAsync(buf, SocketFlags.None); return sock; } /// /// Reads from until the accumulated response contains /// or the timeout elapses. /// 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(); } /// /// Writes a config file, then calls . /// Mirrors the pattern from JetStreamClusterReloadTests. /// private static void WriteConfigAndReload(NatsServer server, string configPath, string configText) { File.WriteAllText(configPath, configText); server.ReloadConfigOrThrow(); } // ─── Tests ────────────────────────────────────────────────────────────── /// /// Port of Go TestConfigReloadMaxConnections (reload_test.go:1978). /// /// Verifies that reducing MaxConnections via hot reload causes the server to /// reject new connections that would exceed the new limit. The .NET server /// enforces the limit at accept-time, so existing connections are preserved /// while future ones beyond the cap receive a -ERR response. /// /// Go reference: max_connections.conf sets max_connections: 1 and the Go /// server then closes one existing client; the .NET implementation rejects /// new connections instead of kicking established ones. /// [Fact] public async Task Reload_max_connections_takes_effect() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-maxconn-{Guid.NewGuid():N}.conf"); try { // Allocate a port first so we can embed it in the config file. // The server will bind to this port; the config file must match // to avoid a non-reloadable Port-change error on reload. var port = GetFreePort(); // Start with no connection limit. File.WriteAllText(configPath, $"port: {port}\nmax_connections: 65536"); 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(); try { // Establish two raw connections before limiting. using var c1 = await RawConnectAsync(port); using var c2 = await RawConnectAsync(port); server.ClientCount.ShouldBe(2); // Reload with MaxConnections = 2 (equal to current count). // New connections beyond this cap must be rejected. WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 2"); // Verify the limit is now in effect: a third connection should be // rejected with -ERR 'maximum connections exceeded'. using var c3 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await c3.ConnectAsync(IPAddress.Loopback, port); // The server sends INFO then immediately -ERR and closes the socket. var response = await ReadUntilAsync(c3, "-ERR", timeoutMs: 5000); response.ShouldContain("maximum connections exceeded"); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Port of Go TestConfigReloadEnableUserAuthentication (reload_test.go:720). /// /// Verifies that enabling username/password authentication via hot reload /// causes new unauthenticated connections to be rejected with an /// "Authorization Violation" error, while connections using the new /// credentials succeed. /// [Fact] public async Task Reload_auth_changes_take_effect() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-auth-{Guid.NewGuid():N}.conf"); try { // Allocate a port and embed it in every config write to prevent a // non-reloadable Port-change error when the config file is updated. var port = GetFreePort(); // Start with no authentication required. 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(); try { // Confirm a connection works with no credentials. await using var preReloadClient = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}", }); await preReloadClient.ConnectAsync(); await preReloadClient.PingAsync(); // Reload with user/password authentication enabled. WriteConfigAndReload(server, configPath, $"port: {port}\nauthorization {{\n user: tyler\n password: T0pS3cr3t\n}}"); // New connections without credentials must be rejected. await using var noAuthClient = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}", MaxReconnectRetry = 0, }); var ex = await Should.ThrowAsync(async () => { await noAuthClient.ConnectAsync(); await noAuthClient.PingAsync(); }); ContainsInChain(ex, "Authorization Violation").ShouldBeTrue( $"Expected 'Authorization Violation' in exception chain, but got: {ex}"); // New connections with the correct credentials must succeed. await using var authClient = new NatsConnection(new NatsOpts { Url = $"nats://tyler:T0pS3cr3t@127.0.0.1:{port}", }); await authClient.ConnectAsync(); await authClient.PingAsync(); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Port of Go TestConfigReloadDisableUserAuthentication (reload_test.go:781). /// /// Verifies that disabling authentication via hot reload allows new /// connections without credentials to succeed. Also verifies that /// connections established before the reload survive the reload cycle /// (the server must not close healthy clients on a logging-only reload). /// [Fact] public async Task Reload_preserves_existing_connections() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-preserve-{Guid.NewGuid():N}.conf"); try { // Allocate a port and embed it in every config write to prevent a // non-reloadable Port-change error when the config file is updated. var port = GetFreePort(); // Start with debug disabled. 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(); try { // Establish a connection before the reload. await using var client = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}", }); await client.ConnectAsync(); await client.PingAsync(); // The connection should be alive before reload. client.ConnectionState.ShouldBe(NatsConnectionState.Open); // Reload with a logging-only change (debug flag); this must not // disconnect existing clients. WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: true"); // Give the server a moment to apply changes. await Task.Delay(100); // The pre-reload connection should still be alive. client.ConnectionState.ShouldBe(NatsConnectionState.Open, "Existing connection should survive a logging-only config reload"); // Verify the connection is still functional. await client.PingAsync(); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } // ─── Private helpers ──────────────────────────────────────────────────── /// /// Checks whether any exception in the chain contains the given substring, /// matching the pattern used in AuthIntegrationTests. /// 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; } }