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:
@@ -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();
|
||||
}
|
||||
}
|
||||
391
tests/NATS.Server.Core.Tests/Configuration/AuthReloadTests.cs
Normal file
391
tests/NATS.Server.Core.Tests/Configuration/AuthReloadTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
169
tests/NATS.Server.Core.Tests/Configuration/LoggingReloadTests.cs
Normal file
169
tests/NATS.Server.Core.Tests/Configuration/LoggingReloadTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
2553
tests/NATS.Server.Core.Tests/Configuration/OptsGoParityTests.cs
Normal file
2553
tests/NATS.Server.Core.Tests/Configuration/OptsGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
1018
tests/NATS.Server.Core.Tests/Configuration/ReloadGoParityTests.cs
Normal file
1018
tests/NATS.Server.Core.Tests/Configuration/ReloadGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
372
tests/NATS.Server.Core.Tests/Configuration/SignalReloadTests.cs
Normal file
372
tests/NATS.Server.Core.Tests/Configuration/SignalReloadTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
386
tests/NATS.Server.Core.Tests/Configuration/TlsReloadTests.cs
Normal file
386
tests/NATS.Server.Core.Tests/Configuration/TlsReloadTests.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user