feat(config): add system account, SIGHUP reload, and auth change propagation (E6+E7+E8)
E6: Add IsSystemAccount property to Account, mark $SYS account as system, add IsSystemSubject/IsSubscriptionAllowed/GetSubListForSubject helpers to route $SYS.> subjects to the system account's SubList and block non-system accounts from subscribing. E7: Add ConfigReloader.ReloadAsync and ApplyDiff for structured async reload, add ConfigReloadResult/ConfigApplyResult types. SIGHUP handler already wired via PosixSignalRegistration in HandleSignals. E8: Add PropagateAuthChanges to re-evaluate connected clients after auth config reload, disconnecting clients whose credentials no longer pass authentication with -ERR 'Authorization Violation'.
This commit is contained in:
394
tests/NATS.Server.Tests/Configuration/SignalReloadTests.cs
Normal file
394
tests/NATS.Server.Tests/Configuration/SignalReloadTests.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
// 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;
|
||||
|
||||
namespace NATS.Server.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 int GetFreePort()
|
||||
{
|
||||
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
||||
}
|
||||
|
||||
private static async Task<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<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeoutMs);
|
||||
var sb = new StringBuilder();
|
||||
var buf = new byte[4096];
|
||||
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
|
||||
{
|
||||
int n;
|
||||
try { n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
if (n == 0) break;
|
||||
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
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 = 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 = 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 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 = 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user