refactor: rename remaining tests to NATS.Server.Core.Tests

- Rename tests/NATS.Server.Tests -> tests/NATS.Server.Core.Tests
- Update solution file, InternalsVisibleTo, and csproj references
- Remove JETSTREAM_INTEGRATION_MATRIX and NATS.NKeys from csproj (moved to JetStream.Tests and Auth.Tests)
- Update all namespaces from NATS.Server.Tests.* to NATS.Server.Core.Tests.*
- Replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls
- Fix stale namespace in Transport.Tests/NetworkingGoParityTests.cs
This commit is contained in:
Joseph Doherty
2026-03-12 16:14:02 -04:00
parent 78b4bc2486
commit 7fbffffd05
114 changed files with 576 additions and 1121 deletions

View File

@@ -0,0 +1,196 @@
// Port of Go server/reload.go — auth change propagation tests.
// Reference: golang/nats-server/server/reload.go — authOption.Apply, usersOption.Apply.
using NATS.Server.Auth;
using NATS.Server.Configuration;
using Shouldly;
namespace NATS.Server.Core.Tests.Configuration;
public sealed class AuthChangePropagationTests
{
// ─── helpers ────────────────────────────────────────────────────
private static User MakeUser(string username, string password = "pw") =>
new() { Username = username, Password = password };
private static NatsOptions BaseOpts() => new();
// ─── tests ──────────────────────────────────────────────────────
[Fact]
public void No_changes_returns_no_changes()
{
// Same empty options → nothing changed.
var oldOpts = BaseOpts();
var newOpts = BaseOpts();
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.HasChanges.ShouldBeFalse();
result.UsersChanged.ShouldBeFalse();
result.AccountsChanged.ShouldBeFalse();
result.TokenChanged.ShouldBeFalse();
}
[Fact]
public void User_added_detected()
{
// Adding a user must set UsersChanged.
var oldOpts = BaseOpts();
var newOpts = BaseOpts();
newOpts.Users = [MakeUser("alice")];
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.UsersChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void User_removed_detected()
{
// Removing a user must set UsersChanged.
var oldOpts = BaseOpts();
oldOpts.Users = [MakeUser("alice")];
var newOpts = BaseOpts();
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.UsersChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void Account_added_detected()
{
// Adding an account must set AccountsChanged.
var oldOpts = BaseOpts();
var newOpts = BaseOpts();
newOpts.Accounts = new Dictionary<string, AccountConfig>
{
["engineering"] = new AccountConfig()
};
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.AccountsChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void Token_changed_detected()
{
// Changing the Authorization token must set TokenChanged.
var oldOpts = BaseOpts();
oldOpts.Authorization = "old-secret-token";
var newOpts = BaseOpts();
newOpts.Authorization = "new-secret-token";
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.TokenChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void Multiple_changes_all_flagged()
{
// Changing both users and accounts must set both flags.
var oldOpts = BaseOpts();
oldOpts.Users = [MakeUser("alice")];
oldOpts.Accounts = new Dictionary<string, AccountConfig>
{
["acct-a"] = new AccountConfig()
};
var newOpts = BaseOpts();
newOpts.Users = [MakeUser("alice"), MakeUser("bob")];
newOpts.Accounts = new Dictionary<string, AccountConfig>
{
["acct-a"] = new AccountConfig(),
["acct-b"] = new AccountConfig()
};
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.UsersChanged.ShouldBeTrue();
result.AccountsChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void Same_users_different_order_no_change()
{
// Users in a different order with the same names must NOT trigger UsersChanged
// because the comparison is set-based.
var oldOpts = BaseOpts();
oldOpts.Users = [MakeUser("alice"), MakeUser("bob")];
var newOpts = BaseOpts();
newOpts.Users = [MakeUser("bob"), MakeUser("alice")];
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.UsersChanged.ShouldBeFalse();
result.HasChanges.ShouldBeFalse();
}
[Fact]
public void HasChanges_true_when_any_change()
{
// A single changed field (token only) is enough to set HasChanges.
var oldOpts = BaseOpts();
var newOpts = BaseOpts();
newOpts.Authorization = "token-xyz";
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void Empty_to_non_empty_users_detected()
{
// Going from zero users to one user must be detected.
var oldOpts = BaseOpts();
// No Users assigned — null list.
var newOpts = BaseOpts();
newOpts.Users = [MakeUser("charlie")];
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.UsersChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void No_auth_to_auth_detected()
{
// Going from null Authorization to a token string must be detected.
var oldOpts = BaseOpts();
// Authorization is null by default.
var newOpts = BaseOpts();
newOpts.Authorization = "brand-new-token";
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.TokenChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void Same_token_no_change()
{
// The same token value on both sides must NOT flag TokenChanged.
var oldOpts = BaseOpts();
oldOpts.Authorization = "stable-token";
var newOpts = BaseOpts();
newOpts.Authorization = "stable-token";
var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts);
result.TokenChanged.ShouldBeFalse();
result.HasChanges.ShouldBeFalse();
}
}

View File

@@ -0,0 +1,391 @@
// 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;
/// <summary>
/// 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.
/// </summary>
public class AuthReloadTests
{
// ─── Helpers ────────────────────────────────────────────────────────────
private static async Task<Socket> 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 ──────────────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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);
}
}
/// <summary>
/// Verifies that changing user credentials disconnects clients using old credentials.
/// Reference: Go server/reload_test.go — TestConfigReloadUserCredentialChange.
/// </summary>
[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);
}
}
/// <summary>
/// Verifies that disabling auth on reload allows new unauthenticated connections.
/// Reference: Go server/reload_test.go — TestConfigReloadDisableUserAuthentication.
/// </summary>
[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<NatsException>(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);
}
}
/// <summary>
/// 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.
/// </summary>
[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<NatsException>(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);
}
}
/// <summary>
/// Verifies that PropagateAuthChanges is a no-op when auth is disabled.
/// </summary>
[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 ────────────────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private static async Task<string> 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;
}
}

View File

