// Port of Go server/reload_test.go — TestConfigReloadAuthChangeDisconnects, // TestConfigReloadAuthEnabled, TestConfigReloadAuthDisabled, // TestConfigReloadUserCredentialChange. // Reference: golang/nats-server/server/reload_test.go lines 720-900. 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; /// /// Tests for auth change propagation on config reload. /// Covers: /// - Enabling auth disconnects unauthenticated clients /// - Changing credentials disconnects clients with old credentials /// - Disabling auth allows previously rejected connections /// - Clients with correct credentials survive reload /// Reference: Go server/reload.go — reloadAuthorization. /// public class AuthReloadTests { // ─── 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 async Task SendConnectAsync(Socket sock, string? user = null, string? pass = null) { string connectJson; if (user != null && pass != null) connectJson = $"CONNECT {{\"verbose\":false,\"pedantic\":false,\"user\":\"{user}\",\"pass\":\"{pass}\"}}\r\n"; else connectJson = "CONNECT {\"verbose\":false,\"pedantic\":false}\r\n"; await sock.SendAsync(Encoding.ASCII.GetBytes(connectJson), SocketFlags.None); } private static void WriteConfigAndReload(NatsServer server, string configPath, string configText) { File.WriteAllText(configPath, configText); server.ReloadConfigOrThrow(); } // ─── Tests ────────────────────────────────────────────────────────────── /// /// Port of Go TestConfigReloadAuthChangeDisconnects (reload_test.go). /// /// Verifies that enabling authentication via hot reload disconnects clients /// that connected without credentials. The server should send -ERR /// 'Authorization Violation' and close the connection. /// [Fact] public async Task Enabling_auth_disconnects_unauthenticated_clients() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-authdc-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); // Start with no auth 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 { // Connect a client without credentials using var sock = await RawConnectAsync(port); await SendConnectAsync(sock); // Send a PING to confirm the connection is established await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None); var pong = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000); pong.ShouldContain("PONG"); server.ClientCount.ShouldBeGreaterThanOrEqualTo(1); // Enable auth via reload WriteConfigAndReload(server, configPath, $"port: {port}\nauthorization {{\n user: admin\n password: secret123\n}}"); // The unauthenticated client should receive an -ERR and/or be disconnected. // Read whatever the server sends before closing the socket. var errResponse = await ReadAllBeforeCloseAsync(sock, timeoutMs: 5000); // The server should have sent -ERR 'Authorization Violation' before closing errResponse.ShouldContain("Authorization Violation", Case.Insensitive, $"Expected 'Authorization Violation' in response but got: '{errResponse}'"); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that changing user credentials disconnects clients using old credentials. /// Reference: Go server/reload_test.go — TestConfigReloadUserCredentialChange. /// [Fact] public async Task Changing_credentials_disconnects_old_credential_clients() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-credchg-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); // Start with user/password auth File.WriteAllText(configPath, $"port: {port}\nauthorization {{\n user: alice\n password: pass1\n}}"); var options = ConfigProcessor.ProcessConfigFile(configPath); options.Port = port; var server = new NatsServer(options, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { // Connect with the original credentials using var sock = await RawConnectAsync(port); await SendConnectAsync(sock, "alice", "pass1"); // Verify connection works await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None); var pong = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000); pong.ShouldContain("PONG"); // Change the password via reload WriteConfigAndReload(server, configPath, $"port: {port}\nauthorization {{\n user: alice\n password: pass2\n}}"); // The client with the old password should be disconnected var errResponse = await ReadAllBeforeCloseAsync(sock, timeoutMs: 5000); errResponse.ShouldContain("Authorization Violation", Case.Insensitive, $"Expected 'Authorization Violation' in response but got: '{errResponse}'"); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that disabling auth on reload allows new unauthenticated connections. /// Reference: Go server/reload_test.go — TestConfigReloadDisableUserAuthentication. /// [Fact] public async Task Disabling_auth_allows_new_connections() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-authoff-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); // Start with auth enabled File.WriteAllText(configPath, $"port: {port}\nauthorization {{\n user: bob\n password: secret\n}}"); var options = ConfigProcessor.ProcessConfigFile(configPath); options.Port = port; var server = new NatsServer(options, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { // Verify unauthenticated connections are 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(); // Disable auth via reload WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: false"); // New connections without credentials should now succeed await using var newClient = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{port}", }); await newClient.ConnectAsync(); await newClient.PingAsync(); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that clients with the new correct credentials survive an auth reload. /// This connects a new client after the reload with the new credentials and /// verifies it works. /// Reference: Go server/reload_test.go — TestConfigReloadEnableUserAuthentication. /// [Fact] public async Task New_clients_with_correct_credentials_work_after_auth_reload() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-newauth-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); // Start with no auth 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 { // Enable auth via reload WriteConfigAndReload(server, configPath, $"port: {port}\nauthorization {{\n user: carol\n password: newpass\n}}"); // New connection with correct credentials should succeed await using var authClient = new NatsConnection(new NatsOpts { Url = $"nats://carol:newpass@127.0.0.1:{port}", }); await authClient.ConnectAsync(); await authClient.PingAsync(); // New connection without credentials should 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(); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } /// /// Verifies that PropagateAuthChanges is a no-op when auth is disabled. /// [Fact] public async Task PropagateAuthChanges_noop_when_auth_disabled() { var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-noauth-{Guid.NewGuid():N}.conf"); try { var port = TestPortAllocator.GetFreePort(); 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 { // Connect a client using var sock = await RawConnectAsync(port); await SendConnectAsync(sock); await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None); var pong = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000); pong.ShouldContain("PONG"); var countBefore = server.ClientCount; // Reload with a logging change only (no auth change) WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: true"); // Wait a moment for any async operations await Task.Delay(200); // Client count should remain the same (no disconnections) server.ClientCount.ShouldBe(countBefore); // Client should still be responsive await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None); var pong2 = await SocketTestHelper.ReadUntilAsync(sock, "PONG", timeoutMs: 3000); pong2.ShouldContain("PONG"); } finally { await cts.CancelAsync(); server.Dispose(); } } finally { if (File.Exists(configPath)) File.Delete(configPath); } } // ─── Private helpers ──────────────────────────────────────────────────── /// /// Reads all data from the socket until the connection is closed or timeout elapses. /// This is more robust than ReadUntilAsync for cases where the server sends an error /// and immediately closes the connection — we want to capture everything sent. /// private static async Task ReadAllBeforeCloseAsync(Socket sock, int timeoutMs = 5000) { using var cts = new CancellationTokenSource(timeoutMs); var sb = new StringBuilder(); var buf = new byte[4096]; while (true) { int n; try { n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); } catch (OperationCanceledException) { break; } catch (SocketException) { break; } if (n == 0) break; // Connection closed sb.Append(Encoding.ASCII.GetString(buf, 0, n)); } return sb.ToString(); } 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; } }