// 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;
namespace NATS.Server.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 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 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 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();
}
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 = 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 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 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 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 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 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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);
}
}
}