@@ -0,0 +1,186 @@
// Tests for ConfigReloader.ApplyClusterConfigChanges.
// Go reference: golang/nats-server/server/reload.go — routesOption.Apply, gatewayOption.Apply.
using NATS.Server;
using NATS.Server.Configuration;
using Shouldly;
namespace NATS.Server.Core.Tests.Configuration;
public class ClusterConfigReloadTests
{
// ─── helpers ────────────────────────────────────────────────────
private static NatsOptions WithRoutes(params string[] routes)
{
var opts = new NatsOptions();
opts.Cluster = new ClusterOptions { Routes = [..routes] };
return opts;
}
private static NatsOptions WithGatewayRemotes(params string[] urls)
{
var opts = new NatsOptions();
opts.Gateway = new GatewayOptions
{
RemoteGateways = [new RemoteGatewayOptions { Urls = [..urls] }]
};
return opts;
}
private static NatsOptions WithLeafRemotes(params string[] urls)
{
var opts = new NatsOptions();
opts.LeafNode = new LeafNodeOptions { Remotes = [..urls] };
return opts;
}
// ─── tests ──────────────────────────────────────────────────────
[Fact]
public void No_changes_returns_no_changes()
{
// Go reference: reload.go routesOption.Apply — no-op when sets are equal
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeFalse();
result.RouteUrlsChanged.ShouldBeFalse();
result.GatewayUrlsChanged.ShouldBeFalse();
result.LeafUrlsChanged.ShouldBeFalse();
}
[Fact]
public void Route_url_added_detected()
{
// Go reference: reload.go routesOption.Apply — new route triggers reconnect
var old = WithRoutes("nats://server1:6222");
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.RouteUrlsChanged.ShouldBeTrue();
}
[Fact]
public void Route_url_removed_detected()
{
// Go reference: reload.go routesOption.Apply — removed route triggers disconnect
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
var updated = WithRoutes("nats://server1:6222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.RouteUrlsChanged.ShouldBeTrue();
}
[Fact]
public void Gateway_url_changed_detected()
{
// Go reference: reload.go gatewayOption.Apply — gateway remotes reconciled on reload
var old = WithGatewayRemotes("nats://gw1:7222");
var updated = WithGatewayRemotes("nats://gw2:7222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.GatewayUrlsChanged.ShouldBeTrue();
}
[Fact]
public void Leaf_url_changed_detected()
{
// Go reference: reload.go leafNodeOption.Apply — leaf remotes reconciled on reload
var old = WithLeafRemotes("nats://hub:5222");
var updated = WithLeafRemotes("nats://hub2:5222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.LeafUrlsChanged.ShouldBeTrue();
}
[Fact]
public void Multiple_changes_detected()
{
// Go reference: reload.go — multiple topology changes in a single reload
var old = new NatsOptions
{
Cluster = new ClusterOptions { Routes = ["nats://r1:6222"] },
Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw1:7222"] }] }
};
var updated = new NatsOptions
{
Cluster = new ClusterOptions { Routes = ["nats://r1:6222", "nats://r2:6222"] },
Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw2:7222"] }] }
};
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.RouteUrlsChanged.ShouldBeTrue();
result.GatewayUrlsChanged.ShouldBeTrue();
result.LeafUrlsChanged.ShouldBeFalse();
}
[Fact]
public void Same_urls_different_order_no_change()
{
// Go reference: reload.go — order-independent URL comparison
var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
var updated = WithRoutes("nats://server2:6222", "nats://server1:6222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeFalse();
result.RouteUrlsChanged.ShouldBeFalse();
}
[Fact]
public void AddedRouteUrls_lists_new_routes()
{
// Go reference: reload.go routesOption.Apply — identifies routes to dial
var old = WithRoutes("nats://server1:6222");
var updated = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.AddedRouteUrls.Count.ShouldBe(2);
result.AddedRouteUrls.ShouldContain("nats://server2:6222");
result.AddedRouteUrls.ShouldContain("nats://server3:6222");
result.RemovedRouteUrls.ShouldBeEmpty();
}
[Fact]
public void RemovedRouteUrls_lists_removed_routes()
{
// Go reference: reload.go routesOption.Apply — identifies routes to close
var old = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
var updated = WithRoutes("nats://server1:6222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.RemovedRouteUrls.Count.ShouldBe(2);
result.RemovedRouteUrls.ShouldContain("nats://server2:6222");
result.RemovedRouteUrls.ShouldContain("nats://server3:6222");
result.AddedRouteUrls.ShouldBeEmpty();
}
[Fact]
public void Empty_to_non_empty_detected()
{
// Go reference: reload.go routesOption.Apply — nil→populated triggers dial
var old = new NatsOptions(); // no Cluster configured
var updated = WithRoutes("nats://server1:6222");
var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.RouteUrlsChanged.ShouldBeTrue();
result.AddedRouteUrls.ShouldContain("nats://server1:6222");
}
}

View File

@@ -0,0 +1,83 @@
using System.Reflection;
using NATS.Server.Configuration;
namespace NATS.Server.Core.Tests.Configuration;
public class ConfigPedanticParityBatch1Tests
{
[Fact]
public void ParseWithChecks_matches_parse_for_basic_input()
{
const string config = "port: 4222\nhost: 127.0.0.1\n";
var regular = NatsConfParser.Parse(config);
var withChecks = NatsConfParser.ParseWithChecks(config);
withChecks["port"].ShouldBe(regular["port"]);
withChecks["host"].ShouldBe(regular["host"]);
}
[Fact]
public void ParseFileWithChecks_and_digest_wrappers_are_available_and_stable()
{
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, "port: 4222\n");
var parsed = NatsConfParser.ParseFileWithChecks(path);
parsed["port"].ShouldBe(4222L);
var (cfg1, d1) = NatsConfParser.ParseFileWithChecksDigest(path);
var (cfg2, d2) = NatsConfParser.ParseFileWithDigest(path);
var (_, d1Repeat) = NatsConfParser.ParseFileWithChecksDigest(path);
cfg1["port"].ShouldBe(4222L);
cfg2["port"].ShouldBe(4222L);
d1.ShouldStartWith("sha256:");
d2.ShouldStartWith("sha256:");
d1.ShouldBe(d1Repeat);
d1.ShouldNotBe(d2);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void PedanticToken_accessors_match_expected_values()
{
var token = new Token(TokenType.Integer, "42", 3, 7);
var pedantic = new PedanticToken(token, value: 42L, usedVariable: true, sourceFile: "test.conf");
pedantic.Value().ShouldBe(42L);
pedantic.Line().ShouldBe(3);
pedantic.Position().ShouldBe(7);
pedantic.IsUsedVariable().ShouldBeTrue();
pedantic.SourceFile().ShouldBe("test.conf");
pedantic.MarshalJson().ShouldBe("42");
}
[Fact]
public void Parser_exposes_pedantic_compatibility_hooks()
{
var parserType = typeof(NatsConfParser);
parserType.GetMethod("CleanupUsedEnvVars", BindingFlags.NonPublic | BindingFlags.Static).ShouldNotBeNull();
var parserStateType = parserType.GetNestedType("ParserState", BindingFlags.NonPublic);
parserStateType.ShouldNotBeNull();
parserStateType!.GetMethod("PushItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull();
parserStateType.GetMethod("PopItemKey", BindingFlags.NonPublic | BindingFlags.Instance).ShouldNotBeNull();
}
[Fact]
public void Bcrypt_prefix_values_are_preserved_for_2a_and_2b()
{
var parsed2a = NatsConfParser.Parse("pwd: $2a$abc\n");
var parsed2b = NatsConfParser.Parse("pwd: $2b$abc\n");
parsed2a["pwd"].ShouldBe("$2a$abc");
parsed2b["pwd"].ShouldBe("$2b$abc");
}
}

View File

@@ -0,0 +1,608 @@
// Advanced configuration and reload tests for full Go parity.
// Covers: CLI override precedence (opts_test.go TestMergeOverrides, TestConfigureOptions),
// configuration defaults (opts_test.go TestDefaultOptions), configuration validation
// (opts_test.go TestMalformedListenAddress, TestMaxClosedClients), NatsOptions model
// defaults, ConfigProcessor parsing, ConfigReloader diff/validate semantics, and
// reload scenarios not covered by ConfigReloadExtendedParityTests.
// Reference: golang/nats-server/server/opts_test.go, 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;
/// <summary>
/// Advanced configuration model and hot-reload tests ported from Go's opts_test.go
/// and reload_test.go. Focuses on: NatsOptions defaults, ConfigProcessor parsing,
/// ConfigReloader diff/validate, CLI-override precedence, and reload-time validation
/// paths not exercised by the basic and extended parity suites.
/// </summary>
public class ConfigReloadAdvancedTests
{
// ─── Helpers ────────────────────────────────────────────────────────────
private static async Task<Socket> 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-adv-{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);
}
// ─── Tests: NatsOptions Default Values ──────────────────────────────────
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52
/// NatsOptions must be constructed with the correct NATS protocol defaults.
/// </summary>
[Fact]
public void NatsOptions_default_port_is_4222()
{
var opts = new NatsOptions();
opts.Port.ShouldBe(4222);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52
/// Default host must be the wildcard address to listen on all interfaces.
/// </summary>
[Fact]
public void NatsOptions_default_host_is_wildcard()
{
var opts = new NatsOptions();
opts.Host.ShouldBe("0.0.0.0");
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxConn = DEFAULT_MAX_CONNECTIONS = 65536)
/// </summary>
[Fact]
public void NatsOptions_default_max_connections_is_65536()
{
var opts = new NatsOptions();
opts.MaxConnections.ShouldBe(65536);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxPayload = MAX_PAYLOAD_SIZE = 1MB)
/// </summary>
[Fact]
public void NatsOptions_default_max_payload_is_1_megabyte()
{
var opts = new NatsOptions();
opts.MaxPayload.ShouldBe(1024 * 1024);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxControlLine = MAX_CONTROL_LINE_SIZE = 4096)
/// </summary>
[Fact]
public void NatsOptions_default_max_control_line_is_4096()
{
var opts = new NatsOptions();
opts.MaxControlLine.ShouldBe(4096);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (PingInterval = DEFAULT_PING_INTERVAL = 2m)
/// </summary>
[Fact]
public void NatsOptions_default_ping_interval_is_two_minutes()
{
var opts = new NatsOptions();
opts.PingInterval.ShouldBe(TimeSpan.FromMinutes(2));
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (MaxPingsOut = DEFAULT_PING_MAX_OUT = 2)
/// </summary>
[Fact]
public void NatsOptions_default_max_pings_out_is_2()
{
var opts = new NatsOptions();
opts.MaxPingsOut.ShouldBe(2);
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (AuthTimeout = AUTH_TIMEOUT = 2s)
/// </summary>
[Fact]
public void NatsOptions_default_auth_timeout_is_two_seconds()
{
var opts = new NatsOptions();
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (WriteDeadline = DEFAULT_FLUSH_DEADLINE = 10s)
/// </summary>
[Fact]
public void NatsOptions_default_write_deadline_is_ten_seconds()
{
var opts = new NatsOptions();
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(10));
}
/// <summary>
/// Go: TestDefaultOptions opts_test.go:52 (ConnectErrorReports = 3600)
/// </summary>
[Fact]
public void NatsOptions_default_connect_error_reports()
{
var opts = new NatsOptions();
opts.ConnectErrorReports.ShouldBe(3600);
}
// ─── Tests: ConfigProcessor Parsing ────────────────────────────────────
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — parsed config overrides default port.
/// </summary>
[Fact]
public void ConfigProcessor_parses_port()
{
var opts = ConfigProcessor.ProcessConfig("port: 14222");
opts.Port.ShouldBe(14222);
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — parsed config sets host.
/// </summary>
[Fact]
public void ConfigProcessor_parses_host()
{
var opts = ConfigProcessor.ProcessConfig("host: 127.0.0.1");
opts.Host.ShouldBe("127.0.0.1");
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — parsed config sets server_name.
/// </summary>
[Fact]
public void ConfigProcessor_parses_server_name()
{
var opts = ConfigProcessor.ProcessConfig("server_name: my-server");
opts.ServerName.ShouldBe("my-server");
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — debug/trace flags parsed from config.
/// </summary>
[Fact]
public void ConfigProcessor_parses_debug_and_trace()
{
var opts = ConfigProcessor.ProcessConfig("debug: true\ntrace: true");
opts.Debug.ShouldBeTrue();
opts.Trace.ShouldBeTrue();
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — max_payload parsed from config.
/// </summary>
[Fact]
public void ConfigProcessor_parses_max_payload()
{
var opts = ConfigProcessor.ProcessConfig("max_payload: 65536");
opts.MaxPayload.ShouldBe(65536);
}
/// <summary>
/// Go: TestPingIntervalNew opts_test.go:1369 — ping_interval parsed as duration string.
/// </summary>
[Fact]
public void ConfigProcessor_parses_ping_interval_duration_string()
{
var opts = ConfigProcessor.ProcessConfig("ping_interval: \"60s\"");
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60));
}
/// <summary>
/// Go: TestParseWriteDeadline opts_test.go:1187 — write_deadline as "Xs" duration string.
/// </summary>
[Fact]
public void ConfigProcessor_parses_write_deadline_duration_string()
{
var opts = ConfigProcessor.ProcessConfig("write_deadline: \"3s\"");
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(3));
}
/// <summary>
/// Go: TestMalformedListenAddress opts_test.go:1314
/// A malformed listen address must produce a parsing exception.
/// </summary>
[Fact]
public void ConfigProcessor_rejects_malformed_listen_address()
{
Should.Throw<Exception>(() => ConfigProcessor.ProcessConfig("listen: \":not-a-port\""));
}
/// <summary>
/// Go: TestEmptyConfig opts_test.go:1302
/// An empty config file must produce options with all default values.
/// </summary>
[Fact]
public void ConfigProcessor_empty_config_produces_defaults()
{
var opts = ConfigProcessor.ProcessConfig("");
opts.Port.ShouldBe(4222);
opts.Host.ShouldBe("0.0.0.0");
opts.MaxPayload.ShouldBe(1024 * 1024);
opts.MaxConnections.ShouldBe(65536);
}
// ─── Tests: ConfigReloader Diff / Validate ──────────────────────────────
/// <summary>
/// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180
/// ConfigReloader.Diff must detect port change as non-reloadable.
/// </summary>
[Fact]
public void ConfigReloader_diff_detects_port_change_as_non_reloadable()
{
var oldOpts = new NatsOptions { Port = 4222 };
var newOpts = new NatsOptions { Port = 5555 };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var portChange = changes.FirstOrDefault(c => c.Name == "Port");
portChange.ShouldNotBeNull();
portChange!.IsNonReloadable.ShouldBeTrue();
}
/// <summary>
/// Go: TestConfigReload reload_test.go:251 — debug flag diff correctly categorised.
/// ConfigReloader.Diff must categorise debug change as a logging change.
/// </summary>
[Fact]
public void ConfigReloader_diff_categorises_debug_as_logging_change()
{
var oldOpts = new NatsOptions { Debug = false };
var newOpts = new NatsOptions { Debug = true };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var debugChange = changes.FirstOrDefault(c => c.Name == "Debug");
debugChange.ShouldNotBeNull();
debugChange!.IsLoggingChange.ShouldBeTrue();
debugChange.IsNonReloadable.ShouldBeFalse();
}
/// <summary>
/// Go: TestConfigReloadRotateUserAuthentication reload_test.go:658
/// ConfigReloader.Diff must categorise username/password change as an auth change.
/// </summary>
[Fact]
public void ConfigReloader_diff_categorises_username_as_auth_change()
{
var oldOpts = new NatsOptions { Username = "alice" };
var newOpts = new NatsOptions { Username = "bob" };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var usernameChange = changes.FirstOrDefault(c => c.Name == "Username");
usernameChange.ShouldNotBeNull();
usernameChange!.IsAuthChange.ShouldBeTrue();
usernameChange.IsNonReloadable.ShouldBeFalse();
}
/// <summary>
/// Go: TestConfigReload reload_test.go:251
/// ConfigReloader.Diff on identical options must return an empty change list.
/// </summary>
[Fact]
public void ConfigReloader_diff_on_identical_options_returns_empty()
{
var opts = new NatsOptions { Port = 4222, Debug = false, MaxPayload = 1024 * 1024 };
var same = new NatsOptions { Port = 4222, Debug = false, MaxPayload = 1024 * 1024 };
var changes = ConfigReloader.Diff(opts, same);
changes.ShouldBeEmpty();
}
/// <summary>
/// Go: TestConfigReloadClusterPortUnsupported reload_test.go:1394
/// ConfigReloader.Diff must detect cluster port change as non-reloadable.
/// </summary>
[Fact]
public void ConfigReloader_diff_detects_cluster_port_change_as_non_reloadable()
{
var oldOpts = new NatsOptions { Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 6222 } };
var newOpts = new NatsOptions { Cluster = new ClusterOptions { Host = "127.0.0.1", Port = 7777 } };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var clusterChange = changes.FirstOrDefault(c => c.Name == "Cluster");
clusterChange.ShouldNotBeNull();
clusterChange!.IsNonReloadable.ShouldBeTrue();
}
/// <summary>
/// Go: reload_test.go — JetStream.StoreDir change must be non-reloadable.
/// </summary>
[Fact]
public void ConfigReloader_diff_detects_jetstream_store_dir_change_as_non_reloadable()
{
var oldOpts = new NatsOptions { JetStream = new JetStreamOptions { StoreDir = "/tmp/js1" } };
var newOpts = new NatsOptions { JetStream = new JetStreamOptions { StoreDir = "/tmp/js2" } };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var jsDirChange = changes.FirstOrDefault(c => c.Name == "JetStream.StoreDir");
jsDirChange.ShouldNotBeNull();
jsDirChange!.IsNonReloadable.ShouldBeTrue();
}
/// <summary>
/// ConfigReloader.Validate must return errors for all non-reloadable changes.
/// </summary>
[Fact]
public void ConfigReloader_validate_returns_errors_for_non_reloadable_changes()
{
var oldOpts = new NatsOptions { Port = 4222 };
var newOpts = new NatsOptions { Port = 9999 };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var errors = ConfigReloader.Validate(changes);
errors.ShouldNotBeEmpty();
errors.ShouldContain(e => e.Contains("Port", StringComparison.OrdinalIgnoreCase));
}
// ─── Tests: CLI Override Precedence ────────────────────────────────────
/// <summary>
/// Go: TestMergeOverrides opts_test.go:264
/// ConfigReloader.MergeCliOverrides must restore the CLI port value after a
/// config reload that tries to set a different port.
/// </summary>
[Fact]
public void ConfigReloader_merge_cli_overrides_restores_port()
{
// Simulate: CLI sets port=14222; config file says port=9999.
var cliValues = new NatsOptions { Port = 14222 };
var cliFlags = new HashSet<string> { "Port" };
var fromConfig = new NatsOptions { Port = 9999 };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
fromConfig.Port.ShouldBe(14222);
}
/// <summary>
/// Go: TestMergeOverrides opts_test.go:264
/// CLI debug=true must override config debug=false after merge.
/// </summary>
[Fact]
public void ConfigReloader_merge_cli_overrides_restores_debug_flag()
{
var cliValues = new NatsOptions { Debug = true };
var cliFlags = new HashSet<string> { "Debug" };
var fromConfig = new NatsOptions { Debug = false };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
fromConfig.Debug.ShouldBeTrue();
}
/// <summary>
/// Go: TestMergeOverrides opts_test.go:264
/// A flag not present in cliFlags must not override the config value.
/// </summary>
[Fact]
public void ConfigReloader_merge_cli_overrides_ignores_non_cli_fields()
{
var cliValues = new NatsOptions { MaxPayload = 512 };
// MaxPayload is NOT in cliFlags — it came from config, not CLI.
var cliFlags = new HashSet<string> { "Port" };
var fromConfig = new NatsOptions { MaxPayload = 1024 * 1024 };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
// MaxPayload should remain the config-file value, not the CLI stub value.
fromConfig.MaxPayload.ShouldBe(1024 * 1024);
}
// ─── Tests: Config File Parsing Round-Trip ──────────────────────────────
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — max_connections parsed and accessible.
/// </summary>
[Fact]
public void ConfigProcessor_parses_max_connections()
{
var opts = ConfigProcessor.ProcessConfig("max_connections: 100");
opts.MaxConnections.ShouldBe(100);
}
/// <summary>
/// Go: TestConfigFile opts_test.go:97 — lame_duck_duration parsed from config.
/// </summary>
[Fact]
public void ConfigProcessor_parses_lame_duck_duration()
{
var opts = ConfigProcessor.ProcessConfig("lame_duck_duration: \"4m\"");
opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(4));
}
/// <summary>
/// Go: TestMaxClosedClients opts_test.go:1340 — max_closed_clients parsed.
/// </summary>
[Fact]
public void ConfigProcessor_parses_max_closed_clients()
{
var opts = ConfigProcessor.ProcessConfig("max_closed_clients: 500");
opts.MaxClosedClients.ShouldBe(500);
}
// ─── Tests: Reload Host Change Rejected ────────────────────────────────
/// <summary>
/// Go: TestConfigReloadUnsupportedHotSwapping reload_test.go:180
/// Changing the listen host must be rejected at reload time.
/// </summary>
[Fact]
public async Task Reload_host_change_rejected()
{
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
try
{
File.WriteAllText(configPath, $"port: {port}\nhost: 127.0.0.1");
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
.Message.ShouldContain("Host");
}
finally
{
await CleanupAsync(server, cts, configPath);
}
}
// ─── Tests: Reload TLS Settings ────────────────────────────────────────
/// <summary>
/// Reloading with allow_non_tls must succeed and not disconnect existing clients.
/// </summary>
[Fact]
public async Task Reload_allow_non_tls_setting()
{
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
try
{
WriteConfigAndReload(server, configPath, $"port: {port}\nallow_non_tls: true");
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: Reload Cluster Name Change ─────────────────────────────────
/// <summary>
/// Go: TestConfigReloadClusterName reload_test.go:1893
/// Adding a cluster block for the first time is a non-reloadable change.
/// </summary>
[Fact]
public async Task Reload_adding_cluster_block_rejected()
{
var clusterPort = TestPortAllocator.GetFreePort();
var (server, port, cts, configPath) = await StartServerWithConfigAsync("port: {PORT}");
try
{
File.WriteAllText(configPath,
$"port: {port}\ncluster {{\n name: new-cluster\n host: 127.0.0.1\n port: {clusterPort}\n}}");
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
.Message.ShouldContain("Cluster");
}
finally
{
await CleanupAsync(server, cts, configPath);
}
}
// ─── Tests: JetStream Options Model ────────────────────────────────────
/// <summary>
/// JetStreamOptions must have sensible defaults (StoreDir empty, all limits 0).
/// Go: server/opts.go JetStreamConfig defaults.
/// </summary>
[Fact]
public void JetStreamOptions_defaults_are_empty_and_unlimited()
{
var jsOpts = new JetStreamOptions();
jsOpts.StoreDir.ShouldBe(string.Empty);
jsOpts.MaxMemoryStore.ShouldBe(0L);
jsOpts.MaxFileStore.ShouldBe(0L);
jsOpts.MaxStreams.ShouldBe(0);
jsOpts.MaxConsumers.ShouldBe(0);
}
/// <summary>
/// ConfigProcessor must correctly parse a jetstream block with store_dir.
/// Go: server/opts.go parseJetStream.
/// </summary>
[Fact]
public void ConfigProcessor_parses_jetstream_store_dir()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-parse-{Guid.NewGuid():N}");
var opts = ConfigProcessor.ProcessConfig(
$"jetstream {{\n store_dir: \"{storeDir.Replace("\\", "\\\\")}\"\n}}");
opts.JetStream.ShouldNotBeNull();
opts.JetStream!.StoreDir.ShouldBe(storeDir);
}
// ─── Tests: Reload max_sub_tokens Validation ────────────────────────────
/// <summary>
/// Go: opts_test.go (max_sub_tokens validation) — ConfigProcessor must reject
/// max_sub_tokens values that exceed 256.
/// </summary>
[Fact]
public void ConfigProcessor_rejects_max_sub_tokens_above_256()
{
Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("max_sub_tokens: 300"));
}
/// <summary>
/// ConfigProcessor must accept max_sub_tokens values of exactly 256.
/// </summary>
[Fact]
public void ConfigProcessor_accepts_max_sub_tokens_at_boundary_256()
{
var opts = ConfigProcessor.ProcessConfig("max_sub_tokens: 256");
opts.MaxSubTokens.ShouldBe(256);
}
// ─── Tests: server_name with spaces ────────────────────────────────────
/// <summary>
/// Go: opts_test.go server_name validation — server names containing spaces
/// must be rejected by the config processor.
/// </summary>
[Fact]
public void ConfigProcessor_rejects_server_name_with_spaces()
{
Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("server_name: \"my server\""));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
// 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;
using NATS.Server.TestUtilities;
namespace NATS.Server.Core.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 async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{
var port = TestPortAllocator.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>
/// <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 = TestPortAllocator.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 SocketTestHelper.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 = TestPortAllocator.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 = TestPortAllocator.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;
}
}

View File

@@ -0,0 +1,31 @@
using NATS.Server.Configuration;
namespace NATS.Server.Core.Tests.Configuration;
public class ConfigWarningsParityBatch1Tests
{
[Fact]
public void Config_warning_types_expose_message_and_source()
{
var warning = new ConfigWarningException("warn", "conf:1:2");
var unknown = new UnknownConfigFieldWarning("mystery_field", "conf:3:1");
warning.Message.ShouldBe("warn");
warning.SourceLocation.ShouldBe("conf:1:2");
unknown.Field.ShouldBe("mystery_field");
unknown.SourceLocation.ShouldBe("conf:3:1");
unknown.Message.ShouldContain("unknown field");
}
[Fact]
public void ProcessConfig_collects_unknown_field_warnings_when_errors_are_present()
{
var ex = Should.Throw<ConfigProcessorException>(() => ConfigProcessor.ProcessConfig("""
max_sub_tokens: 300
totally_unknown_field: 1
"""));
ex.Errors.ShouldNotBeEmpty();
ex.Warnings.ShouldContain(w => w.Contains("unknown field totally_unknown_field", StringComparison.Ordinal));
}
}

View File

@@ -0,0 +1,171 @@
// Port of Go server/reload.go — JetStream config change detection tests.
// Reference: golang/nats-server/server/reload.go — jetStreamOption.Apply.
using NATS.Server.Configuration;
using Shouldly;
namespace NATS.Server.Core.Tests.Configuration;
public sealed class JetStreamConfigReloadTests
{
// ─── helpers ────────────────────────────────────────────────────
private static NatsOptions BaseOpts() => new();
private static NatsOptions OptsWithJs(long maxMemory = 0, long maxStore = 0, string? domain = null) =>
new()
{
JetStream = new JetStreamOptions
{
MaxMemoryStore = maxMemory,
MaxFileStore = maxStore,
Domain = domain
}
};
// ─── tests ──────────────────────────────────────────────────────
[Fact]
public void No_changes_returns_no_changes()
{
// Identical JetStream config on both sides → no changes detected.
var oldOpts = OptsWithJs(maxMemory: 512, maxStore: 1024, domain: "hub");
var newOpts = OptsWithJs(maxMemory: 512, maxStore: 1024, domain: "hub");
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.HasChanges.ShouldBeFalse();
result.MaxMemoryChanged.ShouldBeFalse();
result.MaxStoreChanged.ShouldBeFalse();
result.DomainChanged.ShouldBeFalse();
}
[Fact]
public void MaxMemory_changed_detected()
{
// Changing MaxMemoryStore must set MaxMemoryChanged and HasChanges.
var oldOpts = OptsWithJs(maxMemory: 1024 * 1024);
var newOpts = OptsWithJs(maxMemory: 2 * 1024 * 1024);
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.MaxMemoryChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
result.MaxStoreChanged.ShouldBeFalse();
result.DomainChanged.ShouldBeFalse();
}
[Fact]
public void MaxStore_changed_detected()
{
// Changing MaxFileStore must set MaxStoreChanged and HasChanges.
var oldOpts = OptsWithJs(maxStore: 10 * 1024 * 1024);
var newOpts = OptsWithJs(maxStore: 20 * 1024 * 1024);
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.MaxStoreChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
result.MaxMemoryChanged.ShouldBeFalse();
result.DomainChanged.ShouldBeFalse();
}
[Fact]
public void Domain_changed_detected()
{
// Changing the domain name must set DomainChanged and HasChanges.
var oldOpts = OptsWithJs(domain: "hub");
var newOpts = OptsWithJs(domain: "edge");
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.DomainChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
result.MaxMemoryChanged.ShouldBeFalse();
result.MaxStoreChanged.ShouldBeFalse();
}
[Fact]
public void OldMaxMemory_and_NewMaxMemory_set()
{
// When MaxMemoryStore changes the old and new values must be captured.
var oldOpts = OptsWithJs(maxMemory: 100_000);
var newOpts = OptsWithJs(maxMemory: 200_000);
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.OldMaxMemory.ShouldBe(100_000L);
result.NewMaxMemory.ShouldBe(200_000L);
}
[Fact]
public void OldMaxStore_and_NewMaxStore_set()
{
// When MaxFileStore changes the old and new values must be captured.
var oldOpts = OptsWithJs(maxStore: 500_000);
var newOpts = OptsWithJs(maxStore: 1_000_000);
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.OldMaxStore.ShouldBe(500_000L);
result.NewMaxStore.ShouldBe(1_000_000L);
}
[Fact]
public void OldDomain_and_NewDomain_set()
{
// When Domain changes both old and new values must be captured.
var oldOpts = OptsWithJs(domain: "alpha");
var newOpts = OptsWithJs(domain: "beta");
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.OldDomain.ShouldBe("alpha");
result.NewDomain.ShouldBe("beta");
}
[Fact]
public void Multiple_changes_detected()
{
// Changing MaxMemory, MaxStore, and Domain together must flag all three.
var oldOpts = OptsWithJs(maxMemory: 1024, maxStore: 2048, domain: "primary");
var newOpts = OptsWithJs(maxMemory: 4096, maxStore: 8192, domain: "secondary");
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.MaxMemoryChanged.ShouldBeTrue();
result.MaxStoreChanged.ShouldBeTrue();
result.DomainChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
}
[Fact]
public void Zero_to_nonzero_memory_detected()
{
// Going from the default (0 = unlimited) to a concrete limit must be detected.
var oldOpts = BaseOpts(); // JetStream is null → effective MaxMemoryStore = 0
var newOpts = OptsWithJs(maxMemory: 512 * 1024 * 1024);
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.MaxMemoryChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
result.OldMaxMemory.ShouldBe(0L);
result.NewMaxMemory.ShouldBe(512 * 1024 * 1024L);
}
[Fact]
public void Domain_null_to_value_detected()
{
// Going from no JetStream config (domain null/empty) to a named domain must be detected.
var oldOpts = BaseOpts(); // JetStream is null → effective domain = ""
var newOpts = OptsWithJs(domain: "cloud");
var result = ConfigReloader.ApplyJetStreamConfigChanges(oldOpts, newOpts);
result.DomainChanged.ShouldBeTrue();
result.HasChanges.ShouldBeTrue();
result.OldDomain.ShouldBe(string.Empty);
result.NewDomain.ShouldBe("cloud");
}
}

View File

@@ -0,0 +1,169 @@
// Tests for ConfigReloader.ApplyLoggingChanges.
// Go reference: golang/nats-server/server/reload.go — traceOption.Apply, debugOption.Apply.
using NATS.Server;
using NATS.Server.Configuration;
using Shouldly;
namespace NATS.Server.Core.Tests.Configuration;
public class LoggingReloadTests
{
// ─── helpers ────────────────────────────────────────────────────
private static NatsOptions BaseOpts() => new NatsOptions();
// ─── tests ──────────────────────────────────────────────────────
[Fact]
public void No_changes_returns_no_changes()
{
// Go reference: reload.go traceOption.Apply — no-op when flags unchanged
var old = BaseOpts();
var updated = BaseOpts();
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.HasChanges.ShouldBeFalse();
result.LevelChanged.ShouldBeFalse();
result.TraceChanged.ShouldBeFalse();
result.DebugChanged.ShouldBeFalse();
}
[Fact]
public void Level_changed_detected()
{
// Go reference: reload.go debugOption.Apply — enabling debug changes effective level
var old = BaseOpts(); // Debug=false, Trace=false → "Information"
var updated = BaseOpts();
updated.Debug = true; // Debug=true → "Debug"
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.LevelChanged.ShouldBeTrue();
}
[Fact]
public void Trace_enabled_detected()
{
// Go reference: reload.go traceOption.Apply — enabling trace flag
var old = BaseOpts();
var updated = BaseOpts();
updated.Trace = true;
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.TraceChanged.ShouldBeTrue();
}
[Fact]
public void Trace_disabled_detected()
{
// Go reference: reload.go traceOption.Apply — disabling trace flag
var old = BaseOpts();
old.Trace = true;
var updated = BaseOpts(); // Trace=false
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.TraceChanged.ShouldBeTrue();
}
[Fact]
public void Debug_enabled_detected()
{
// Go reference: reload.go debugOption.Apply — enabling debug flag
var old = BaseOpts();
var updated = BaseOpts();
updated.Debug = true;
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.DebugChanged.ShouldBeTrue();
}
[Fact]
public void Debug_disabled_detected()
{
// Go reference: reload.go debugOption.Apply — disabling debug flag
var old = BaseOpts();
old.Debug = true;
var updated = BaseOpts(); // Debug=false
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.DebugChanged.ShouldBeTrue();
}
[Fact]
public void OldLevel_and_NewLevel_set()
{
// Go reference: reload.go — level transition is reported with explicit before/after values
var old = BaseOpts(); // Information
var updated = BaseOpts();
updated.Trace = true; // Trace takes precedence
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.LevelChanged.ShouldBeTrue();
result.OldLevel.ShouldBe("Information");
result.NewLevel.ShouldBe("Trace");
}
[Fact]
public void Case_insensitive_level_comparison()
{
// Same effective level produced regardless of flag combination that yields the same tier
// Debug=true on both sides → "Debug" == "Debug", no level change
var old = BaseOpts();
old.Debug = true;
var updated = BaseOpts();
updated.Debug = true;
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.LevelChanged.ShouldBeFalse();
result.HasChanges.ShouldBeFalse();
}
[Fact]
public void Null_level_defaults_to_Information()
{
// Go reference: reload.go — absent log level is treated as Information
// When neither Debug nor Trace is set the effective level is "Information"
var old = BaseOpts();
var updated = BaseOpts();
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
// No change, and effective level should be "Information" for both sides
result.LevelChanged.ShouldBeFalse();
result.OldLevel.ShouldBeNull(); // only populated when a level change is detected
result.NewLevel.ShouldBeNull();
}
[Fact]
public void Multiple_changes_detected()
{
// Go reference: reload.go — independent trace and debug options both apply
var old = BaseOpts();
old.Debug = true;
var updated = BaseOpts();
updated.Trace = true; // Debug removed, Trace added — both flags changed
var result = ConfigReloader.ApplyLoggingChanges(old, updated);
result.HasChanges.ShouldBeTrue();
result.TraceChanged.ShouldBeTrue();
result.DebugChanged.ShouldBeTrue();
result.LevelChanged.ShouldBeTrue();
result.OldLevel.ShouldBe("Debug");
result.NewLevel.ShouldBe("Trace");
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,372 @@
// Port of Go server/reload_test.go — TestConfigReloadSIGHUP, TestReloadAsync,
// TestApplyDiff, TestReloadConfigOrThrow.
// Reference: golang/nats-server/server/reload_test.go, reload.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;
/// <summary>
/// Tests for SIGHUP-triggered config reload and the ConfigReloader async API.
/// Covers:
/// - PosixSignalRegistration for SIGHUP wired to ReloadConfig
/// - ConfigReloader.ReloadAsync parses, diffs, and validates
/// - ConfigReloader.ApplyDiff returns correct category flags
/// - End-to-end reload via config file rewrite and ReloadConfigOrThrow
/// Reference: Go server/reload.go — Reload, applyOptions.
/// </summary>
public class SignalReloadTests
{
// ─── Helpers ────────────────────────────────────────────────────────────
private static async Task<Socket> 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();
}
// ─── Tests ──────────────────────────────────────────────────────────────
/// <summary>
/// Verifies that HandleSignals registers a SIGHUP handler that calls ReloadConfig.
/// We cannot actually send SIGHUP in a test, but we verify the handler is registered
/// by confirming ReloadConfig works when called directly, and that the server survives
/// signal registration without error.
/// Reference: Go server/signals_unix.go — handleSignals.
/// </summary>
[Fact]
public async Task HandleSignals_registers_sighup_handler()
{
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-sighup-{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
{
// Register signal handlers — should not throw
server.HandleSignals();
// Verify the reload mechanism works by calling it directly
// (simulating what SIGHUP would trigger)
File.WriteAllText(configPath, $"port: {port}\ndebug: true");
server.ReloadConfig();
// The server should still be operational
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();
}
}
finally
{
if (File.Exists(configPath)) File.Delete(configPath);
}
}
/// <summary>
/// Verifies that ConfigReloader.ReloadAsync correctly detects an unchanged config file.
/// </summary>
[Fact]
public async Task ReloadAsync_detects_unchanged_config()
{
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-noop-{Guid.NewGuid():N}.conf");
try
{
File.WriteAllText(configPath, "port: 4222\ndebug: false");
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222 };
// Compute initial digest
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
var result = await ConfigReloader.ReloadAsync(
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
result.Unchanged.ShouldBeTrue();
}
finally
{
if (File.Exists(configPath)) File.Delete(configPath);
}
}
/// <summary>
/// Verifies that ConfigReloader.ReloadAsync correctly detects config changes.
/// </summary>
[Fact]
public async Task ReloadAsync_detects_changes()
{
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-change-{Guid.NewGuid():N}.conf");
try
{
File.WriteAllText(configPath, "port: 4222\ndebug: false");
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = false };
// Compute initial digest
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
// Change the config file
File.WriteAllText(configPath, "port: 4222\ndebug: true");
var result = await ConfigReloader.ReloadAsync(
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
result.Unchanged.ShouldBeFalse();
result.NewOptions.ShouldNotBeNull();
result.NewOptions!.Debug.ShouldBeTrue();
result.Changes.ShouldNotBeNull();
result.Changes!.Count.ShouldBeGreaterThan(0);
result.HasErrors.ShouldBeFalse();
}
finally
{
if (File.Exists(configPath)) File.Delete(configPath);
}
}
/// <summary>
/// Verifies that ConfigReloader.ReloadAsync reports errors for non-reloadable changes.
/// </summary>
[Fact]
public async Task ReloadAsync_reports_non_reloadable_errors()
{
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-nonreload-{Guid.NewGuid():N}.conf");
try
{
File.WriteAllText(configPath, "port: 4222\nserver_name: original");
var currentOpts = new NatsOptions
{
ConfigFile = configPath,
Port = 4222,
ServerName = "original",
};
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
// Change a non-reloadable option
File.WriteAllText(configPath, "port: 4222\nserver_name: changed");
var result = await ConfigReloader.ReloadAsync(
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
result.Unchanged.ShouldBeFalse();
result.HasErrors.ShouldBeTrue();
result.Errors!.ShouldContain(e => e.Contains("ServerName"));
}
finally
{
if (File.Exists(configPath)) File.Delete(configPath);
}
}
/// <summary>
/// Verifies that ConfigReloader.ApplyDiff returns correct category flags.
/// </summary>
[Fact]
public void ApplyDiff_returns_correct_category_flags()
{
var oldOpts = new NatsOptions { Debug = false, Username = "old" };
var newOpts = new NatsOptions { Debug = true, Username = "new" };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
result.HasLoggingChanges.ShouldBeTrue();
result.HasAuthChanges.ShouldBeTrue();
result.ChangeCount.ShouldBeGreaterThan(0);
}
/// <summary>
/// Verifies that ApplyDiff detects TLS changes.
/// </summary>
[Fact]
public void ApplyDiff_detects_tls_changes()
{
var oldOpts = new NatsOptions { TlsCert = null };
var newOpts = new NatsOptions { TlsCert = "/path/to/cert.pem" };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
result.HasTlsChanges.ShouldBeTrue();
}
/// <summary>
/// Verifies that ReloadAsync preserves CLI overrides during reload.
/// </summary>
[Fact]
public async Task ReloadAsync_preserves_cli_overrides()
{
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-cli-{Guid.NewGuid():N}.conf");
try
{
File.WriteAllText(configPath, "port: 4222\ndebug: false");
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = true };
var cliSnapshot = new NatsOptions { Debug = true };
var cliFlags = new HashSet<string> { "Debug" };
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
// Change config — debug goes to true in file, but CLI override also says true
File.WriteAllText(configPath, "port: 4222\ndebug: true");
var result = await ConfigReloader.ReloadAsync(
configPath, currentOpts, initialDigest, cliSnapshot, cliFlags, CancellationToken.None);
// Config changed, so it should not be "unchanged"
result.Unchanged.ShouldBeFalse();
result.NewOptions.ShouldNotBeNull();
result.NewOptions!.Debug.ShouldBeTrue("CLI override should preserve debug=true");
}
finally
{
if (File.Exists(configPath)) File.Delete(configPath);
}
}
/// <summary>
/// Verifies end-to-end: rewrite config file and call ReloadConfigOrThrow
/// to apply max_connections changes, then verify new connections are rejected.
/// Reference: Go server/reload_test.go — TestConfigReloadMaxConnections.
/// </summary>
[Fact]
public async Task Reload_via_config_file_rewrite_applies_changes()
{
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-e2e-{Guid.NewGuid():N}.conf");
try
{
var port = TestPortAllocator.GetFreePort();
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 one connection
using var c1 = await RawConnectAsync(port);
server.ClientCount.ShouldBe(1);
// Reduce max_connections to 1 via reload
WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 1");
// New connection should be rejected
using var c2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await c2.ConnectAsync(IPAddress.Loopback, port);
var response = await SocketTestHelper.ReadUntilAsync(c2, "-ERR", timeoutMs: 5000);
response.ShouldContain("maximum connections exceeded");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
finally
{
if (File.Exists(configPath)) File.Delete(configPath);
}
}
/// <summary>
/// Verifies that ReloadConfigOrThrow throws for non-reloadable changes.
/// </summary>
[Fact]
public async Task ReloadConfigOrThrow_throws_on_non_reloadable_change()
{
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-throw-{Guid.NewGuid():N}.conf");
try
{
var port = TestPortAllocator.GetFreePort();
File.WriteAllText(configPath, $"port: {port}\nserver_name: original");
var options = new NatsOptions { ConfigFile = configPath, Port = port, ServerName = "original" };
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
try
{
// Try to change a non-reloadable option
File.WriteAllText(configPath, $"port: {port}\nserver_name: changed");
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
.Message.ShouldContain("ServerName");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
finally
{
if (File.Exists(configPath)) File.Delete(configPath);
}
}
/// <summary>
/// Verifies that ReloadConfig does not throw when no config file is specified
/// (it logs a warning and returns).
/// </summary>
[Fact]
public void ReloadConfig_no_config_file_does_not_throw()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
// Should not throw; just logs a warning
Should.NotThrow(() => server.ReloadConfig());
}
/// <summary>
/// Verifies that ReloadConfigOrThrow throws when no config file is specified.
/// </summary>
[Fact]
public void ReloadConfigOrThrow_throws_when_no_config_file()
{
var options = new NatsOptions { Port = 0 };
using var server = new NatsServer(options, NullLoggerFactory.Instance);
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
.Message.ShouldContain("No config file");
}
}

View File

@@ -0,0 +1,386 @@
// Tests for TLS certificate hot reload (E9).
// Verifies that TlsCertificateProvider supports atomic cert swapping
// and that ConfigReloader.ReloadTlsCertificate integrates correctly.
// Reference: golang/nats-server/server/reload_test.go — TestConfigReloadRotateTLS (line 392).
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using NATS.Server.Configuration;
using NATS.Server.Tls;
namespace NATS.Server.Core.Tests.Configuration;
public class TlsReloadTests
{
/// <summary>
/// Generates a self-signed X509Certificate2 for testing.
/// </summary>
private static X509Certificate2 GenerateSelfSignedCert(string cn = "test")
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));
// Export and re-import to ensure the cert has the private key bound
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pkcs12), null);
}
[Fact]
public void CertificateProvider_GetCurrentCertificate_ReturnsInitialCert()
{
// Go parity: TestConfigReloadRotateTLS — initial cert is usable
var cert = GenerateSelfSignedCert("initial");
using var provider = new TlsCertificateProvider(cert);
var current = provider.GetCurrentCertificate();
current.ShouldNotBeNull();
current.Subject.ShouldContain("initial");
}
[Fact]
public void CertificateProvider_SwapCertificate_ReturnsOldCert()
{
// Go parity: TestConfigReloadRotateTLS — cert rotation returns old cert
var cert1 = GenerateSelfSignedCert("cert1");
var cert2 = GenerateSelfSignedCert("cert2");
using var provider = new TlsCertificateProvider(cert1);
var old = provider.SwapCertificate(cert2);
old.ShouldNotBeNull();
old.Subject.ShouldContain("cert1");
old.Dispose();
var current = provider.GetCurrentCertificate();
current.ShouldNotBeNull();
current.Subject.ShouldContain("cert2");
}
[Fact]
public void CertificateProvider_SwapCertificate_IncrementsVersion()
{
// Go parity: TestConfigReloadRotateTLS — version tracking for reload detection
var cert1 = GenerateSelfSignedCert("v1");
var cert2 = GenerateSelfSignedCert("v2");
using var provider = new TlsCertificateProvider(cert1);
var v0 = provider.Version;
v0.ShouldBe(0);
provider.SwapCertificate(cert2)?.Dispose();
provider.Version.ShouldBe(1);
}
[Fact]
public void CertificateProvider_MultipleSwa_NewConnectionsGetLatest()
{
// Go parity: TestConfigReloadRotateTLS — multiple rotations, each new
// handshake gets the latest certificate
var cert1 = GenerateSelfSignedCert("round1");
var cert2 = GenerateSelfSignedCert("round2");
var cert3 = GenerateSelfSignedCert("round3");
using var provider = new TlsCertificateProvider(cert1);
provider.GetCurrentCertificate()!.Subject.ShouldContain("round1");
provider.SwapCertificate(cert2)?.Dispose();
provider.GetCurrentCertificate()!.Subject.ShouldContain("round2");
provider.SwapCertificate(cert3)?.Dispose();
provider.GetCurrentCertificate()!.Subject.ShouldContain("round3");
provider.Version.ShouldBe(2);
}
[Fact]
public async Task CertificateProvider_ConcurrentAccess_IsThreadSafe()
{
// Go parity: TestConfigReloadRotateTLS — cert swap must be safe under
// concurrent connection accept
var cert1 = GenerateSelfSignedCert("concurrent1");
using var provider = new TlsCertificateProvider(cert1);
var tasks = new Task[50];
for (int i = 0; i < tasks.Length; i++)
{
var idx = i;
tasks[i] = Task.Run(() =>
{
if (idx % 2 == 0)
{
// Readers — simulate new connections getting current cert
var c = provider.GetCurrentCertificate();
c.ShouldNotBeNull();
}
else
{
// Writers — simulate reload
var newCert = GenerateSelfSignedCert($"swap-{idx}");
provider.SwapCertificate(newCert)?.Dispose();
}
});
}
await Task.WhenAll(tasks);
// After all swaps, the provider should still return a valid cert
provider.GetCurrentCertificate().ShouldNotBeNull();
}
[Fact]
public void ReloadTlsCertificate_NullProvider_ReturnsFalse()
{
// Edge case: server running without TLS
var opts = new NatsOptions();
var result = ConfigReloader.ReloadTlsCertificate(opts, null);
result.ShouldBeFalse();
}
[Fact]
public void ReloadTlsCertificate_NoTlsConfig_ReturnsFalse()
{
// Edge case: provider exists but options don't have TLS paths
var cert = GenerateSelfSignedCert("no-tls");
using var provider = new TlsCertificateProvider(cert);
var opts = new NatsOptions(); // HasTls is false (no TlsCert/TlsKey)
var result = ConfigReloader.ReloadTlsCertificate(opts, provider);
result.ShouldBeFalse();
}
[Fact]
public void ReloadTlsCertificate_WithCertFiles_SwapsCertAndSslOptions()
{
// Go parity: TestConfigReloadRotateTLS — full reload with cert files.
// Write a self-signed cert to temp files and verify the provider loads it.
var tempDir = Path.Combine(Path.GetTempPath(), $"nats-tls-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
try
{
var certPath = Path.Combine(tempDir, "cert.pem");
var keyPath = Path.Combine(tempDir, "key.pem");
WriteSelfSignedCertFiles(certPath, keyPath, "reload-test");
// Create provider with initial cert
var initialCert = GenerateSelfSignedCert("initial");
using var provider = new TlsCertificateProvider(initialCert);
var opts = new NatsOptions { TlsCert = certPath, TlsKey = keyPath };
var result = ConfigReloader.ReloadTlsCertificate(opts, provider);
result.ShouldBeTrue();
provider.Version.ShouldBeGreaterThan(0);
provider.GetCurrentCertificate().ShouldNotBeNull();
provider.GetCurrentSslOptions().ShouldNotBeNull();
}
finally
{
Directory.Delete(tempDir, recursive: true);
}
}
[Fact]
public void ConfigDiff_DetectsTlsChanges()
{
// Go parity: TestConfigReloadEnableTLS, TestConfigReloadDisableTLS
// Verify that diff detects TLS option changes and flags them
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem", TlsKey = "/old/key.pem" };
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem", TlsKey = "/new/key.pem" };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
changes.Count.ShouldBeGreaterThan(0);
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsCert");
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsKey");
}
[Fact]
public void ConfigDiff_TlsVerifyChange_IsTlsChange()
{
// Go parity: TestConfigReloadRotateTLS — enabling client verification
var oldOpts = new NatsOptions { TlsVerify = false };
var newOpts = new NatsOptions { TlsVerify = true };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsVerify");
}
[Fact]
public void ConfigApplyResult_ReportsTlsChanges()
{
// Verify ApplyDiff flags TLS changes correctly
var changes = new List<IConfigChange>
{
new ConfigChange("TlsCert", isTlsChange: true),
new ConfigChange("TlsKey", isTlsChange: true),
};
var oldOpts = new NatsOptions();
var newOpts = new NatsOptions();
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
result.HasTlsChanges.ShouldBeTrue();
result.ChangeCount.ShouldBe(2);
}
// ─── ReloadTlsCertificates (TlsReloadResult) tests ─────────────────────────
[Fact]
public void No_cert_change_returns_no_change()
{
// Go parity: tlsConfigReload — identical cert path means no reload needed
var oldOpts = new NatsOptions { TlsCert = "/same/cert.pem" };
var newOpts = new NatsOptions { TlsCert = "/same/cert.pem" };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificateChanged.ShouldBeFalse();
result.CertificateLoaded.ShouldBeFalse();
result.Error.ShouldBeNull();
}
[Fact]
public void Cert_path_changed_detected()
{
// Go parity: tlsConfigReload — different cert path triggers reload
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificateChanged.ShouldBeTrue();
}
[Fact]
public void Cert_path_set_returns_path()
{
// Verify CertificatePath reflects the new cert path when changed
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificatePath.ShouldBe("/new/cert.pem");
}
[Fact]
public void Missing_cert_file_returns_error()
{
// Go parity: tlsConfigReload — non-existent cert path returns an error
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
var newOpts = new NatsOptions { TlsCert = "/nonexistent/path/cert.pem" };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.Error.ShouldNotBeNullOrWhiteSpace();
result.Error!.ShouldContain("/nonexistent/path/cert.pem");
result.CertificateLoaded.ShouldBeFalse();
}
[Fact]
public void Null_to_null_no_change()
{
// Both TlsCert null — no TLS configured on either side, no change
var oldOpts = new NatsOptions { TlsCert = null };
var newOpts = new NatsOptions { TlsCert = null };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificateChanged.ShouldBeFalse();
}
[Fact]
public void Null_to_value_detected()
{
// Go parity: TestConfigReloadEnableTLS — enabling TLS is detected as a change
var oldOpts = new NatsOptions { TlsCert = null };
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem" };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificateChanged.ShouldBeTrue();
}
[Fact]
public void Value_to_null_detected()
{
// Go parity: TestConfigReloadDisableTLS — disabling TLS is a change, loads successfully
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
var newOpts = new NatsOptions { TlsCert = null };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificateChanged.ShouldBeTrue();
result.CertificateLoaded.ShouldBeTrue();
result.Error.ShouldBeNull();
}
[Fact]
public void Valid_cert_path_loaded_true()
{
// Go parity: tlsConfigReload — file exists, so CertificateLoaded is true
var tempFile = Path.GetTempFileName();
try
{
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
var newOpts = new NatsOptions { TlsCert = tempFile };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificateChanged.ShouldBeTrue();
result.CertificateLoaded.ShouldBeTrue();
result.Error.ShouldBeNull();
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public void Error_null_on_success()
{
// Successful reload (file exists) must have Error as null
var tempFile = Path.GetTempFileName();
try
{
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem" };
var newOpts = new NatsOptions { TlsCert = tempFile };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.Error.ShouldBeNull();
}
finally
{
File.Delete(tempFile);
}
}
[Fact]
public void Same_empty_strings_no_change()
{
// Both TlsCert are empty string — treat as equal, no change
var oldOpts = new NatsOptions { TlsCert = string.Empty };
var newOpts = new NatsOptions { TlsCert = string.Empty };
var result = ConfigReloader.ReloadTlsCertificates(oldOpts, newOpts);
result.CertificateChanged.ShouldBeFalse();
}
/// <summary>
/// Helper to write a self-signed certificate to PEM files.
/// </summary>
private static void WriteSelfSignedCertFiles(string certPath, string keyPath, string cn)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));
File.WriteAllText(certPath, cert.ExportCertificatePem());
File.WriteAllText(keyPath, rsa.ExportRSAPrivateKeyPem());
}
}