FileStore basics (4), MemStore/retention (10), RAFT election/append (16), config reload parity (3), monitoring endpoints varz/connz/healthz (6). 972 total tests passing, 0 failures.
323 lines
13 KiB
C#
323 lines
13 KiB
C#
// 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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connects a raw TCP client and reads the initial INFO line.
|
|
/// Returns the connected socket (caller owns disposal).
|
|
/// </summary>
|
|
private static async Task<Socket> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads from <paramref name="sock"/> until the accumulated response contains
|
|
/// <paramref name="expected"/> or the timeout elapses.
|
|
/// </summary>
|
|
private static async Task<string> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes a config file, then calls <see cref="NatsServer.ReloadConfigOrThrow"/>.
|
|
/// Mirrors the pattern from JetStreamClusterReloadTests.
|
|
/// </summary>
|
|
private static void WriteConfigAndReload(NatsServer server, string configPath, string configText)
|
|
{
|
|
File.WriteAllText(configPath, configText);
|
|
server.ReloadConfigOrThrow();
|
|
}
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<NatsException>(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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[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 ────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Checks whether any exception in the chain contains the given substring,
|
|
/// matching the pattern used in AuthIntegrationTests.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|