refactor: extract NATS.Server.LeafNodes.Tests project

Move 28 leaf node test files from NATS.Server.Tests into a dedicated
NATS.Server.LeafNodes.Tests project. Update namespaces, add
InternalsVisibleTo, register in solution file. Replace all Task.Delay
polling loops with PollHelper.WaitUntilAsync/YieldForAsync from
TestUtilities. Replace private ReadUntilAsync in LeafProtocolTests
with SocketTestHelper.ReadUntilAsync.

All 281 tests pass.
This commit is contained in:
Joseph Doherty
2026-03-12 15:23:33 -04:00
parent 9972b74bc3
commit 3f7d896a34
31 changed files with 132 additions and 243 deletions

View File

@@ -1,70 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class LeafAdvancedSemanticsTests
{
[Fact]
public async Task Leaf_loop_marker_blocks_reinjected_message_and_account_mapping_routes_to_expected_account()
{
const string serverId = "S1";
var marked = LeafLoopDetector.Mark("orders.created", serverId);
LeafLoopDetector.IsLooped(marked, serverId).ShouldBeTrue();
LeafLoopDetector.IsLooped(marked, "S2").ShouldBeFalse();
LeafLoopDetector.TryUnmark(marked, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("orders.created");
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var received = new TaskCompletionSource<RemoteSubscription>(TaskCreationOptions.RunContinuationsAsynchronously);
leaf.RemoteSubscriptionReceived = sub =>
{
received.TrySetResult(sub);
return Task.CompletedTask;
};
leaf.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ ACC_A leaf.>", timeout.Token);
var lsPlus = await received.Task.WaitAsync(timeout.Token);
lsPlus.Account.ShouldBe("ACC_A");
lsPlus.Subject.ShouldBe("leaf.>");
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
break;
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,138 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
using NATS.Server.Configuration;
namespace NATS.Server.Tests.LeafNodes;
public class LeafAccountScopedDeliveryTests
{
[Fact]
public async Task Remote_message_delivery_uses_target_account_sublist_not_global_sublist()
{
const string subject = "orders.created";
await using var fixture = await LeafAccountDeliveryFixture.StartAsync();
await using var remoteAccountA = await fixture.ConnectAsync(fixture.Spoke, "a_sub");
await using var remoteAccountB = await fixture.ConnectAsync(fixture.Spoke, "b_sub");
await using var publisher = await fixture.ConnectAsync(fixture.Hub, "a_pub");
await using var subA = await remoteAccountA.SubscribeCoreAsync<string>(subject);
await using var subB = await remoteAccountB.SubscribeCoreAsync<string>(subject);
await remoteAccountA.PingAsync();
await remoteAccountB.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("A", subject);
await publisher.PublishAsync(subject, "from-leaf-a");
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msgA = await subA.Msgs.ReadAsync(receiveTimeout.Token);
msgA.Data.ShouldBe("from-leaf-a");
using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await subB.Msgs.ReadAsync(leakTimeout.Token));
}
}
internal sealed class LeafAccountDeliveryFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _hubCts;
private readonly CancellationTokenSource _spokeCts;
private LeafAccountDeliveryFixture(NatsServer hub, NatsServer spoke, CancellationTokenSource hubCts, CancellationTokenSource spokeCts)
{
Hub = hub;
Spoke = spoke;
_hubCts = hubCts;
_spokeCts = spokeCts;
}
public NatsServer Hub { get; }
public NatsServer Spoke { get; }
public static async Task<LeafAccountDeliveryFixture> StartAsync()
{
var users = new User[]
{
new() { Username = "a_pub", Password = "pass", Account = "A" },
new() { Username = "a_sub", Password = "pass", Account = "A" },
new() { Username = "b_sub", Password = "pass", Account = "B" },
};
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users = users,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users = users,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
return new LeafAccountDeliveryFixture(hub, spoke, hubCts, spokeCts);
}
public async Task<NatsConnection> ConnectAsync(NatsServer server, string username)
{
var connection = new NatsConnection(new NatsOpts
{
Url = $"nats://{username}:pass@127.0.0.1:{server.Port}",
});
await connection.ConnectAsync();
return connection;
}
public async Task WaitForRemoteInterestOnHubAsync(string account, string subject)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested)
{
if (Hub.HasRemoteInterest(account, subject))
return;
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
}
public async ValueTask DisposeAsync()
{
await _hubCts.CancelAsync();
await _spokeCts.CancelAsync();
Hub.Dispose();
Spoke.Dispose();
_hubCts.Dispose();
_spokeCts.Dispose();
}
}

View File

@@ -1,180 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Basic leaf node hub-spoke connectivity tests.
/// Reference: golang/nats-server/server/leafnode_test.go — TestLeafNodeRemoteIsHub
/// Verifies that subscriptions propagate between hub and leaf (spoke) servers
/// and that messages are forwarded in both directions.
/// </summary>
public class LeafBasicTests
{
[Fact]
public async Task Leaf_node_forwards_subscriptions_to_hub()
{
// Arrange: start hub with a leaf node listener, then start a spoke that connects to hub
await using var fixture = await LeafBasicFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
// Subscribe on the leaf (spoke) side
await using var sub = await leafConn.SubscribeCoreAsync<string>("leaf.test");
await leafConn.PingAsync();
// Wait for the subscription interest to propagate to the hub
await fixture.WaitForRemoteInterestOnHubAsync("leaf.test");
// Publish on the hub side
await hubConn.PublishAsync("leaf.test", "from-hub");
// Assert: message arrives on the leaf
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
msg.Data.ShouldBe("from-hub");
}
[Fact]
public async Task Hub_forwards_subscriptions_to_leaf()
{
// Arrange: start hub with a leaf node listener, then start a spoke that connects to hub
await using var fixture = await LeafBasicFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await leafConn.ConnectAsync();
// Subscribe on the hub side
await using var sub = await hubConn.SubscribeCoreAsync<string>("hub.test");
await hubConn.PingAsync();
// Wait for the subscription interest to propagate to the spoke
await fixture.WaitForRemoteInterestOnSpokeAsync("hub.test");
// Publish on the leaf (spoke) side
await leafConn.PublishAsync("hub.test", "from-leaf");
// Assert: message arrives on the hub
using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token);
msg.Data.ShouldBe("from-leaf");
}
}
internal sealed class LeafBasicFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _hubCts;
private readonly CancellationTokenSource _spokeCts;
private LeafBasicFixture(NatsServer hub, NatsServer spoke, CancellationTokenSource hubCts, CancellationTokenSource spokeCts)
{
Hub = hub;
Spoke = spoke;
_hubCts = hubCts;
_spokeCts = spokeCts;
}
public NatsServer Hub { get; }
public NatsServer Spoke { get; }
public static async Task<LeafBasicFixture> StartAsync()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
// Wait for the leaf node connection to be established on both sides
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
return new LeafBasicFixture(hub, spoke, hubCts, spokeCts);
}
public async Task WaitForRemoteInterestOnHubAsync(string subject)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested)
{
if (Hub.HasRemoteInterest(subject))
return;
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
throw new TimeoutException($"Timed out waiting for remote interest on hub for '{subject}'.");
}
public async Task WaitForRemoteInterestOnSpokeAsync(string subject)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested)
{
if (Spoke.HasRemoteInterest(subject))
return;
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
}
throw new TimeoutException($"Timed out waiting for remote interest on spoke for '{subject}'.");
}
public async ValueTask DisposeAsync()
{
await _spokeCts.CancelAsync();
await _hubCts.CancelAsync();
Spoke.Dispose();
Hub.Dispose();
_spokeCts.Dispose();
_hubCts.Dispose();
}
}

View File

@@ -1,149 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for leaf cluster topology registration (Gap 12.6).
/// Verifies that <see cref="LeafNodeManager.RegisterLeafNodeCluster"/>,
/// <see cref="LeafNodeManager.UnregisterLeafNodeCluster"/>,
/// <see cref="LeafNodeManager.HasLeafNodeCluster"/>,
/// <see cref="LeafNodeManager.GetLeafNodeCluster"/>,
/// <see cref="LeafNodeManager.GetAllLeafClusters"/>,
/// <see cref="LeafNodeManager.LeafClusterCount"/>, and
/// <see cref="LeafNodeManager.UpdateLeafClusterConnectionCount"/>
/// correctly manage leaf cluster entries.
/// Go reference: leafnode.go registerLeafNodeCluster.
/// </summary>
public class LeafClusterRegistrationTests
{
private static LeafNodeManager CreateManager() =>
new(
options: new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
stats: new ServerStats(),
serverId: "test-server",
remoteSubSink: _ => { },
messageSink: _ => { },
logger: NullLogger<LeafNodeManager>.Instance);
// Go: leafnode.go registerLeafNodeCluster — first registration succeeds
[Fact]
public void RegisterLeafNodeCluster_NewCluster_ReturnsTrue()
{
var manager = CreateManager();
var result = manager.RegisterLeafNodeCluster("cluster-A", "nats://gateway:7222", 3);
result.ShouldBeTrue();
}
// Go: leafnode.go registerLeafNodeCluster — duplicate name returns false
[Fact]
public void RegisterLeafNodeCluster_Duplicate_ReturnsFalse()
{
var manager = CreateManager();
manager.RegisterLeafNodeCluster("cluster-A", "nats://gateway:7222", 3);
var result = manager.RegisterLeafNodeCluster("cluster-A", "nats://other:7222", 1);
result.ShouldBeFalse();
}
// Go: leafnode.go — cluster removal for existing entry returns true
[Fact]
public void UnregisterLeafNodeCluster_Existing_ReturnsTrue()
{
var manager = CreateManager();
manager.RegisterLeafNodeCluster("cluster-A", "nats://gateway:7222", 3);
var result = manager.UnregisterLeafNodeCluster("cluster-A");
result.ShouldBeTrue();
}
// Go: leafnode.go — cluster removal for absent entry returns false
[Fact]
public void UnregisterLeafNodeCluster_NonExistent_ReturnsFalse()
{
var manager = CreateManager();
var result = manager.UnregisterLeafNodeCluster("cluster-X");
result.ShouldBeFalse();
}
// Go: leafnode.go — HasLeafNodeCluster true after registration
[Fact]
public void HasLeafNodeCluster_Registered_ReturnsTrue()
{
var manager = CreateManager();
manager.RegisterLeafNodeCluster("cluster-A", "nats://gateway:7222", 3);
manager.HasLeafNodeCluster("cluster-A").ShouldBeTrue();
}
// Go: leafnode.go — HasLeafNodeCluster false when not registered
[Fact]
public void HasLeafNodeCluster_NotRegistered_ReturnsFalse()
{
var manager = CreateManager();
manager.HasLeafNodeCluster("cluster-X").ShouldBeFalse();
}
// Go: leafnode.go — GetLeafNodeCluster returns registered info
[Fact]
public void GetLeafNodeCluster_Found_ReturnsInfo()
{
var manager = CreateManager();
manager.RegisterLeafNodeCluster("cluster-A", "nats://gateway:7222", 5);
var info = manager.GetLeafNodeCluster("cluster-A");
info.ShouldNotBeNull();
info.ClusterName.ShouldBe("cluster-A");
info.GatewayUrl.ShouldBe("nats://gateway:7222");
info.ConnectionCount.ShouldBe(5);
}
// Go: leafnode.go — GetLeafNodeCluster returns null for absent cluster
[Fact]
public void GetLeafNodeCluster_NotFound_ReturnsNull()
{
var manager = CreateManager();
var info = manager.GetLeafNodeCluster("cluster-X");
info.ShouldBeNull();
}
// Go: leafnode.go — GetAllLeafClusters returns all registered entries
[Fact]
public void GetAllLeafClusters_ReturnsAll()
{
var manager = CreateManager();
manager.RegisterLeafNodeCluster("cluster-A", "nats://gw-a:7222", 2);
manager.RegisterLeafNodeCluster("cluster-B", "nats://gw-b:7222", 4);
var all = manager.GetAllLeafClusters();
all.Count.ShouldBe(2);
all.Select(c => c.ClusterName).ShouldContain("cluster-A");
all.Select(c => c.ClusterName).ShouldContain("cluster-B");
}
// Go: leafnode.go — UpdateLeafClusterConnectionCount updates the count on existing entry
[Fact]
public void UpdateLeafClusterConnectionCount_UpdatesCount()
{
var manager = CreateManager();
manager.RegisterLeafNodeCluster("cluster-A", "nats://gateway:7222", 1);
manager.UpdateLeafClusterConnectionCount("cluster-A", 7);
var info = manager.GetLeafNodeCluster("cluster-A");
info.ShouldNotBeNull();
info.ConnectionCount.ShouldBe(7);
}
}

View File

@@ -1,136 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
public class LeafConnectionAndRemoteConfigParityBatch1Tests
{
[Fact]
public async Task LeafConnection_role_helpers_reflect_connection_flags()
{
await using var connection = CreateConnection();
connection.IsSolicitedLeafNode().ShouldBeFalse();
connection.IsSpokeLeafNode().ShouldBeFalse();
connection.IsHubLeafNode().ShouldBeTrue();
connection.IsIsolatedLeafNode().ShouldBeFalse();
connection.IsSolicited = true;
connection.IsSpoke = true;
connection.Isolated = true;
connection.IsSolicitedLeafNode().ShouldBeTrue();
connection.IsSpokeLeafNode().ShouldBeTrue();
connection.IsHubLeafNode().ShouldBeFalse();
connection.IsIsolatedLeafNode().ShouldBeTrue();
}
[Fact]
public void RemoteLeafOptions_pick_next_url_round_robins()
{
var remote = new RemoteLeafOptions
{
Urls =
[
"nats://127.0.0.1:7422",
"nats://127.0.0.1:7423",
"nats://127.0.0.1:7424",
],
};
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7422");
remote.GetCurrentUrl().ShouldBe("nats://127.0.0.1:7422");
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7423");
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7424");
remote.PickNextUrl().ShouldBe("nats://127.0.0.1:7422");
}
[Fact]
public void RemoteLeafOptions_pick_next_url_without_entries_throws()
{
var remote = new RemoteLeafOptions();
Should.Throw<InvalidOperationException>(() => remote.PickNextUrl());
}
[Fact]
public void RemoteLeafOptions_saves_tls_hostname_and_user_password_from_url()
{
var remote = new RemoteLeafOptions();
remote.SaveTlsHostname("nats://leaf.example.com:7422");
remote.TlsName.ShouldBe("leaf.example.com");
remote.SaveUserPassword("nats://demo:secret@leaf.example.com:7422");
remote.Username.ShouldBe("demo");
remote.Password.ShouldBe("secret");
}
[Fact]
public void RemoteLeafOptions_connect_delay_round_trips()
{
var remote = new RemoteLeafOptions();
remote.GetConnectDelay().ShouldBe(TimeSpan.Zero);
remote.SetConnectDelay(TimeSpan.FromSeconds(30));
remote.GetConnectDelay().ShouldBe(TimeSpan.FromSeconds(30));
}
[Fact]
public void RemoteLeafNodeStillValid_checks_configured_and_disabled_remotes()
{
var manager = new LeafNodeManager(
new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = ["127.0.0.1:7422"],
RemoteLeaves =
[
new RemoteLeafOptions
{
Urls = ["nats://127.0.0.1:7423"],
},
],
},
new ServerStats(),
"S1",
_ => { },
_ => { },
NullLogger<LeafNodeManager>.Instance);
manager.RemoteLeafNodeStillValid("127.0.0.1:7422").ShouldBeTrue();
manager.RemoteLeafNodeStillValid("nats://127.0.0.1:7423").ShouldBeTrue();
manager.RemoteLeafNodeStillValid("127.0.0.1:7999").ShouldBeFalse();
manager.DisableLeafConnect("127.0.0.1:7422");
manager.RemoteLeafNodeStillValid("127.0.0.1:7422").ShouldBeFalse();
}
[Fact]
public void LeafNode_delay_constants_match_go_defaults()
{
LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected.ShouldBe(TimeSpan.FromSeconds(30));
LeafNodeManager.LeafNodeReconnectAfterPermViolation.ShouldBe(TimeSpan.FromSeconds(30));
LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame.ShouldBe(TimeSpan.FromSeconds(30));
LeafNodeManager.LeafNodeWaitBeforeClose.ShouldBe(TimeSpan.FromSeconds(5));
}
private static LeafConnection CreateConnection()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
client.Connect(endpoint);
var server = listener.AcceptSocket();
server.Dispose();
listener.Stop();
return new LeafConnection(client);
}
}

View File

@@ -1,181 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
public class LeafConnectionParityBatch3Tests
{
[Fact]
public async Task SendLeafConnect_writes_connect_json_payload_with_expected_fields()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
var info = new LeafConnectInfo
{
Jwt = "jwt-token",
Nkey = "nkey",
Sig = "sig",
Hub = true,
Cluster = "C1",
Headers = true,
JetStream = true,
Compression = "s2_auto",
RemoteAccount = "A",
Proto = 1,
};
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await leaf.SendLeafConnectAsync(info, timeout.Token);
var line = await ReadLineAsync(remoteSocket, timeout.Token);
line.ShouldStartWith("CONNECT ");
var payload = line["CONNECT ".Length..];
using var json = JsonDocument.Parse(payload);
var root = json.RootElement;
root.GetProperty("jwt").GetString().ShouldBe("jwt-token");
root.GetProperty("nkey").GetString().ShouldBe("nkey");
root.GetProperty("sig").GetString().ShouldBe("sig");
root.GetProperty("hub").GetBoolean().ShouldBeTrue();
root.GetProperty("cluster").GetString().ShouldBe("C1");
root.GetProperty("headers").GetBoolean().ShouldBeTrue();
root.GetProperty("jetstream").GetBoolean().ShouldBeTrue();
root.GetProperty("compression").GetString().ShouldBe("s2_auto");
root.GetProperty("remote_account").GetString().ShouldBe("A");
root.GetProperty("proto").GetInt32().ShouldBe(1);
}
[Fact]
public async Task RemoteCluster_returns_cluster_from_leaf_handshake_attributes()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE cluster=HUB-A domain=JS-A", timeout.Token);
await handshakeTask;
leaf.RemoteId.ShouldBe("REMOTE");
leaf.RemoteCluster().ShouldBe("HUB-A");
leaf.RemoteJetStreamDomain.ShouldBe("JS-A");
}
[Fact]
public async Task SetLeafConnectDelayIfSoliciting_sets_delay_only_for_solicited_connections()
{
await using var solicited = await CreateConnectionAsync();
solicited.IsSolicited = true;
solicited.SetLeafConnectDelayIfSoliciting(TimeSpan.FromSeconds(30));
solicited.GetConnectDelay().ShouldBe(TimeSpan.FromSeconds(30));
await using var inbound = await CreateConnectionAsync();
inbound.IsSolicited = false;
inbound.SetLeafConnectDelayIfSoliciting(TimeSpan.FromSeconds(30));
inbound.GetConnectDelay().ShouldBe(TimeSpan.Zero);
}
[Fact]
public async Task LeafProcessErr_maps_known_errors_to_reconnect_delays_on_solicited_connections()
{
await using var leaf = await CreateConnectionAsync();
leaf.IsSolicited = true;
leaf.LeafProcessErr("Permissions Violation for Subscription to foo");
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
leaf.LeafProcessErr("Loop detected");
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected);
leaf.LeafProcessErr("Cluster name is same");
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame);
}
[Fact]
public async Task LeafSubPermViolation_and_LeafPermViolation_set_permission_delay()
{
await using var leaf = await CreateConnectionAsync();
leaf.IsSolicited = true;
leaf.LeafSubPermViolation("subj.A");
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
leaf.SetLeafConnectDelayIfSoliciting(TimeSpan.Zero);
leaf.LeafPermViolation(pub: true, subj: "subj.B");
leaf.GetConnectDelay().ShouldBe(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
}
[Fact]
public async Task CancelMigrateTimer_stops_pending_timer_callback()
{
var remote = new RemoteLeafOptions();
using var signal = new SemaphoreSlim(0, 1);
remote.StartMigrateTimer(_ => signal.Release(), TimeSpan.FromMilliseconds(120));
remote.CancelMigrateTimer();
// The timer was disposed before its 120 ms deadline. We wait 300 ms via WaitAsync;
// if the callback somehow fires it will release the semaphore and WaitAsync returns
// true, which the assertion catches. No Task.Delay required.
var fired = await signal.WaitAsync(TimeSpan.FromMilliseconds(300));
fired.ShouldBeFalse();
}
private static async Task<LeafConnection> CreateConnectionAsync()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client.ConnectAsync(IPAddress.Loopback, endpoint.Port);
var server = await listener.AcceptSocketAsync();
listener.Stop();
client.Dispose();
return new LeafConnection(server);
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
throw new IOException("Connection closed while reading line");
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}

View File

@@ -1,132 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.LeafNodes;
public class LeafConnectionParityBatch4Tests
{
[Fact]
public async Task SendLsPlus_with_queue_weight_writes_weighted_frame()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
await leaf.SendLsPlusAsync("$G", "jobs.>", "workers", queueWeight: 3, timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LS+ $G jobs.> workers 3");
}
[Fact]
public async Task ReadLoop_parses_queue_weight_from_ls_plus()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var received = new List<RemoteSubscription>();
leaf.RemoteSubscriptionReceived = sub =>
{
received.Add(sub);
return Task.CompletedTask;
};
leaf.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ $G jobs.> workers 7", timeout.Token);
await WaitForAsync(() => received.Count >= 1, timeout.Token);
received[0].Subject.ShouldBe("jobs.>");
received[0].Queue.ShouldBe("workers");
received[0].QueueWeight.ShouldBe(7);
}
[Fact]
public async Task ReadLoop_defaults_invalid_queue_weight_to_one()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var endpoint = (IPEndPoint)listener.LocalEndpoint;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, endpoint.Port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var received = new List<RemoteSubscription>();
leaf.RemoteSubscriptionReceived = sub =>
{
received.Add(sub);
return Task.CompletedTask;
};
leaf.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ $G jobs.> workers 0", timeout.Token);
await WaitForAsync(() => received.Count >= 1, timeout.Token);
received[0].QueueWeight.ShouldBe(1);
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
throw new IOException("Connection closed while reading line");
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (predicate())
return;
await Task.Yield();
}
throw new TimeoutException("Timed out waiting for condition.");
}
}

View File

@@ -1,149 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for leaf connection disable flag (Gap 12.7).
/// Verifies that <see cref="LeafNodeManager.IsLeafConnectDisabled"/>,
/// <see cref="LeafNodeManager.DisableLeafConnect"/>, <see cref="LeafNodeManager.EnableLeafConnect"/>,
/// <see cref="LeafNodeManager.DisableAllLeafConnections"/>, and related APIs correctly track
/// per-remote and global disable state.
/// Go reference: leafnode.go isLeafConnectDisabled.
/// </summary>
public class LeafDisableTests
{
private static LeafNodeManager CreateManager() =>
new(
options: new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
stats: new ServerStats(),
serverId: "test-server",
remoteSubSink: _ => { },
messageSink: _ => { },
logger: NullLogger<LeafNodeManager>.Instance);
// Go: leafnode.go isLeafConnectDisabled — fresh manager has no disabled remotes
[Fact]
public void IsLeafConnectDisabled_NotDisabled_ReturnsFalse()
{
var manager = CreateManager();
manager.IsLeafConnectDisabled("nats://127.0.0.1:4222").ShouldBeFalse();
}
// Go: leafnode.go isLeafConnectDisabled — per-remote disable recorded
[Fact]
public void DisableLeafConnect_ThenIsDisabled_ReturnsTrue()
{
var manager = CreateManager();
manager.DisableLeafConnect("nats://127.0.0.1:4222");
manager.IsLeafConnectDisabled("nats://127.0.0.1:4222").ShouldBeTrue();
}
// Go: leafnode.go isLeafConnectDisabled — re-enable clears disable state
[Fact]
public void EnableLeafConnect_AfterDisable_ReturnsFalse()
{
var manager = CreateManager();
manager.DisableLeafConnect("nats://127.0.0.1:4222");
manager.EnableLeafConnect("nats://127.0.0.1:4222");
manager.IsLeafConnectDisabled("nats://127.0.0.1:4222").ShouldBeFalse();
}
// Go: leafnode.go isLeafConnectDisabled — each remote tracked independently
[Fact]
public void DisableLeafConnect_MultipleRemotes_TrackedSeparately()
{
var manager = CreateManager();
manager.DisableLeafConnect("nats://192.168.1.1:4222");
manager.DisableLeafConnect("nats://192.168.1.2:4222");
manager.IsLeafConnectDisabled("nats://192.168.1.1:4222").ShouldBeTrue();
manager.IsLeafConnectDisabled("nats://192.168.1.2:4222").ShouldBeTrue();
manager.IsLeafConnectDisabled("nats://192.168.1.3:4222").ShouldBeFalse();
}
// Go: leafnode.go isLeafConnectDisabled — global flag defaults to false
[Fact]
public void IsGloballyDisabled_Default_False()
{
var manager = CreateManager();
manager.IsGloballyDisabled.ShouldBeFalse();
}
// Go: leafnode.go isLeafConnectDisabled — DisableAllLeafConnections sets global flag
[Fact]
public void DisableAllLeafConnections_DisablesAll()
{
var manager = CreateManager();
manager.DisableAllLeafConnections("test reason");
manager.IsGloballyDisabled.ShouldBeTrue();
manager.IsLeafConnectDisabled("nats://127.0.0.1:4222").ShouldBeTrue();
manager.IsLeafConnectDisabled("nats://10.0.0.1:6222").ShouldBeTrue();
}
// Go: leafnode.go isLeafConnectDisabled — EnableAllLeafConnections clears global flag
[Fact]
public void EnableAllLeafConnections_ReEnables()
{
var manager = CreateManager();
manager.DisableAllLeafConnections();
manager.EnableAllLeafConnections();
manager.IsGloballyDisabled.ShouldBeFalse();
manager.IsLeafConnectDisabled("nats://127.0.0.1:4222").ShouldBeFalse();
}
// Go: leafnode.go isLeafConnectDisabled — global disable overrides non-disabled remote
[Fact]
public void IsLeafConnectDisabled_GlobalOverridesPerRemote()
{
var manager = CreateManager();
// Remote is NOT individually disabled — but global disable should still block it.
manager.DisableAllLeafConnections();
manager.IsLeafConnectDisabled("nats://127.0.0.1:4222").ShouldBeTrue();
}
// Go: leafnode.go isLeafConnectDisabled — GetDisabledRemotes lists all per-remote entries
[Fact]
public void GetDisabledRemotes_ReturnsAll()
{
var manager = CreateManager();
manager.DisableLeafConnect("nats://10.0.0.1:4222");
manager.DisableLeafConnect("nats://10.0.0.2:4222");
var disabled = manager.GetDisabledRemotes();
disabled.Count.ShouldBe(2);
disabled.ShouldContain("nats://10.0.0.1:4222");
disabled.ShouldContain("nats://10.0.0.2:4222");
}
// Go: leafnode.go isLeafConnectDisabled — DisabledRemoteCount matches number of disabled remotes
[Fact]
public void DisabledRemoteCount_MatchesDisabled()
{
var manager = CreateManager();
manager.DisabledRemoteCount.ShouldBe(0);
manager.DisableLeafConnect("nats://10.0.0.1:4222");
manager.DisabledRemoteCount.ShouldBe(1);
manager.DisableLeafConnect("nats://10.0.0.2:4222");
manager.DisabledRemoteCount.ShouldBe(2);
manager.EnableLeafConnect("nats://10.0.0.1:4222");
manager.DisabledRemoteCount.ShouldBe(1);
}
}

View File

@@ -1,21 +0,0 @@
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests;
public class LeafHubSpokeMappingParityTests
{
[Fact]
public void Leaf_hub_spoke_mapper_round_trips_account_mapping()
{
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>
{
["HUB"] = "SPOKE",
});
var outbound = mapper.Map("HUB", "orders.created", LeafMapDirection.Outbound);
outbound.Account.ShouldBe("SPOKE");
var inbound = mapper.Map("SPOKE", "orders.created", LeafMapDirection.Inbound);
inbound.Account.ShouldBe("HUB");
}
}

View File

@@ -1,87 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.LeafNodes;
public class LeafInterestIdempotencyTests
{
[Fact]
public async Task Duplicate_RSplus_or_reconnect_replay_does_not_double_count_remote_interest()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
using var subList = new SubList();
var remoteAdded = 0;
subList.InterestChanged += change =>
{
if (change.Kind == InterestChangeKind.RemoteAdded)
remoteAdded++;
};
leaf.RemoteSubscriptionReceived = sub =>
{
subList.ApplyRemoteSub(sub);
return Task.CompletedTask;
};
leaf.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ A orders.*", timeout.Token);
await WaitForAsync(() => subList.HasRemoteInterest("A", "orders.created"), timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ A orders.*", timeout.Token);
await Task.Delay(100, timeout.Token);
subList.MatchRemote("A", "orders.created").Count.ShouldBe(1);
remoteAdded.ShouldBe(1);
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
break;
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (predicate())
return;
await Task.Delay(20, ct);
}
throw new TimeoutException("Timed out waiting for condition.");
}
}

View File

@@ -1,231 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for JetStream migration checks on leaf node connections (Gap 12.4).
/// Verifies <see cref="LeafNodeManager.CheckJetStreamMigrate"/>,
/// <see cref="LeafNodeManager.GetActiveJetStreamDomains"/>,
/// <see cref="LeafNodeManager.IsJetStreamDomainInUse"/>, and
/// <see cref="LeafNodeManager.JetStreamEnabledConnectionCount"/>.
/// Go reference: leafnode.go checkJetStreamMigrate.
/// </summary>
public class LeafJetStreamMigrationTests
{
private static LeafNodeManager CreateManager(string serverId = "server-A") =>
new(
options: new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
stats: new ServerStats(),
serverId: serverId,
remoteSubSink: _ => { },
messageSink: _ => { },
logger: NullLogger<LeafNodeManager>.Instance);
/// <summary>
/// Creates a connected socket pair using a loopback TcpListener and returns both sockets.
/// The caller is responsible for disposing both.
/// </summary>
private static async Task<(Socket serverSide, Socket clientSide)> CreateSocketPairAsync()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
var serverSocket = await listener.AcceptSocketAsync();
listener.Stop();
return (serverSocket, clientSocket);
}
private static async Task<LeafConnection> CreateConnectionAsync(string remoteId, string? jsDomain = null)
{
var (serverSide, clientSide) = await CreateSocketPairAsync();
clientSide.Dispose();
var conn = new LeafConnection(serverSide)
{
RemoteId = remoteId,
JetStreamDomain = jsDomain,
};
return conn;
}
// Go: leafnode.go checkJetStreamMigrate — valid migration to a new unused domain
[Fact]
public async Task CheckJetStreamMigrate_Valid_NewDomain()
{
var manager = CreateManager();
await using var conn = await CreateConnectionAsync("server-B", jsDomain: "domain-old");
manager.InjectConnectionForTesting(conn);
var connectionId = manager.GetConnectionIds().Single();
var result = manager.CheckJetStreamMigrate(connectionId, "domain-new");
result.Valid.ShouldBeTrue();
result.Status.ShouldBe(JetStreamMigrationStatus.Valid);
result.Error.ShouldBeNull();
}
// Go: leafnode.go checkJetStreamMigrate — unknown connection ID
[Fact]
public void CheckJetStreamMigrate_ConnectionNotFound()
{
var manager = CreateManager();
var result = manager.CheckJetStreamMigrate("no-such-id", "some-domain");
result.Valid.ShouldBeFalse();
result.Status.ShouldBe(JetStreamMigrationStatus.ConnectionNotFound);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go checkJetStreamMigrate — clearing the domain (null) is always valid
[Fact]
public async Task CheckJetStreamMigrate_NullDomain_AlwaysValid()
{
var manager = CreateManager();
await using var conn = await CreateConnectionAsync("server-B", jsDomain: "domain-existing");
manager.InjectConnectionForTesting(conn);
var connectionId = manager.GetConnectionIds().Single();
var result = manager.CheckJetStreamMigrate(connectionId, null);
result.Valid.ShouldBeTrue();
result.Status.ShouldBe(JetStreamMigrationStatus.Valid);
result.Error.ShouldBeNull();
}
// Go: leafnode.go checkJetStreamMigrate — proposed domain matches current, no migration needed
[Fact]
public async Task CheckJetStreamMigrate_SameDomain_NoChangeNeeded()
{
var manager = CreateManager();
await using var conn = await CreateConnectionAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(conn);
var connectionId = manager.GetConnectionIds().Single();
var result = manager.CheckJetStreamMigrate(connectionId, "domain-hub");
result.Valid.ShouldBeTrue();
result.Status.ShouldBe(JetStreamMigrationStatus.NoChangeNeeded);
result.Error.ShouldBeNull();
}
// Go: leafnode.go checkJetStreamMigrate — another connection already uses the proposed domain
[Fact]
public async Task CheckJetStreamMigrate_DomainConflict()
{
var manager = CreateManager();
// conn-A already owns "domain-hub"
await using var connA = await CreateConnectionAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(connA);
// conn-B is on a different domain and wants to migrate to "domain-hub"
await using var connB = await CreateConnectionAsync("server-C", jsDomain: "domain-spoke");
manager.InjectConnectionForTesting(connB);
// Retrieve connection ID for server-C (conn-B)
var connectionIdB = manager.GetConnectionIds()
.Single(id => manager.GetConnectionByRemoteId("server-C") != null
&& _connections_ContainsKey(manager, id, "server-C"));
var result = manager.CheckJetStreamMigrate(connectionIdB, "domain-hub");
result.Valid.ShouldBeFalse();
result.Status.ShouldBe(JetStreamMigrationStatus.DomainConflict);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go — GetActiveJetStreamDomains returns distinct domains
[Fact]
public async Task GetActiveJetStreamDomains_ReturnsDistinct()
{
var manager = CreateManager();
await using var connA = await CreateConnectionAsync("server-B", jsDomain: "domain-hub");
await using var connB = await CreateConnectionAsync("server-C", jsDomain: "domain-hub");
await using var connC = await CreateConnectionAsync("server-D", jsDomain: "domain-spoke");
manager.InjectConnectionForTesting(connA);
manager.InjectConnectionForTesting(connB);
manager.InjectConnectionForTesting(connC);
var domains = manager.GetActiveJetStreamDomains();
domains.Count.ShouldBe(2);
domains.ShouldContain("domain-hub");
domains.ShouldContain("domain-spoke");
}
// Go: leafnode.go — GetActiveJetStreamDomains excludes connections without a domain
[Fact]
public async Task GetActiveJetStreamDomains_SkipsNull()
{
var manager = CreateManager();
await using var connWithDomain = await CreateConnectionAsync("server-B", jsDomain: "domain-hub");
await using var connWithoutDomain = await CreateConnectionAsync("server-C", jsDomain: null);
manager.InjectConnectionForTesting(connWithDomain);
manager.InjectConnectionForTesting(connWithoutDomain);
var domains = manager.GetActiveJetStreamDomains();
domains.Count.ShouldBe(1);
domains.ShouldContain("domain-hub");
}
// Go: leafnode.go — IsJetStreamDomainInUse returns true when domain is active
[Fact]
public async Task IsJetStreamDomainInUse_True()
{
var manager = CreateManager();
await using var conn = await CreateConnectionAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(conn);
manager.IsJetStreamDomainInUse("domain-hub").ShouldBeTrue();
}
// Go: leafnode.go — IsJetStreamDomainInUse returns false when domain is not active
[Fact]
public async Task IsJetStreamDomainInUse_False()
{
var manager = CreateManager();
await using var conn = await CreateConnectionAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(conn);
manager.IsJetStreamDomainInUse("domain-unknown").ShouldBeFalse();
}
// Go: leafnode.go — JetStreamEnabledConnectionCount counts only connections with a domain
[Fact]
public async Task JetStreamEnabledConnectionCount_CountsNonNull()
{
var manager = CreateManager();
await using var connA = await CreateConnectionAsync("server-B", jsDomain: "domain-hub");
await using var connB = await CreateConnectionAsync("server-C", jsDomain: null);
await using var connC = await CreateConnectionAsync("server-D", jsDomain: "domain-spoke");
manager.InjectConnectionForTesting(connA);
manager.InjectConnectionForTesting(connB);
manager.InjectConnectionForTesting(connC);
manager.JetStreamEnabledConnectionCount.ShouldBe(2);
}
// ── Internal helper ────────────────────────────────────────────────────────
/// <summary>
/// Checks whether the given connection key in the manager corresponds to the connection
/// with the specified RemoteId. Uses <see cref="LeafNodeManager.GetConnectionByRemoteId"/>
/// as an indirect lookup since the key is internal.
/// </summary>
private static bool _connections_ContainsKey(LeafNodeManager manager, string key, string remoteId)
{
// We use GetConnectionIds() and GetConnectionByRemoteId() — both public/internal.
// The key format is "remoteId:endpoint:guid" so we can prefix-check.
return key.StartsWith(remoteId + ":", StringComparison.Ordinal);
}
}

View File

@@ -1,18 +0,0 @@
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
public class LeafLoopTransparencyRuntimeTests
{
[Fact]
public void Transport_internal_reply_and_loop_markers_never_leak_to_client_visible_subjects()
{
var nested = LeafLoopDetector.Mark(
LeafLoopDetector.Mark("orders.created", "S1"),
"S2");
LeafLoopDetector.TryUnmark(nested, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("orders.created");
unmarked.ShouldNotStartWith("$LDS.");
}
}

View File

@@ -1,702 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
using NATS.Server.TestUtilities;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Advanced leaf node behavior tests: daisy chains, account scoping, concurrency,
/// multiple hub connections, and edge cases.
/// Reference: golang/nats-server/server/leafnode_test.go
/// </summary>
public class LeafNodeAdvancedTests
{
// Go: TestLeafNodeInterestPropagationDaisychain server/leafnode_test.go:3953
[Fact]
public async Task Daisy_chain_A_to_B_to_C_establishes_leaf_connections()
{
// A (hub) <- B (spoke/hub) <- C (spoke)
// Verify the three-server daisy chain topology connects correctly
var aOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var serverA = new NatsServer(aOptions, NullLoggerFactory.Instance);
var aCts = new CancellationTokenSource();
_ = serverA.StartAsync(aCts.Token);
await serverA.WaitForReadyAsync();
var bOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [serverA.LeafListen!],
},
};
var serverB = new NatsServer(bOptions, NullLoggerFactory.Instance);
var bCts = new CancellationTokenSource();
_ = serverB.StartAsync(bCts.Token);
await serverB.WaitForReadyAsync();
var cOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [serverB.LeafListen!],
},
};
var serverC = new NatsServer(cOptions, NullLoggerFactory.Instance);
var cCts = new CancellationTokenSource();
_ = serverC.StartAsync(cCts.Token);
await serverC.WaitForReadyAsync();
// Wait for leaf connections
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested
&& (serverA.Stats.Leafs == 0 || Interlocked.Read(ref serverB.Stats.Leafs) < 2 || serverC.Stats.Leafs == 0))
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
Interlocked.Read(ref serverA.Stats.Leafs).ShouldBe(1);
Interlocked.Read(ref serverB.Stats.Leafs).ShouldBeGreaterThanOrEqualTo(2);
Interlocked.Read(ref serverC.Stats.Leafs).ShouldBe(1);
// Verify each server has a unique ID
serverA.ServerId.ShouldNotBe(serverB.ServerId);
serverB.ServerId.ShouldNotBe(serverC.ServerId);
serverA.ServerId.ShouldNotBe(serverC.ServerId);
await cCts.CancelAsync();
await bCts.CancelAsync();
await aCts.CancelAsync();
serverC.Dispose();
serverB.Dispose();
serverA.Dispose();
cCts.Dispose();
bCts.Dispose();
aCts.Dispose();
}
// Go: TestLeafNodeDupeDeliveryQueueSubAndPlainSub server/leafnode_test.go:9634
[Fact]
public async Task Queue_sub_and_plain_sub_both_receive_from_hub()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
// Plain sub
await using var plainSub = await leafConn.SubscribeCoreAsync<string>("mixed.test");
// Queue sub
await using var queueSub = await leafConn.SubscribeCoreAsync<string>("mixed.test", queueGroup: "q1");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("mixed.test");
await hubConn.PublishAsync("mixed.test", "to-both");
// Both should receive
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var plainMsg = await plainSub.Msgs.ReadAsync(cts1.Token);
plainMsg.Data.ShouldBe("to-both");
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var queueMsg = await queueSub.Msgs.ReadAsync(cts2.Token);
queueMsg.Data.ShouldBe("to-both");
}
// Go: TestLeafNodeAccountNotFound server/leafnode_test.go:352
[Fact]
public async Task Account_scoped_messages_do_not_cross_accounts()
{
var users = new User[]
{
new() { Username = "user_a", Password = "pass", Account = "ACCT_A" },
new() { Username = "user_b", Password = "pass", Account = "ACCT_B" },
};
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users = users,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Users = users,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Subscribe with account A on spoke
await using var connA = new NatsConnection(new NatsOpts
{
Url = $"nats://user_a:pass@127.0.0.1:{spoke.Port}",
});
await connA.ConnectAsync();
await using var subA = await connA.SubscribeCoreAsync<string>("acct.test");
// Subscribe with account B on spoke
await using var connB = new NatsConnection(new NatsOpts
{
Url = $"nats://user_b:pass@127.0.0.1:{spoke.Port}",
});
await connB.ConnectAsync();
await using var subB = await connB.SubscribeCoreAsync<string>("acct.test");
await connA.PingAsync();
await connB.PingAsync();
// Wait for account A interest to propagate
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!interestTimeout.IsCancellationRequested && !hub.HasRemoteInterest("ACCT_A", "acct.test"))
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Publish from account A on hub
await using var pubA = new NatsConnection(new NatsOpts
{
Url = $"nats://user_a:pass@127.0.0.1:{hub.Port}",
});
await pubA.ConnectAsync();
await pubA.PublishAsync("acct.test", "for-A-only");
// Account A subscriber should receive
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msgA = await subA.Msgs.ReadAsync(cts.Token);
msgA.Data.ShouldBe("for-A-only");
// Account B subscriber should NOT receive
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await subB.Msgs.ReadAsync(leakCts.Token));
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
}
// Go: TestLeafNodePermissionsConcurrentAccess server/leafnode_test.go:1389
[Fact]
public async Task Concurrent_subscribe_unsubscribe_does_not_corrupt_interest_state()
{
await using var fixture = await LeafFixture.StartAsync();
var tasks = new List<Task>();
for (var i = 0; i < 10; i++)
{
var index = i;
tasks.Add(Task.Run(async () =>
{
await using var conn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await conn.ConnectAsync();
var sub = await conn.SubscribeCoreAsync<string>($"concurrent.{index}");
await conn.PingAsync();
await Task.Delay(50);
await sub.DisposeAsync();
await conn.PingAsync();
}));
}
await Task.WhenAll(tasks);
// After all subs are unsubscribed, interest should be gone
await Task.Delay(200);
for (var i = 0; i < 10; i++)
fixture.Hub.HasRemoteInterest($"concurrent.{i}").ShouldBeFalse();
}
// Go: TestLeafNodePubAllowedPruning server/leafnode_test.go:1452
[Fact]
public async Task Hub_publishes_rapidly_and_leaf_receives_all()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("rapid.test");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("rapid.test");
const int count = 50;
for (var i = 0; i < count; i++)
await hubConn.PublishAsync("rapid.test", $"r-{i}");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var received = 0;
while (received < count)
{
await sub.Msgs.ReadAsync(cts.Token);
received++;
}
received.ShouldBe(count);
}
// Go: TestLeafNodeSameLocalAccountToMultipleHubs server/leafnode_test.go:8983
[Fact]
public async Task Leaf_with_multiple_subscribers_on_same_subject_all_receive()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
var connections = new List<NatsConnection>();
var subs = new List<INatsSub<string>>();
try
{
for (var i = 0; i < 3; i++)
{
var conn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await conn.ConnectAsync();
connections.Add(conn);
var sub = await conn.SubscribeCoreAsync<string>("multi.sub.test");
subs.Add(sub);
await conn.PingAsync();
}
await fixture.WaitForRemoteInterestOnHubAsync("multi.sub.test");
await hubConn.PublishAsync("multi.sub.test", "fan-out");
// All 3 subscribers should receive
for (var i = 0; i < 3; i++)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await subs[i].Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("fan-out");
}
}
finally
{
foreach (var sub in subs)
await sub.DisposeAsync();
foreach (var conn in connections)
await conn.DisposeAsync();
}
}
// Go: TestLeafNodeHubWithGateways server/leafnode_test.go:1584
[Fact]
public async Task Server_info_shows_correct_leaf_connection_count()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(0);
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && hub.Stats.Leafs == 0)
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
await spokeCts.CancelAsync();
spoke.Dispose();
// After spoke disconnects, wait for count to drop
using var disconnTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!disconnTimeout.IsCancellationRequested && Interlocked.Read(ref hub.Stats.Leafs) > 0)
await Task.Delay(50, disconnTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(0);
await hubCts.CancelAsync();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
}
// Go: TestLeafNodeOriginClusterInfo server/leafnode_test.go:1942
[Fact]
public async Task Server_id_is_unique_between_hub_and_spoke()
{
await using var fixture = await LeafFixture.StartAsync();
fixture.Hub.ServerId.ShouldNotBeNullOrEmpty();
fixture.Spoke.ServerId.ShouldNotBeNullOrEmpty();
fixture.Hub.ServerId.ShouldNotBe(fixture.Spoke.ServerId);
}
// Go: TestLeafNodeNoDuplicateWithinCluster server/leafnode_test.go:2286
[Fact]
public async Task LeafListen_returns_correct_endpoint()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
hub.LeafListen.ShouldNotBeNull();
hub.LeafListen.ShouldStartWith("127.0.0.1:");
var parts = hub.LeafListen.Split(':');
parts.Length.ShouldBe(2);
int.TryParse(parts[1], out var port).ShouldBeTrue();
port.ShouldBeGreaterThan(0);
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
// Go: TestLeafNodeQueueGroupDistribution server/leafnode_test.go:4021
[Fact]
public async Task Queue_group_interest_from_two_spokes_both_propagate_to_hub()
{
await using var fixture = await TwoSpokeFixture.StartAsync();
await using var conn1 = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke1.Port}",
});
await conn1.ConnectAsync();
await using var conn2 = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke2.Port}",
});
await conn2.ConnectAsync();
// Queue subs on each spoke
await using var sub1 = await conn1.SubscribeCoreAsync<string>("dist.test", queueGroup: "workers");
await using var sub2 = await conn2.SubscribeCoreAsync<string>("dist.test", queueGroup: "workers");
await conn1.PingAsync();
await conn2.PingAsync();
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!interestTimeout.IsCancellationRequested && !fixture.Hub.HasRemoteInterest("dist.test"))
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Hub should have remote interest from at least one spoke
fixture.Hub.HasRemoteInterest("dist.test").ShouldBeTrue();
// Both spokes should track their own leaf connection
Interlocked.Read(ref fixture.Spoke1.Stats.Leafs).ShouldBeGreaterThan(0);
Interlocked.Read(ref fixture.Spoke2.Stats.Leafs).ShouldBeGreaterThan(0);
// Hub should have both leaf connections
Interlocked.Read(ref fixture.Hub.Stats.Leafs).ShouldBeGreaterThanOrEqualTo(2);
}
// Go: TestLeafNodeConfigureWriteDeadline server/leafnode_test.go:10802
[Fact]
public void LeafNodeOptions_defaults_to_empty_remotes_list()
{
var options = new LeafNodeOptions();
options.Remotes.ShouldNotBeNull();
options.Remotes.Count.ShouldBe(0);
options.Host.ShouldBe("0.0.0.0");
options.Port.ShouldBe(0);
}
// Go: TestLeafNodeValidateAuthOptions server/leafnode_test.go:583
[Fact]
public void NatsOptions_with_no_leaf_config_has_null_leaf()
{
var options = new NatsOptions();
options.LeafNode.ShouldBeNull();
}
// Go: TestLeafNodeAccountNotFound server/leafnode_test.go:352
[Fact]
public void NatsOptions_leaf_node_can_be_configured()
{
var options = new NatsOptions
{
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 5222,
Remotes = ["127.0.0.1:6222"],
},
};
options.LeafNode.ShouldNotBeNull();
options.LeafNode.Host.ShouldBe("127.0.0.1");
options.LeafNode.Port.ShouldBe(5222);
options.LeafNode.Remotes.Count.ShouldBe(1);
}
// Go: TestLeafNodePermissionWithLiteralSubjectAndQueueInterest server/leafnode_test.go:9935
[Fact]
public async Task Multiple_wildcard_subs_on_leaf_all_receive_matching_messages()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Spoke.Port}",
});
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await hubConn.ConnectAsync();
// Two different wildcard subs that both match the same subject
await using var sub1 = await leafConn.SubscribeCoreAsync<string>("multi.*.test");
await using var sub2 = await leafConn.SubscribeCoreAsync<string>("multi.>");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("multi.xyz.test");
await hubConn.PublishAsync("multi.xyz.test", "match-both");
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg1 = await sub1.Msgs.ReadAsync(cts1.Token);
msg1.Data.ShouldBe("match-both");
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg2 = await sub2.Msgs.ReadAsync(cts2.Token);
msg2.Data.ShouldBe("match-both");
}
// Go: TestLeafNodeExportPermissionsNotForSpecialSubs server/leafnode_test.go:1484
[Fact]
public async Task Leaf_node_hub_client_count_is_correct_with_multiple_clients()
{
await using var fixture = await LeafFixture.StartAsync();
var connections = new List<NatsConnection>();
try
{
for (var i = 0; i < 5; i++)
{
var conn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{fixture.Hub.Port}",
});
await conn.ConnectAsync();
connections.Add(conn);
}
fixture.Hub.ClientCount.ShouldBeGreaterThanOrEqualTo(5);
}
finally
{
foreach (var conn in connections)
await conn.DisposeAsync();
}
}
// Go: TestLeafNodeInterestPropagationDaisychain server/leafnode_test.go:3953
[Fact]
public async Task Leaf_server_port_is_nonzero_after_ephemeral_bind()
{
var options = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
server.Port.ShouldBeGreaterThan(0);
server.LeafListen.ShouldNotBeNull();
await cts.CancelAsync();
server.Dispose();
cts.Dispose();
}
// Go: TestLeafNodeRoutedSubKeyDifferentBetweenLeafSubAndRoutedSub server/leafnode_test.go:5602
[Fact]
public async Task Spoke_shutdown_reduces_hub_leaf_count()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && hub.Stats.Leafs == 0)
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
// Shut down spoke
await spokeCts.CancelAsync();
spoke.Dispose();
using var disconnTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!disconnTimeout.IsCancellationRequested && Interlocked.Read(ref hub.Stats.Leafs) > 0)
await Task.Delay(50, disconnTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(0);
await hubCts.CancelAsync();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
}
// Go: TestLeafNodeHubWithGateways server/leafnode_test.go:1584
[Fact]
public void LeafHubSpokeMapper_maps_accounts_in_both_directions()
{
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>
{
["HUB_ACCT"] = "SPOKE_ACCT",
["SYS"] = "SPOKE_SYS",
});
var outbound = mapper.Map("HUB_ACCT", "foo.bar", LeafMapDirection.Outbound);
outbound.Account.ShouldBe("SPOKE_ACCT");
outbound.Subject.ShouldBe("foo.bar");
var inbound = mapper.Map("SPOKE_ACCT", "foo.bar", LeafMapDirection.Inbound);
inbound.Account.ShouldBe("HUB_ACCT");
var sys = mapper.Map("SYS", "sys.event", LeafMapDirection.Outbound);
sys.Account.ShouldBe("SPOKE_SYS");
}
// Go: TestLeafNodeHubWithGateways server/leafnode_test.go:1584
[Fact]
public void LeafHubSpokeMapper_returns_original_for_unmapped_account()
{
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>
{
["KNOWN"] = "MAPPED",
});
var result = mapper.Map("UNKNOWN", "test", LeafMapDirection.Outbound);
result.Account.ShouldBe("UNKNOWN");
result.Subject.ShouldBe("test");
}
}

View File

@@ -1,538 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
using NATS.Server.TestUtilities;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for leaf node connection establishment, authentication, and lifecycle.
/// Reference: golang/nats-server/server/leafnode_test.go
/// </summary>
public class LeafNodeConnectionTests
{
// Go: TestLeafNodeBasicAuthSingleton server/leafnode_test.go:602
[Fact]
public async Task Leaf_node_connects_with_basic_hub_spoke_setup()
{
await using var fixture = await LeafFixture.StartAsync();
fixture.Hub.Stats.Leafs.ShouldBeGreaterThan(0);
fixture.Spoke.Stats.Leafs.ShouldBeGreaterThan(0);
}
// Go: TestLeafNodesBasicTokenAuth server/leafnode_test.go:10862
[Fact]
public async Task Leaf_node_connects_with_token_auth_on_hub()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
Authorization = "secret-token",
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
hub.Stats.Leafs.ShouldBeGreaterThan(0);
spoke.Stats.Leafs.ShouldBeGreaterThan(0);
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
}
// Go: TestLeafNodeBasicAuthSingleton server/leafnode_test.go:602
[Fact]
public async Task Leaf_node_connects_with_user_password_auth()
{
var users = new User[] { new() { Username = "leafuser", Password = "leafpass" } };
var hubOptions = new NatsOptions
{
Host = "127.0.0.1", Port = 0, Users = users,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1", Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0, Remotes = [hub.LeafListen!] },
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
hub.Stats.Leafs.ShouldBeGreaterThan(0);
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
}
// Go: TestLeafNodeRTT server/leafnode_test.go:488
[Fact]
public async Task Hub_and_spoke_both_report_leaf_connection_count()
{
await using var fixture = await LeafFixture.StartAsync();
Interlocked.Read(ref fixture.Hub.Stats.Leafs).ShouldBe(1);
Interlocked.Read(ref fixture.Spoke.Stats.Leafs).ShouldBe(1);
}
// Go: TestLeafNodeTwoRemotesToSameHubAccount server/leafnode_test.go:8758
[Fact]
public async Task Two_spoke_servers_can_connect_to_same_hub()
{
await using var fixture = await TwoSpokeFixture.StartAsync();
Interlocked.Read(ref fixture.Hub.Stats.Leafs).ShouldBeGreaterThanOrEqualTo(2);
Interlocked.Read(ref fixture.Spoke1.Stats.Leafs).ShouldBeGreaterThan(0);
Interlocked.Read(ref fixture.Spoke2.Stats.Leafs).ShouldBeGreaterThan(0);
}
// Go: TestLeafNodeRemoteWrongPort server/leafnode_test.go:1095
[Fact]
public async Task Outbound_handshake_completes_between_raw_sockets()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var acceptedSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(acceptedSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(clientSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(clientSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
leaf.RemoteId.ShouldBe("REMOTE");
}
// Go: TestLeafNodeCloseTLSConnection server/leafnode_test.go:968
[Fact]
public async Task Inbound_handshake_completes_between_raw_sockets()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var acceptedSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(acceptedSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformInboundHandshakeAsync("SERVER", timeout.Token);
await WriteLineAsync(clientSocket, "LEAF REMOTE_CLIENT", timeout.Token);
(await ReadLineAsync(clientSocket, timeout.Token)).ShouldBe("LEAF SERVER");
await handshakeTask;
leaf.RemoteId.ShouldBe("REMOTE_CLIENT");
}
// Go: TestLeafNodeNoPingBeforeConnect server/leafnode_test.go:3713
[Fact]
public async Task Leaf_connection_disposes_cleanly_without_starting_loop()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
using var acceptedSocket = await listener.AcceptSocketAsync();
var leaf = new LeafConnection(acceptedSocket);
await leaf.DisposeAsync();
var buffer = new byte[1];
var read = await clientSocket.ReceiveAsync(buffer, SocketFlags.None);
read.ShouldBe(0);
}
// Go: TestLeafNodeBannerNoClusterNameIfNoCluster server/leafnode_test.go:9803
[Fact]
public async Task Leaf_connection_sends_LS_plus_and_LS_minus()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
await leaf.SendLsPlusAsync("$G", "foo.bar", null, timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LS+ $G foo.bar");
await leaf.SendLsPlusAsync("$G", "foo.baz", "queue1", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LS+ $G foo.baz queue1");
await leaf.SendLsMinusAsync("$G", "foo.bar", null, timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LS- $G foo.bar");
}
// Go: TestLeafNodeLMsgSplit server/leafnode_test.go:2387
[Fact]
public async Task Leaf_connection_sends_LMSG()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var payload = "hello world"u8.ToArray();
await leaf.SendMessageAsync("$G", "test.subject", "reply-to", payload, timeout.Token);
var controlLine = await ReadLineAsync(remoteSocket, timeout.Token);
controlLine.ShouldBe($"LMSG $G test.subject reply-to {payload.Length}");
}
// Go: TestLeafNodeLMsgSplit server/leafnode_test.go:2387
[Fact]
public async Task Leaf_connection_sends_LMSG_with_no_reply()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var payload = "test"u8.ToArray();
await leaf.SendMessageAsync("ACCT", "subject", null, payload, timeout.Token);
var controlLine = await ReadLineAsync(remoteSocket, timeout.Token);
controlLine.ShouldBe($"LMSG ACCT subject - {payload.Length}");
}
// Go: TestLeafNodeLMsgSplit server/leafnode_test.go:2387
[Fact]
public async Task Leaf_connection_sends_LMSG_with_empty_payload()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
await leaf.SendMessageAsync("$G", "empty.msg", null, ReadOnlyMemory<byte>.Empty, timeout.Token);
var controlLine = await ReadLineAsync(remoteSocket, timeout.Token);
controlLine.ShouldBe("LMSG $G empty.msg - 0");
}
// Go: TestLeafNodeTmpClients server/leafnode_test.go:1663
[Fact]
public async Task Leaf_connection_receives_LS_plus_and_triggers_callback()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var received = new List<RemoteSubscription>();
leaf.RemoteSubscriptionReceived = sub => { received.Add(sub); return Task.CompletedTask; };
leaf.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ $G orders.>", timeout.Token);
await WaitForAsync(() => received.Count >= 1, timeout.Token);
received[0].Subject.ShouldBe("orders.>");
received[0].Account.ShouldBe("$G");
received[0].IsRemoval.ShouldBeFalse();
}
// Go: TestLeafNodeRouteParseLSUnsub server/leafnode_test.go:2486
[Fact]
public async Task Leaf_connection_receives_LS_minus_and_triggers_removal()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var received = new List<RemoteSubscription>();
leaf.RemoteSubscriptionReceived = sub => { received.Add(sub); return Task.CompletedTask; };
leaf.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ $G foo.bar", timeout.Token);
await WaitForAsync(() => received.Count >= 1, timeout.Token);
await WriteLineAsync(remoteSocket, "LS- $G foo.bar", timeout.Token);
await WaitForAsync(() => received.Count >= 2, timeout.Token);
received[1].Subject.ShouldBe("foo.bar");
received[1].IsRemoval.ShouldBeTrue();
}
// Go: TestLeafNodeLMsgSplit server/leafnode_test.go:2387
[Fact]
public async Task Leaf_connection_receives_LMSG_and_triggers_message_callback()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var messages = new List<LeafMessage>();
leaf.MessageReceived = msg => { messages.Add(msg); return Task.CompletedTask; };
leaf.StartLoop(timeout.Token);
var payload = "hello from remote"u8.ToArray();
await WriteLineAsync(remoteSocket, $"LMSG $G test.subject reply-to {payload.Length}", timeout.Token);
await remoteSocket.SendAsync(payload, SocketFlags.None, timeout.Token);
await remoteSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, timeout.Token);
await WaitForAsync(() => messages.Count >= 1, timeout.Token);
messages[0].Subject.ShouldBe("test.subject");
messages[0].ReplyTo.ShouldBe("reply-to");
messages[0].Account.ShouldBe("$G");
Encoding.ASCII.GetString(messages[0].Payload.Span).ShouldBe("hello from remote");
}
// Go: TestLeafNodeLMsgSplit server/leafnode_test.go:2387
[Fact]
public async Task Leaf_connection_receives_LMSG_with_account_scoped_format()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var messages = new List<LeafMessage>();
leaf.MessageReceived = msg => { messages.Add(msg); return Task.CompletedTask; };
leaf.StartLoop(timeout.Token);
var payload = "acct"u8.ToArray();
await WriteLineAsync(remoteSocket, $"LMSG MYACCT test.subject - {payload.Length}", timeout.Token);
await remoteSocket.SendAsync(payload, SocketFlags.None, timeout.Token);
await remoteSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, timeout.Token);
await WaitForAsync(() => messages.Count >= 1, timeout.Token);
messages[0].Account.ShouldBe("MYACCT");
messages[0].Subject.ShouldBe("test.subject");
messages[0].ReplyTo.ShouldBeNull();
}
// Go: TestLeafNodeTwoRemotesToSameHubAccount server/leafnode_test.go:2210
[Fact]
public async Task Leaf_connection_receives_LS_plus_with_queue()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var received = new List<RemoteSubscription>();
leaf.RemoteSubscriptionReceived = sub => { received.Add(sub); return Task.CompletedTask; };
leaf.StartLoop(timeout.Token);
await WriteLineAsync(remoteSocket, "LS+ $G work.> workers", timeout.Token);
await WaitForAsync(() => received.Count >= 1, timeout.Token);
received[0].Subject.ShouldBe("work.>");
received[0].Queue.ShouldBe("workers");
received[0].Account.ShouldBe("$G");
}
// Go: TestLeafNodeSlowConsumer server/leafnode_test.go:9103
[Fact]
public async Task Leaf_connection_handles_multiple_rapid_LMSG_messages()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, port);
using var leafSocket = await listener.AcceptSocketAsync();
await using var leaf = new LeafConnection(leafSocket);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldBe("LEAF LOCAL");
await WriteLineAsync(remoteSocket, "LEAF REMOTE", timeout.Token);
await handshakeTask;
var messageCount = 0;
leaf.MessageReceived = _ => { Interlocked.Increment(ref messageCount); return Task.CompletedTask; };
leaf.StartLoop(timeout.Token);
const int numMessages = 20;
for (var i = 0; i < numMessages; i++)
{
var payload = Encoding.ASCII.GetBytes($"msg-{i}");
var line = $"LMSG $G test.multi - {payload.Length}\r\n";
await remoteSocket.SendAsync(Encoding.ASCII.GetBytes(line), SocketFlags.None, timeout.Token);
await remoteSocket.SendAsync(payload, SocketFlags.None, timeout.Token);
await remoteSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, timeout.Token);
}
await WaitForAsync(() => Volatile.Read(ref messageCount) >= numMessages, timeout.Token);
Volatile.Read(ref messageCount).ShouldBe(numMessages);
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0) break;
if (single[0] == (byte)'\n') break;
if (single[0] != (byte)'\r') bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
private static async Task WaitForAsync(Func<bool> predicate, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
if (predicate()) return;
await Task.Delay(20, ct);
}
throw new TimeoutException("Timed out waiting for condition.");
}
}

View File

@@ -1,389 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
using NATS.Server.TestUtilities;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for message forwarding through leaf node connections (hub-to-leaf, leaf-to-hub, leaf-to-leaf).
/// Reference: golang/nats-server/server/leafnode_test.go
/// </summary>
public class LeafNodeForwardingTests
{
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
[Fact]
public async Task Hub_publishes_message_reaches_leaf_subscriber()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("forward.test");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("forward.test");
await hubConn.PublishAsync("forward.test", "from-hub");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("from-hub");
}
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
[Fact]
public async Task Leaf_publishes_message_reaches_hub_subscriber()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var sub = await hubConn.SubscribeCoreAsync<string>("forward.hub");
await hubConn.PingAsync();
await fixture.WaitForRemoteInterestOnSpokeAsync("forward.hub");
await leafConn.PublishAsync("forward.hub", "from-leaf");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("from-leaf");
}
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
[Fact]
public async Task Message_published_on_leaf_does_not_loop_back_via_hub()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("noloop.test");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("noloop.test");
await leafConn.PublishAsync("noloop.test", "from-leaf");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("from-leaf");
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await sub.Msgs.ReadAsync(leakCts.Token));
}
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
[Fact]
public async Task Multiple_messages_forwarded_from_hub_each_arrive_once()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("multi.test");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("multi.test");
const int count = 10;
for (var i = 0; i < count; i++)
await hubConn.PublishAsync("multi.test", $"msg-{i}");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var received = new List<string>();
for (var i = 0; i < count; i++)
{
var msg = await sub.Msgs.ReadAsync(cts.Token);
received.Add(msg.Data!);
}
received.Count.ShouldBe(count);
for (var i = 0; i < count; i++)
received.ShouldContain($"msg-{i}");
}
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
[Fact]
public async Task Bidirectional_forwarding_hub_and_leaf_can_exchange_messages()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubSub = await hubConn.SubscribeCoreAsync<string>("bidir.hub");
await using var leafSub = await leafConn.SubscribeCoreAsync<string>("bidir.leaf");
await hubConn.PingAsync();
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnSpokeAsync("bidir.hub");
await fixture.WaitForRemoteInterestOnHubAsync("bidir.leaf");
await leafConn.PublishAsync("bidir.hub", "leaf-to-hub");
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await hubSub.Msgs.ReadAsync(cts1.Token)).Data.ShouldBe("leaf-to-hub");
await hubConn.PublishAsync("bidir.leaf", "hub-to-leaf");
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await leafSub.Msgs.ReadAsync(cts2.Token)).Data.ShouldBe("hub-to-leaf");
}
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
[Fact]
public async Task Two_spokes_interest_propagates_to_hub()
{
await using var fixture = await TwoSpokeFixture.StartAsync();
await using var spoke1Conn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke1.Port}" });
await spoke1Conn.ConnectAsync();
await using var spoke2Conn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke2.Port}" });
await spoke2Conn.ConnectAsync();
await using var sub1 = await spoke1Conn.SubscribeCoreAsync<string>("spoke1.interest");
await using var sub2 = await spoke2Conn.SubscribeCoreAsync<string>("spoke2.interest");
await spoke1Conn.PingAsync();
await spoke2Conn.PingAsync();
// Both spokes' interests should propagate to the hub
using var waitCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitCts.IsCancellationRequested
&& (!fixture.Hub.HasRemoteInterest("spoke1.interest") || !fixture.Hub.HasRemoteInterest("spoke2.interest")))
await Task.Delay(50, waitCts.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Hub.HasRemoteInterest("spoke1.interest").ShouldBeTrue();
fixture.Hub.HasRemoteInterest("spoke2.interest").ShouldBeTrue();
}
// Go: TestLeafNodeRemoteIsHub server/leafnode_test.go:1177
[Fact]
public async Task Large_payload_forwarded_correctly_through_leaf_node()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<byte[]>("large.payload");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("large.payload");
var largePayload = new byte[10240];
Random.Shared.NextBytes(largePayload);
await hubConn.PublishAsync("large.payload", largePayload);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldNotBeNull();
msg.Data!.Length.ShouldBe(largePayload.Length);
msg.Data.ShouldBe(largePayload);
}
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
// Note: Request-reply across leaf nodes requires _INBOX reply subject
// interest propagation which needs the hub to forward reply-to messages
// back to the requester. This is a more complex scenario tested at
// the integration level when full reply routing is implemented.
[Fact]
public async Task Reply_subject_from_hub_reaches_leaf_subscriber()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var requestSub = await leafConn.SubscribeCoreAsync<string>("request.test");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("request.test");
// Publish with a reply-to from hub
await hubConn.PublishAsync("request.test", "hello", replyTo: "reply.subject");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await requestSub.Msgs.ReadAsync(cts.Token);
msg.Data.ShouldBe("hello");
// The reply-to may or may not be propagated depending on implementation
// At minimum, the message itself should arrive
}
// Go: TestLeafNodeDuplicateMsg server/leafnode_test.go:6513
[Fact]
public async Task Subscriber_on_both_hub_and_leaf_receives_message_once_each()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubSub = await hubConn.SubscribeCoreAsync<string>("both.test");
await using var leafSub = await leafConn.SubscribeCoreAsync<string>("both.test");
await hubConn.PingAsync();
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("both.test");
await using var pubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await pubConn.ConnectAsync();
await pubConn.PublishAsync("both.test", "dual");
using var cts1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await hubSub.Msgs.ReadAsync(cts1.Token)).Data.ShouldBe("dual");
using var cts2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await leafSub.Msgs.ReadAsync(cts2.Token)).Data.ShouldBe("dual");
}
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
[Fact]
public async Task Hub_subscriber_receives_leaf_message_with_correct_subject()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var sub = await hubConn.SubscribeCoreAsync<string>("subject.check");
await hubConn.PingAsync();
await fixture.WaitForRemoteInterestOnSpokeAsync("subject.check");
await leafConn.PublishAsync("subject.check", "payload");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(cts.Token);
msg.Subject.ShouldBe("subject.check");
msg.Data.ShouldBe("payload");
}
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
[Fact]
public async Task No_message_received_when_no_subscriber_on_leaf()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await hubConn.PublishAsync("no.subscriber", "lost");
await Task.Delay(200);
true.ShouldBeTrue(); // No crash = success
}
// Go: TestLeafNodeNoMsgLoop server/leafnode_test.go:3800
[Fact]
public async Task Empty_payload_forwarded_correctly_through_leaf_node()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<byte[]>("empty.payload");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("empty.payload");
await hubConn.PublishAsync<byte[]>("empty.payload", []);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(cts.Token);
msg.Subject.ShouldBe("empty.payload");
}
}
internal sealed class TwoSpokeFixture : IAsyncDisposable
{
private readonly CancellationTokenSource _hubCts;
private readonly CancellationTokenSource _spoke1Cts;
private readonly CancellationTokenSource _spoke2Cts;
private TwoSpokeFixture(NatsServer hub, NatsServer spoke1, NatsServer spoke2,
CancellationTokenSource hubCts, CancellationTokenSource spoke1Cts, CancellationTokenSource spoke2Cts)
{
Hub = hub;
Spoke1 = spoke1;
Spoke2 = spoke2;
_hubCts = hubCts;
_spoke1Cts = spoke1Cts;
_spoke2Cts = spoke2Cts;
}
public NatsServer Hub { get; }
public NatsServer Spoke1 { get; }
public NatsServer Spoke2 { get; }
public static async Task<TwoSpokeFixture> StartAsync()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1", Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spoke1Options = new NatsOptions
{
Host = "127.0.0.1", Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0, Remotes = [hub.LeafListen!] },
};
var spoke1 = new NatsServer(spoke1Options, NullLoggerFactory.Instance);
var spoke1Cts = new CancellationTokenSource();
_ = spoke1.StartAsync(spoke1Cts.Token);
await spoke1.WaitForReadyAsync();
var spoke2Options = new NatsOptions
{
Host = "127.0.0.1", Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0, Remotes = [hub.LeafListen!] },
};
var spoke2 = new NatsServer(spoke2Options, NullLoggerFactory.Instance);
var spoke2Cts = new CancellationTokenSource();
_ = spoke2.StartAsync(spoke2Cts.Token);
await spoke2.WaitForReadyAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested
&& (Interlocked.Read(ref hub.Stats.Leafs) < 2
|| spoke1.Stats.Leafs == 0
|| spoke2.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
return new TwoSpokeFixture(hub, spoke1, spoke2, hubCts, spoke1Cts, spoke2Cts);
}
public async ValueTask DisposeAsync()
{
await _spoke2Cts.CancelAsync();
await _spoke1Cts.CancelAsync();
await _hubCts.CancelAsync();
Spoke2.Dispose();
Spoke1.Dispose();
Hub.Dispose();
_spoke2Cts.Dispose();
_spoke1Cts.Dispose();
_hubCts.Dispose();
}
}

View File

@@ -1,345 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Configuration;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for JetStream behavior over leaf node connections.
/// Reference: golang/nats-server/server/leafnode_test.go — TestLeafNodeJetStreamDomainMapCrossTalk, etc.
/// </summary>
public class LeafNodeJetStreamTests
{
// Go: TestLeafNodeJetStreamDomainMapCrossTalk server/leafnode_test.go:5948
[Fact]
public async Task JetStream_API_requests_reach_hub_with_JS_enabled()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-hub-{Guid.NewGuid():N}") },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
hub.Stats.JetStreamEnabled.ShouldBeTrue();
// Verify hub counts leaf
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
// Clean up store dir
if (Directory.Exists(hubOptions.JetStream.StoreDir))
Directory.Delete(hubOptions.JetStream.StoreDir, true);
}
// Go: TestLeafNodeJetStreamDomainMapCrossTalk server/leafnode_test.go:5948
[Fact]
public async Task JetStream_on_hub_receives_messages_published_from_leaf()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-leaf-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = storeDir },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Subscribe on hub for a subject
await using var hubConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{hub.Port}",
});
await hubConn.ConnectAsync();
await using var sub = await hubConn.SubscribeCoreAsync<string>("js.leaf.test");
await hubConn.PingAsync();
// Wait for interest propagation
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!interestTimeout.IsCancellationRequested && !spoke.HasRemoteInterest("js.leaf.test"))
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Publish from spoke
await using var spokeConn = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{spoke.Port}",
});
await spokeConn.ConnectAsync();
await spokeConn.PublishAsync("js.leaf.test", "from-leaf-to-js");
using var msgTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(msgTimeout.Token);
msg.Data.ShouldBe("from-leaf-to-js");
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(storeDir))
Directory.Delete(storeDir, true);
}
// Go: TestLeafNodeStreamImport server/leafnode_test.go:3441
[Fact]
public async Task Leaf_node_with_JetStream_disabled_spoke_still_forwards_messages()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-fwd-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = storeDir },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
// Spoke without JetStream
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
hub.Stats.JetStreamEnabled.ShouldBeTrue();
spoke.Stats.JetStreamEnabled.ShouldBeFalse();
// Subscribe on hub
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await hubConn.SubscribeCoreAsync<string>("njs.forward");
await hubConn.PingAsync();
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!interestTimeout.IsCancellationRequested && !spoke.HasRemoteInterest("njs.forward"))
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Publish from spoke
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await spokeConn.ConnectAsync();
await spokeConn.PublishAsync("njs.forward", "no-js-spoke");
using var msgTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(msgTimeout.Token);
msg.Data.ShouldBe("no-js-spoke");
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(storeDir))
Directory.Delete(storeDir, true);
}
// Go: TestLeafNodeJetStreamDomainMapCrossTalk server/leafnode_test.go:5948
[Fact]
public async Task Both_hub_and_spoke_with_JetStream_enabled_connect_successfully()
{
var hubStoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-hub2-{Guid.NewGuid():N}");
var spokeStoreDir = Path.Combine(Path.GetTempPath(), $"nats-js-spoke2-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = hubStoreDir },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
JetStream = new JetStreamOptions { StoreDir = spokeStoreDir },
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
hub.Stats.JetStreamEnabled.ShouldBeTrue();
spoke.Stats.JetStreamEnabled.ShouldBeTrue();
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(hubStoreDir))
Directory.Delete(hubStoreDir, true);
if (Directory.Exists(spokeStoreDir))
Directory.Delete(spokeStoreDir, true);
}
// Go: TestLeafNodeStreamAndShadowSubs server/leafnode_test.go:6176
[Fact]
public async Task Leaf_node_message_forwarding_works_alongside_JetStream()
{
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-js-combo-{Guid.NewGuid():N}");
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
JetStream = new JetStreamOptions { StoreDir = storeDir },
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var waitTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!waitTimeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, waitTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
// Regular pub/sub should still work alongside JS
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("combo.test");
await leafConn.PingAsync();
using var interestTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!interestTimeout.IsCancellationRequested && !hub.HasRemoteInterest("combo.test"))
await Task.Delay(50, interestTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await hubConn.PublishAsync("combo.test", "js-combo");
using var msgTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(msgTimeout.Token);
msg.Data.ShouldBe("js-combo");
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
if (Directory.Exists(storeDir))
Directory.Delete(storeDir, true);
}
}

View File

@@ -1,179 +0,0 @@
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for leaf node loop detection via $LDS. prefix.
/// Reference: golang/nats-server/server/leafnode_test.go
/// </summary>
public class LeafNodeLoopDetectionTests
{
// Go: TestLeafNodeLoop server/leafnode_test.go:837
[Fact]
public void HasLoopMarker_returns_true_for_marked_subject()
{
var marked = LeafLoopDetector.Mark("orders.created", "SERVER1");
LeafLoopDetector.HasLoopMarker(marked).ShouldBeTrue();
}
[Fact]
public void HasLoopMarker_returns_false_for_plain_subject()
{
LeafLoopDetector.HasLoopMarker("orders.created").ShouldBeFalse();
}
[Fact]
public void Mark_prepends_LDS_prefix_with_server_id()
{
LeafLoopDetector.Mark("foo.bar", "ABC123").ShouldBe("$LDS.ABC123.foo.bar");
}
[Fact]
public void IsLooped_returns_true_when_subject_contains_own_server_id()
{
var marked = LeafLoopDetector.Mark("foo.bar", "MYSERVER");
LeafLoopDetector.IsLooped(marked, "MYSERVER").ShouldBeTrue();
}
[Fact]
public void IsLooped_returns_false_when_subject_contains_different_server_id()
{
var marked = LeafLoopDetector.Mark("foo.bar", "OTHER");
LeafLoopDetector.IsLooped(marked, "MYSERVER").ShouldBeFalse();
}
// Go: TestLeafNodeLoopDetectionOnActualLoop server/leafnode_test.go:9410
[Fact]
public void TryUnmark_extracts_original_subject_from_single_mark()
{
var marked = LeafLoopDetector.Mark("orders.created", "S1");
LeafLoopDetector.TryUnmark(marked, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("orders.created");
}
[Fact]
public void TryUnmark_extracts_original_subject_from_nested_marks()
{
var nested = LeafLoopDetector.Mark(LeafLoopDetector.Mark("data.stream", "S1"), "S2");
LeafLoopDetector.TryUnmark(nested, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("data.stream");
}
[Fact]
public void TryUnmark_extracts_original_from_triple_nested_marks()
{
var tripleNested = LeafLoopDetector.Mark(
LeafLoopDetector.Mark(LeafLoopDetector.Mark("test.subject", "S1"), "S2"), "S3");
LeafLoopDetector.TryUnmark(tripleNested, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("test.subject");
}
[Fact]
public void TryUnmark_returns_false_for_unmarked_subject()
{
LeafLoopDetector.TryUnmark("orders.created", out var unmarked).ShouldBeFalse();
unmarked.ShouldBe("orders.created");
}
[Fact]
public void Mark_preserves_dot_separated_structure()
{
var marked = LeafLoopDetector.Mark("a.b.c.d", "SRV");
marked.ShouldStartWith("$LDS.SRV.");
marked.ShouldEndWith("a.b.c.d");
}
// Go: TestLeafNodeLoopDetectionWithMultipleClusters server/leafnode_test.go:3546
[Fact]
public void IsLooped_detects_loop_in_nested_marks()
{
var marked = LeafLoopDetector.Mark(LeafLoopDetector.Mark("test", "REMOTE"), "LOCAL");
LeafLoopDetector.IsLooped(marked, "LOCAL").ShouldBeTrue();
LeafLoopDetector.IsLooped(marked, "REMOTE").ShouldBeFalse();
}
[Fact]
public void HasLoopMarker_works_with_prefix_only()
{
LeafLoopDetector.HasLoopMarker("$LDS.").ShouldBeTrue();
}
[Fact]
public void IsLooped_returns_false_for_plain_subject()
{
LeafLoopDetector.IsLooped("plain.subject", "MYSERVER").ShouldBeFalse();
}
[Fact]
public void Mark_with_single_token_subject()
{
var marked = LeafLoopDetector.Mark("simple", "S1");
marked.ShouldBe("$LDS.S1.simple");
LeafLoopDetector.TryUnmark(marked, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("simple");
}
// Go: TestLeafNodeLoopFromDAG server/leafnode_test.go:899
[Fact]
public void Multiple_servers_in_chain_each_add_their_mark()
{
var original = "data.stream";
var fromS1 = LeafLoopDetector.Mark(original, "S1");
fromS1.ShouldBe("$LDS.S1.data.stream");
var fromS2 = LeafLoopDetector.Mark(fromS1, "S2");
fromS2.ShouldBe("$LDS.S2.$LDS.S1.data.stream");
LeafLoopDetector.IsLooped(fromS2, "S2").ShouldBeTrue();
LeafLoopDetector.IsLooped(fromS2, "S1").ShouldBeFalse();
LeafLoopDetector.TryUnmark(fromS2, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("data.stream");
}
[Fact]
public void Roundtrip_mark_unmark_preserves_original()
{
var subjects = new[] { "foo", "foo.bar", "foo.bar.baz", "a.b.c.d.e", "single", "with.*.wildcard", "with.>" };
foreach (var subject in subjects)
{
var marked = LeafLoopDetector.Mark(subject, "TESTSRV");
LeafLoopDetector.TryUnmark(marked, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe(subject, $"Failed roundtrip for: {subject}");
}
}
[Fact]
public void Four_server_chain_marks_and_unmarks_correctly()
{
var step1 = LeafLoopDetector.Mark("test", "A");
var step2 = LeafLoopDetector.Mark(step1, "B");
var step3 = LeafLoopDetector.Mark(step2, "C");
var step4 = LeafLoopDetector.Mark(step3, "D");
LeafLoopDetector.IsLooped(step4, "D").ShouldBeTrue();
LeafLoopDetector.IsLooped(step4, "C").ShouldBeFalse();
LeafLoopDetector.IsLooped(step4, "B").ShouldBeFalse();
LeafLoopDetector.IsLooped(step4, "A").ShouldBeFalse();
LeafLoopDetector.TryUnmark(step4, out var unmarked).ShouldBeTrue();
unmarked.ShouldBe("test");
}
[Fact]
public void HasLoopMarker_is_case_sensitive()
{
LeafLoopDetector.HasLoopMarker("$LDS.SRV.foo").ShouldBeTrue();
LeafLoopDetector.HasLoopMarker("$lds.SRV.foo").ShouldBeFalse();
}
// Go: TestLeafNodeLoopDetectedOnAcceptSide server/leafnode_test.go:1522
[Fact]
public void IsLooped_is_case_sensitive_for_server_id()
{
var marked = LeafLoopDetector.Mark("foo", "MYSERVER");
LeafLoopDetector.IsLooped(marked, "MYSERVER").ShouldBeTrue();
LeafLoopDetector.IsLooped(marked, "myserver").ShouldBeFalse();
}
}

View File

@@ -1,135 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
public class LeafNodeManagerParityBatch5Tests
{
[Fact]
[SlopwatchSuppress("SW004", "Delay verifies a blocked subject is NOT forwarded; absence of a frame cannot be observed via synchronization primitives")]
public async Task PropagateLocalSubscription_enforces_spoke_subscribe_permissions_and_keeps_queue_weight()
{
await using var ctx = await CreateManagerWithInboundConnectionAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var conn = ctx.Manager.GetConnectionByRemoteId("SPOKE1");
conn.ShouldNotBeNull();
conn!.IsSpoke = true;
var sync = ctx.Manager.SendPermsAndAccountInfo(
ctx.ConnectionId,
"$G",
pubAllow: null,
subAllow: ["allowed.>"]);
sync.Found.ShouldBeTrue();
sync.PermsSynced.ShouldBeTrue();
ctx.Manager.PropagateLocalSubscription("$G", "blocked.data", null);
ctx.Manager.PropagateLocalSubscription("$G", "allowed.data", "workers", queueWeight: 4);
// Only the allowed subject should appear on the wire; the blocked one is filtered synchronously.
var line = await ReadLineAsync(ctx.RemoteSocket, timeout.Token);
line.ShouldBe("LS+ $G allowed.data workers 4");
}
[Fact]
public async Task PropagateLocalSubscription_allows_loop_and_gateway_reply_prefixes_for_spoke()
{
await using var ctx = await CreateManagerWithInboundConnectionAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var conn = ctx.Manager.GetConnectionByRemoteId("SPOKE1");
conn.ShouldNotBeNull();
conn!.IsSpoke = true;
ctx.Manager.SendPermsAndAccountInfo(
ctx.ConnectionId,
"$G",
pubAllow: null,
subAllow: ["allowed.>"]);
ctx.Manager.PropagateLocalSubscription("$G", "$LDS.HUB.loop", null);
(await ReadLineAsync(ctx.RemoteSocket, timeout.Token)).ShouldBe("LS+ $G $LDS.HUB.loop");
ctx.Manager.PropagateLocalSubscription("$G", "_GR_.A.reply", null);
(await ReadLineAsync(ctx.RemoteSocket, timeout.Token)).ShouldBe("LS+ $G _GR_.A.reply");
}
private sealed class ManagerContext : IAsyncDisposable
{
private readonly CancellationTokenSource _cts;
public ManagerContext(LeafNodeManager manager, string connectionId, Socket remoteSocket, CancellationTokenSource cts)
{
Manager = manager;
ConnectionId = connectionId;
RemoteSocket = remoteSocket;
_cts = cts;
}
public LeafNodeManager Manager { get; }
public string ConnectionId { get; }
public Socket RemoteSocket { get; }
public async ValueTask DisposeAsync()
{
RemoteSocket.Close();
await Manager.DisposeAsync();
_cts.Dispose();
}
}
private static async Task<ManagerContext> CreateManagerWithInboundConnectionAsync()
{
var options = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 };
var manager = new LeafNodeManager(
options,
new ServerStats(),
"HUB",
_ => { },
_ => { },
NullLogger<LeafNodeManager>.Instance);
var cts = new CancellationTokenSource();
await manager.StartAsync(cts.Token);
var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var registered = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
manager.OnConnectionRegistered = id => registered.TrySetResult(id);
timeout.Token.Register(() => registered.TrySetCanceled(timeout.Token));
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", timeout.Token);
(await ReadLineAsync(remoteSocket, timeout.Token)).ShouldStartWith("LEAF ");
var connectionId = await registered.Task;
return new ManagerContext(manager, connectionId, remoteSocket, cts);
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
throw new IOException("Connection closed while reading line");
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}

View File

@@ -1,256 +0,0 @@
using NATS.Client.Core;
using NATS.Server.TestUtilities;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for subject filter propagation through leaf nodes.
/// Reference: golang/nats-server/server/leafnode_test.go
/// </summary>
public class LeafNodeSubjectFilterTests
{
// Go: TestLeafNodeInterestPropagationDaisychain server/leafnode_test.go:3953
[Fact]
public async Task Wildcard_subscription_propagates_through_leaf_node()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("wild.*");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("wild.test");
await hubConn.PublishAsync("wild.test", "wildcard-match");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await sub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("wildcard-match");
}
// Go: TestLeafNodeInterestPropagationDaisychain server/leafnode_test.go:3953
[Fact]
public async Task Full_wildcard_subscription_propagates_through_leaf_node()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("fwc.>");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("fwc.a.b.c");
await hubConn.PublishAsync("fwc.a.b.c", "full-wc");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await sub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("full-wc");
}
// Go: TestLeafNodeStreamAndShadowSubs server/leafnode_test.go:6176
[Fact]
public async Task Catch_all_subscription_propagates_through_leaf_node()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>(">");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("anything.at.all");
await hubConn.PublishAsync("anything.at.all", "catch-all");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await sub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("catch-all");
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task Subscription_interest_propagates_from_hub_to_leaf()
{
await using var fixture = await LeafFixture.StartAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await hubConn.SubscribeCoreAsync<string>("interest.prop");
await hubConn.PingAsync();
await fixture.WaitForRemoteInterestOnSpokeAsync("interest.prop");
fixture.Spoke.HasRemoteInterest("interest.prop").ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task Unsubscribe_removes_interest_on_remote()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
var sub = await leafConn.SubscribeCoreAsync<string>("unsub.test");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("unsub.test");
fixture.Hub.HasRemoteInterest("unsub.test").ShouldBeTrue();
await sub.DisposeAsync();
await leafConn.PingAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && fixture.Hub.HasRemoteInterest("unsub.test"))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
fixture.Hub.HasRemoteInterest("unsub.test").ShouldBeFalse();
}
// Go: TestLeafNodeInterestPropagationDaisychain server/leafnode_test.go:3953
[Fact]
public async Task Multiple_subscriptions_on_different_subjects_all_propagate()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var sub1 = await leafConn.SubscribeCoreAsync<string>("multi.a");
await using var sub2 = await leafConn.SubscribeCoreAsync<string>("multi.b");
await using var sub3 = await leafConn.SubscribeCoreAsync<string>("multi.c");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("multi.a");
await fixture.WaitForRemoteInterestOnHubAsync("multi.b");
await fixture.WaitForRemoteInterestOnHubAsync("multi.c");
fixture.Hub.HasRemoteInterest("multi.a").ShouldBeTrue();
fixture.Hub.HasRemoteInterest("multi.b").ShouldBeTrue();
fixture.Hub.HasRemoteInterest("multi.c").ShouldBeTrue();
}
// Go: TestLeafNodeDuplicateMsg server/leafnode_test.go:6513
[Fact]
public async Task No_interest_for_unsubscribed_subject()
{
await using var fixture = await LeafFixture.StartAsync();
fixture.Hub.HasRemoteInterest("nonexistent.subject").ShouldBeFalse();
}
// Go: TestLeafNodeInterestPropagationDaisychain server/leafnode_test.go:3953
[Fact]
public async Task Wildcard_interest_matches_multiple_concrete_subjects()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("events.*");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("events.created");
await hubConn.PublishAsync("events.created", "ev1");
await hubConn.PublishAsync("events.updated", "ev2");
await hubConn.PublishAsync("events.deleted", "ev3");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var received = new List<string>();
for (var i = 0; i < 3; i++)
received.Add((await sub.Msgs.ReadAsync(cts.Token)).Data!);
received.ShouldContain("ev1");
received.ShouldContain("ev2");
received.ShouldContain("ev3");
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task Non_matching_wildcard_does_not_receive_message()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("orders.*");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("orders.test");
await hubConn.PublishAsync("users.test", "should-not-arrive");
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await sub.Msgs.ReadAsync(leakCts.Token));
}
// Go: TestLeafNodeQueueGroupDistribution server/leafnode_test.go:4021
[Fact]
public async Task Queue_subscription_interest_propagates_through_leaf_node()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("queue.test", queueGroup: "workers");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("queue.test");
await hubConn.PublishAsync("queue.test", "queued-msg");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await sub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("queued-msg");
}
// Go: TestLeafNodeIsolatedLeafSubjectPropagationGlobal server/leafnode_test.go:10280
[Fact]
public async Task Interest_on_hub_side_includes_remote_interest_from_leaf()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var sub = await leafConn.SubscribeCoreAsync<string>("remote.interest.check");
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync("remote.interest.check");
fixture.Hub.HasRemoteInterest("remote.interest.check").ShouldBeTrue();
fixture.Hub.HasRemoteInterest("some.other.subject").ShouldBeFalse();
}
// Go: TestLeafNodeInterestPropagationDaisychain server/leafnode_test.go:3953
[Fact]
public async Task Deep_subject_hierarchy_forwarded_correctly()
{
await using var fixture = await LeafFixture.StartAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Hub.Port}" });
await hubConn.ConnectAsync();
const string deepSubject = "a.b.c.d.e.f.g.h";
await using var sub = await leafConn.SubscribeCoreAsync<string>(deepSubject);
await leafConn.PingAsync();
await fixture.WaitForRemoteInterestOnHubAsync(deepSubject);
await hubConn.PublishAsync(deepSubject, "deep");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
(await sub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("deep");
}
}

View File

@@ -1,296 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for leaf node permission and account syncing (Gap 12.2).
/// Verifies <see cref="LeafConnection.SetPermissions"/>, <see cref="LeafNodeManager.SendPermsAndAccountInfo"/>,
/// <see cref="LeafNodeManager.InitLeafNodeSmapAndSendSubs"/>, and
/// <see cref="LeafNodeManager.GetPermSyncStatus"/>.
/// Go reference: leafnode.go — sendPermsAndAccountInfo, initLeafNodeSmapAndSendSubs.
/// </summary>
public class LeafPermissionSyncTests
{
// ── LeafConnection.SetPermissions ─────────────────────────────────────────
// Go: leafnode.go — sendPermsAndAccountInfo sets client.perm.pub.allow
[Fact]
public async Task SetPermissions_SetsPublishAllow()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.SetPermissions(["orders.*", "events.>"], null);
leaf.AllowedPublishSubjects.Count.ShouldBe(2);
leaf.AllowedPublishSubjects.ShouldContain("orders.*");
leaf.AllowedPublishSubjects.ShouldContain("events.>");
}
// Go: leafnode.go — sendPermsAndAccountInfo sets client.perm.sub.allow
[Fact]
public async Task SetPermissions_SetsSubscribeAllow()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.SetPermissions(null, ["metrics.cpu", "metrics.memory"]);
leaf.AllowedSubscribeSubjects.Count.ShouldBe(2);
leaf.AllowedSubscribeSubjects.ShouldContain("metrics.cpu");
leaf.AllowedSubscribeSubjects.ShouldContain("metrics.memory");
}
// Go: leafnode.go — sendPermsAndAccountInfo marks perms as synced
[Fact]
public async Task SetPermissions_MarksPermsSynced()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.PermsSynced.ShouldBeFalse();
leaf.SetPermissions(["foo.*"], ["bar.*"]);
leaf.PermsSynced.ShouldBeTrue();
}
// Go: leafnode.go — clearing perms via null resets the allow lists
[Fact]
public async Task SetPermissions_NullLists_ClearsPermissions()
{
await using var leaf = await CreateConnectedLeafAsync();
leaf.SetPermissions(["orders.*"], ["events.*"]);
leaf.AllowedPublishSubjects.Count.ShouldBe(1);
leaf.AllowedSubscribeSubjects.Count.ShouldBe(1);
// Passing null clears both lists but still marks PermsSynced
leaf.SetPermissions(null, null);
leaf.AllowedPublishSubjects.ShouldBeEmpty();
leaf.AllowedSubscribeSubjects.ShouldBeEmpty();
leaf.PermsSynced.ShouldBeTrue();
}
// ── LeafNodeManager.SendPermsAndAccountInfo ───────────────────────────────
// Go: leafnode.go — sendPermsAndAccountInfo for known connection
[Fact]
public async Task SendPermsAndAccountInfo_ExistingConnection_SyncsPerms()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var result = ctx.Manager.SendPermsAndAccountInfo(
ctx.ConnectionId,
"myaccount",
["pub.>"],
["sub.>"]);
result.Found.ShouldBeTrue();
result.PermsSynced.ShouldBeTrue();
result.PublishAllowCount.ShouldBe(1);
result.SubscribeAllowCount.ShouldBe(1);
}
// Go: leafnode.go — sendPermsAndAccountInfo returns early when connection not found
[Fact]
public async Task SendPermsAndAccountInfo_NonExistent_ReturnsNotFound()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var result = ctx.Manager.SendPermsAndAccountInfo(
"no-such-connection-id",
"account",
["foo.*"],
null);
result.Found.ShouldBeFalse();
result.PermsSynced.ShouldBeFalse();
result.PublishAllowCount.ShouldBe(0);
result.SubscribeAllowCount.ShouldBe(0);
}
// Go: leafnode.go — sendPermsAndAccountInfo sets client.acc (account name)
[Fact]
public async Task SendPermsAndAccountInfo_SetsAccountName()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var result = ctx.Manager.SendPermsAndAccountInfo(
ctx.ConnectionId,
"tenant-alpha",
null,
null);
result.Found.ShouldBeTrue();
result.AccountName.ShouldBe("tenant-alpha");
}
// ── LeafNodeManager.InitLeafNodeSmapAndSendSubs ───────────────────────────
// Go: leafnode.go — initLeafNodeSmapAndSendSubs returns subject count
[Fact]
public async Task InitLeafNodeSmapAndSendSubs_ReturnSubjectCount()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var count = ctx.Manager.InitLeafNodeSmapAndSendSubs(
ctx.ConnectionId,
["orders.new", "orders.updated", "events.>"]);
count.ShouldBe(3);
}
// ── LeafNodeManager.GetPermSyncStatus ─────────────────────────────────────
// Go: leafnode.go — GetPermSyncStatus returns status for known connection
[Fact]
public async Task GetPermSyncStatus_FoundConnection_ReturnsStatus()
{
await using var ctx = await CreateManagerWithConnectionAsync();
ctx.Manager.SendPermsAndAccountInfo(ctx.ConnectionId, "acct", ["pub.*"], ["sub.*"]);
var status = ctx.Manager.GetPermSyncStatus(ctx.ConnectionId);
status.Found.ShouldBeTrue();
status.PermsSynced.ShouldBeTrue();
status.AccountName.ShouldBe("acct");
status.PublishAllowCount.ShouldBe(1);
status.SubscribeAllowCount.ShouldBe(1);
}
// Go: leafnode.go — GetPermSyncStatus returns not-found for unknown ID
[Fact]
public async Task GetPermSyncStatus_NotFound_ReturnsNotFound()
{
await using var ctx = await CreateManagerWithConnectionAsync();
var status = ctx.Manager.GetPermSyncStatus("unknown-id");
status.Found.ShouldBeFalse();
status.PermsSynced.ShouldBeFalse();
status.AccountName.ShouldBeNull();
status.PublishAllowCount.ShouldBe(0);
status.SubscribeAllowCount.ShouldBe(0);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// <summary>
/// Creates a connected pair of sockets and returns the server-side LeafConnection
/// after completing an outbound handshake. Callers must await-dispose the returned
/// connection to avoid socket leaks.
/// </summary>
private static async Task<LeafConnection> CreateConnectedLeafAsync()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, port);
var serverSocket = await listener.AcceptSocketAsync();
listener.Stop();
var leaf = new LeafConnection(serverSocket);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var handshakeTask = leaf.PerformOutboundHandshakeAsync("LOCAL", cts.Token);
// Answer the handshake from the remote side
var line = await ReadLineAsync(clientSocket, cts.Token);
line.ShouldStartWith("LEAF ");
await WriteLineAsync(clientSocket, "LEAF REMOTE", cts.Token);
await handshakeTask;
clientSocket.Close();
return leaf;
}
/// <summary>
/// Context returned by <see cref="CreateManagerWithConnectionAsync"/>.
/// Disposes the manager and drains sockets on disposal.
/// </summary>
private sealed class ManagerContext : IAsyncDisposable
{
private readonly Socket _remoteSocket;
private readonly CancellationTokenSource _cts;
public ManagerContext(LeafNodeManager manager, string connectionId, Socket remoteSocket, CancellationTokenSource cts)
{
Manager = manager;
ConnectionId = connectionId;
_remoteSocket = remoteSocket;
_cts = cts;
}
public LeafNodeManager Manager { get; }
public string ConnectionId { get; }
public async ValueTask DisposeAsync()
{
_remoteSocket.Close();
await Manager.DisposeAsync();
_cts.Dispose();
}
}
/// <summary>
/// Starts a <see cref="LeafNodeManager"/>, establishes one inbound leaf connection, waits
/// for registration, and returns a context containing the manager plus the registered
/// connection ID.
/// </summary>
private static async Task<ManagerContext> CreateManagerWithConnectionAsync()
{
var options = new LeafNodeOptions { Host = "127.0.0.1", Port = 0 };
var manager = new LeafNodeManager(
options,
new ServerStats(),
"HUB-SERVER",
_ => { },
_ => { },
NullLogger<LeafNodeManager>.Instance);
var cts = new CancellationTokenSource();
await manager.StartAsync(cts.Token);
// Connect a raw socket acting as the spoke (inbound to the manager's listener)
var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Set up a TaskCompletionSource that fires as soon as the manager registers
// the inbound connection — avoids any polling or timing-dependent delays.
var registered = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
manager.OnConnectionRegistered = id => registered.TrySetResult(id);
timeoutCts.Token.Register(() => registered.TrySetCanceled(timeoutCts.Token));
// For inbound connections the manager reads first, then writes
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", timeoutCts.Token);
var response = await ReadLineAsync(remoteSocket, timeoutCts.Token);
response.ShouldStartWith("LEAF ");
var connectionId = await registered.Task;
connectionId.ShouldNotBeNull("Manager should have registered the inbound connection");
return new ManagerContext(manager, connectionId, remoteSocket, cts);
}
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0) break;
if (single[0] == (byte)'\n') break;
if (single[0] != (byte)'\r') bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}

View File

@@ -1,239 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for solicited (outbound) leaf node connections with retry logic,
/// exponential backoff, JetStream domain propagation, and cancellation.
/// Go reference: leafnode.go — connectSolicited, solicitLeafNode.
/// </summary>
public class LeafSolicitedConnectionTests
{
// Go: TestLeafNodeBasicAuthSingleton server/leafnode_test.go:602
[Fact]
public async Task ConnectSolicited_ValidUrl_EstablishesConnection()
{
// Start a hub server with leaf node listener
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
// Create a spoke server that connects to the hub
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
// Wait for leaf connections to establish
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
hub.Stats.Leafs.ShouldBeGreaterThan(0);
spoke.Stats.Leafs.ShouldBeGreaterThan(0);
await spokeCts.CancelAsync();
await hubCts.CancelAsync();
spoke.Dispose();
hub.Dispose();
spokeCts.Dispose();
hubCts.Dispose();
}
// Go: leafnode.go — reconnect with backoff on connection failure
[Fact]
public async Task ConnectSolicited_InvalidUrl_RetriesWithBackoff()
{
// Create a leaf node manager targeting a non-existent endpoint
var options = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = ["127.0.0.1:19999"], // Nothing listening here
};
var stats = new ServerStats();
var manager = new LeafNodeManager(
options, stats, "test-server",
_ => { }, _ => { },
NullLogger<LeafNodeManager>.Instance);
// Start the manager — it will try to connect to 127.0.0.1:19999 and fail
using var cts = new CancellationTokenSource();
await manager.StartAsync(cts.Token);
// Give it some time to attempt connections
await Task.Delay(500);
// No connections should have succeeded
stats.Leafs.ShouldBe(0);
await cts.CancelAsync();
await manager.DisposeAsync();
}
// Go: leafnode.go — backoff caps at 60 seconds
[Fact]
public void ConnectSolicited_MaxBackoff_CapsAt60Seconds()
{
// Verify the backoff calculation caps at 60 seconds
LeafNodeManager.ComputeBackoff(0).ShouldBe(TimeSpan.FromSeconds(1));
LeafNodeManager.ComputeBackoff(1).ShouldBe(TimeSpan.FromSeconds(2));
LeafNodeManager.ComputeBackoff(2).ShouldBe(TimeSpan.FromSeconds(4));
LeafNodeManager.ComputeBackoff(3).ShouldBe(TimeSpan.FromSeconds(8));
LeafNodeManager.ComputeBackoff(4).ShouldBe(TimeSpan.FromSeconds(16));
LeafNodeManager.ComputeBackoff(5).ShouldBe(TimeSpan.FromSeconds(32));
LeafNodeManager.ComputeBackoff(6).ShouldBe(TimeSpan.FromSeconds(60)); // Capped
LeafNodeManager.ComputeBackoff(7).ShouldBe(TimeSpan.FromSeconds(60)); // Still capped
LeafNodeManager.ComputeBackoff(100).ShouldBe(TimeSpan.FromSeconds(60)); // Still capped
}
// Go: leafnode.go — JsDomain in leafInfo propagated during handshake
[Fact]
public async Task JetStreamDomain_PropagatedInHandshake()
{
// Start a hub with JetStream domain
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
JetStreamDomain = "hub-domain",
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
// Create a raw socket connection to verify the handshake includes domain
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
var leafEndpoint = hub.LeafListen!.Split(':');
await client.ConnectAsync(IPAddress.Parse(leafEndpoint[0]), int.Parse(leafEndpoint[1]));
using var stream = new NetworkStream(client, ownsSocket: false);
// Send our LEAF handshake with a domain
var outMsg = Encoding.ASCII.GetBytes("LEAF test-spoke domain=spoke-domain\r\n");
await stream.WriteAsync(outMsg);
await stream.FlushAsync();
// Read the hub's handshake response
var response = await ReadLineAsync(stream);
// The hub's handshake should include the JetStream domain
response.ShouldStartWith("LEAF ");
response.ShouldContain("domain=hub-domain");
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
// Go: leafnode.go — cancellation stops reconnect loop
[Fact]
public async Task Retry_CancellationToken_StopsRetrying()
{
var options = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = ["127.0.0.1:19998"], // Nothing listening
};
var stats = new ServerStats();
var manager = new LeafNodeManager(
options, stats, "test-server",
_ => { }, _ => { },
NullLogger<LeafNodeManager>.Instance);
using var cts = new CancellationTokenSource();
await manager.StartAsync(cts.Token);
// Let it attempt at least one retry
await Task.Delay(200);
// Cancel — the retry loop should stop promptly
await cts.CancelAsync();
await manager.DisposeAsync();
// No connections should have been established
stats.Leafs.ShouldBe(0);
}
// Go: leafnode.go — verify backoff delay sequence
[Fact]
public void ExponentialBackoff_CalculatesCorrectDelays()
{
var delays = new List<TimeSpan>();
for (var i = 0; i < 10; i++)
delays.Add(LeafNodeManager.ComputeBackoff(i));
// Verify the sequence: 1, 2, 4, 8, 16, 32, 60, 60, 60, 60
delays[0].ShouldBe(TimeSpan.FromSeconds(1));
delays[1].ShouldBe(TimeSpan.FromSeconds(2));
delays[2].ShouldBe(TimeSpan.FromSeconds(4));
delays[3].ShouldBe(TimeSpan.FromSeconds(8));
delays[4].ShouldBe(TimeSpan.FromSeconds(16));
delays[5].ShouldBe(TimeSpan.FromSeconds(32));
// After attempt 5, all should be capped at 60s
for (var i = 6; i < 10; i++)
delays[i].ShouldBe(TimeSpan.FromSeconds(60));
// Negative attempt should be treated as 0
LeafNodeManager.ComputeBackoff(-1).ShouldBe(TimeSpan.FromSeconds(1));
}
private static async Task<string> ReadLineAsync(NetworkStream stream)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await stream.ReadAsync(single);
if (read == 0)
throw new IOException("Connection closed");
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
}

View File

@@ -1,42 +0,0 @@
using NATS.Server.LeafNodes;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.LeafNodes;
public class LeafSubKeyParityBatch2Tests
{
[Fact]
public void Constants_match_go_leaf_key_and_delay_values()
{
LeafSubKey.KeyRoutedSub.ShouldBe("R");
LeafSubKey.KeyRoutedSubByte.ShouldBe((byte)'R');
LeafSubKey.KeyRoutedLeafSub.ShouldBe("L");
LeafSubKey.KeyRoutedLeafSubByte.ShouldBe((byte)'L');
LeafSubKey.SharedSysAccDelay.ShouldBe(TimeSpan.FromMilliseconds(250));
LeafSubKey.ConnectProcessTimeout.ShouldBe(TimeSpan.FromSeconds(2));
}
[Fact]
public void KeyFromSub_matches_go_subject_and_queue_shape()
{
LeafSubKey.KeyFromSub(NewSub("foo")).ShouldBe("foo");
LeafSubKey.KeyFromSub(NewSub("foo", "bar")).ShouldBe("foo bar");
}
[Fact]
public void KeyFromSubWithOrigin_matches_go_routed_and_leaf_routed_shapes()
{
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo")).ShouldBe("R foo");
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo", "bar")).ShouldBe("R foo bar");
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo"), "leaf").ShouldBe("L foo leaf");
LeafSubKey.KeyFromSubWithOrigin(NewSub("foo", "bar"), "leaf").ShouldBe("L foo bar leaf");
}
private static Subscription NewSub(string subject, string? queue = null)
=> new()
{
Subject = subject,
Queue = queue,
Sid = Guid.NewGuid().ToString("N"),
};
}

View File

@@ -1,887 +0,0 @@
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.LeafNodes;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Tests for leaf node subject filtering via DenyExports/DenyImports (deny-lists) and
/// ExportSubjects/ImportSubjects (allow-lists). When an allow-list is non-empty, only
/// subjects matching at least one allow pattern are permitted; deny takes precedence.
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231,
/// auth.go:127 (SubjectPermission with Allow + Deny).
/// </summary>
public class LeafSubjectFilterTests
{
// ── LeafHubSpokeMapper.IsSubjectAllowed Unit Tests ────────────────
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Literal_deny_export_blocks_outbound_subject()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["secret.data"],
denyImports: []);
mapper.IsSubjectAllowed("secret.data", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Literal_deny_import_blocks_inbound_subject()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: ["internal.status"]);
mapper.IsSubjectAllowed("internal.status", LeafMapDirection.Inbound).ShouldBeFalse();
mapper.IsSubjectAllowed("external.status", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Wildcard_deny_export_blocks_matching_subjects()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["admin.*"],
denyImports: []);
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("admin.config", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("admin.deep.nested", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Fwc_deny_import_blocks_all_matching_subjects()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: ["_SYS.>"]);
mapper.IsSubjectAllowed("_SYS.heartbeat", LeafMapDirection.Inbound).ShouldBeFalse();
mapper.IsSubjectAllowed("_SYS.a.b.c", LeafMapDirection.Inbound).ShouldBeFalse();
mapper.IsSubjectAllowed("user.data", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Bidirectional_filtering_applies_independently()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["export.denied"],
denyImports: ["import.denied"]);
// Export deny does not affect inbound direction
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Inbound).ShouldBeTrue();
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Outbound).ShouldBeFalse();
// Import deny does not affect outbound direction
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Inbound).ShouldBeFalse();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Multiple_deny_patterns_all_evaluated()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["admin.*", "secret.>", "internal.config"],
denyImports: []);
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("secret.key.value", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("internal.config", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Empty_deny_lists_allow_everything()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: []);
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Account_mapping_still_works_with_subject_filter()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string> { ["HUB_ACCT"] = "SPOKE_ACCT" },
denyExports: ["denied.>"],
denyImports: []);
var outbound = mapper.Map("HUB_ACCT", "foo.bar", LeafMapDirection.Outbound);
outbound.Account.ShouldBe("SPOKE_ACCT");
outbound.Subject.ShouldBe("foo.bar");
var inbound = mapper.Map("SPOKE_ACCT", "foo.bar", LeafMapDirection.Inbound);
inbound.Account.ShouldBe("HUB_ACCT");
inbound.Subject.ShouldBe("foo.bar");
mapper.IsSubjectAllowed("denied.test", LeafMapDirection.Outbound).ShouldBeFalse();
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public void Default_constructor_allows_everything()
{
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>());
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
}
// ── Integration: DenyExports blocks hub→leaf message forwarding ────
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyExports_blocks_message_forwarding_hub_to_leaf()
{
// Start a hub with DenyExports configured
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyExports = ["secret.>"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
// Wait for leaf connection
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
// Subscribe on spoke for allowed and denied subjects
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("public.data");
await using var deniedSub = await leafConn.SubscribeCoreAsync<string>("secret.data");
await leafConn.PingAsync();
// Wait for interest propagation
await Task.Delay(500);
// Publish from hub
await hubConn.PublishAsync("public.data", "allowed-msg");
await hubConn.PublishAsync("secret.data", "denied-msg");
// The allowed message should arrive
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
// The denied message should NOT arrive
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await deniedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyImports_blocks_message_forwarding_leaf_to_hub()
{
// Start hub with DenyImports — leaf→hub messages for denied subjects are dropped
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyImports = ["private.>"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
// Wait for leaf connection
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
// Subscribe on hub for both allowed and denied subjects
await using var allowedSub = await hubConn.SubscribeCoreAsync<string>("public.data");
await using var deniedSub = await hubConn.SubscribeCoreAsync<string>("private.data");
await hubConn.PingAsync();
// Wait for interest propagation
await Task.Delay(500);
// Publish from spoke (leaf)
await leafConn.PublishAsync("public.data", "allowed-msg");
await leafConn.PublishAsync("private.data", "denied-msg");
// The allowed message should arrive on hub
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
// The denied message should NOT arrive
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await deniedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyExports_with_wildcard_blocks_pattern_matching_subjects()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyExports = ["admin.*"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
// admin.users should be blocked; admin.deep.nested should pass (* doesn't match multi-token)
await using var blockedSub = await leafConn.SubscribeCoreAsync<string>("admin.users");
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("admin.deep.nested");
await leafConn.PingAsync();
await Task.Delay(500);
await hubConn.PublishAsync("admin.users", "blocked");
await hubConn.PublishAsync("admin.deep.nested", "allowed");
// The multi-token subject passes because * matches only single token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed");
// The single-token subject is blocked
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await blockedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// ── Wire-level: DenyExports blocks LS+ propagation ──────────────
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
[Fact]
public async Task DenyExports_blocks_subscription_propagation()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var options = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
DenyExports = ["secret.>"],
};
var manager = new LeafNodeManager(
options,
new ServerStats(),
"HUB1",
_ => { },
_ => { },
NullLogger<LeafNodeManager>.Instance);
await manager.StartAsync(CancellationToken.None);
try
{
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
// Exchange handshakes — inbound connections send LEAF first, then read response
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", cts.Token);
var line = await ReadLineAsync(remoteSocket, cts.Token);
line.ShouldStartWith("LEAF ");
await Task.Delay(200);
// Propagate allowed subscription
manager.PropagateLocalSubscription("$G", "public.data", null);
await Task.Delay(100);
var lsLine = await ReadLineAsync(remoteSocket, cts.Token);
lsLine.ShouldBe("LS+ $G public.data");
// Propagate denied subscription — should NOT appear on wire
manager.PropagateLocalSubscription("$G", "secret.data", null);
// Send a PING to verify nothing else was sent
manager.PropagateLocalSubscription("$G", "allowed.check", null);
await Task.Delay(100);
var nextLine = await ReadLineAsync(remoteSocket, cts.Token);
nextLine.ShouldBe("LS+ $G allowed.check");
}
finally
{
await manager.DisposeAsync();
}
}
// ── ExportSubjects/ImportSubjects allow-list Unit Tests ────────────
// Go: auth.go:127 SubjectPermission.Allow semantics
[Fact]
public void Allow_export_restricts_outbound_to_matching_subjects()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: [],
allowExports: ["orders.*", "events.>"],
allowImports: []);
mapper.IsSubjectAllowed("orders.created", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("orders.updated", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("events.system.boot", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("users.created", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("admin.config", LeafMapDirection.Outbound).ShouldBeFalse();
}
// Go: auth.go:127 SubjectPermission.Allow semantics
[Fact]
public void Allow_import_restricts_inbound_to_matching_subjects()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: [],
allowExports: [],
allowImports: ["metrics.*"]);
mapper.IsSubjectAllowed("metrics.cpu", LeafMapDirection.Inbound).ShouldBeTrue();
mapper.IsSubjectAllowed("metrics.memory", LeafMapDirection.Inbound).ShouldBeTrue();
mapper.IsSubjectAllowed("logs.app", LeafMapDirection.Inbound).ShouldBeFalse();
}
// Go: auth.go:127 SubjectPermission — deny takes precedence over allow
[Fact]
public void Deny_takes_precedence_over_allow()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: ["orders.secret"],
denyImports: [],
allowExports: ["orders.*"],
allowImports: []);
// orders.created matches allow and not deny → permitted
mapper.IsSubjectAllowed("orders.created", LeafMapDirection.Outbound).ShouldBeTrue();
// orders.secret matches both allow and deny → deny wins
mapper.IsSubjectAllowed("orders.secret", LeafMapDirection.Outbound).ShouldBeFalse();
}
// Go: auth.go:127 SubjectPermission — deny takes precedence over allow (import direction)
[Fact]
public void Deny_import_takes_precedence_over_allow_import()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: ["metrics.secret"],
allowExports: [],
allowImports: ["metrics.*"]);
mapper.IsSubjectAllowed("metrics.cpu", LeafMapDirection.Inbound).ShouldBeTrue();
mapper.IsSubjectAllowed("metrics.secret", LeafMapDirection.Inbound).ShouldBeFalse();
}
// Go: auth.go:127 SubjectPermission.Allow — empty allow-list means allow all
[Fact]
public void Empty_allow_lists_allow_everything_not_denied()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: [],
allowExports: [],
allowImports: []);
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: auth.go:127 SubjectPermission.Allow — wildcard patterns in allow-list
[Fact]
public void Allow_export_with_fwc_matches_deep_hierarchy()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: [],
allowExports: ["data.>"],
allowImports: []);
mapper.IsSubjectAllowed("data.x", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("data.x.y.z", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("other.x", LeafMapDirection.Outbound).ShouldBeFalse();
}
// Go: auth.go:127 SubjectPermission.Allow — bidirectional allow-lists are independent
[Fact]
public void Allow_lists_are_direction_independent()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: [],
allowExports: ["export.only"],
allowImports: ["import.only"]);
// export.only is allowed outbound, not restricted inbound (no inbound allow match required for it)
mapper.IsSubjectAllowed("export.only", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("export.only", LeafMapDirection.Inbound).ShouldBeFalse();
mapper.IsSubjectAllowed("import.only", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("import.only", LeafMapDirection.Inbound).ShouldBeTrue();
}
// Go: auth.go:127 SubjectPermission.Allow — multiple allow patterns
[Fact]
public void Multiple_allow_patterns_any_match_permits()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: [],
allowExports: ["orders.*", "events.*", "metrics.>"],
allowImports: []);
mapper.IsSubjectAllowed("orders.new", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("events.created", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("metrics.cpu.avg", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("users.list", LeafMapDirection.Outbound).ShouldBeFalse();
}
// Go: auth.go:127 SubjectPermission — allow + deny combined with account mapping
[Fact]
public void Allow_with_account_mapping_and_deny()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string> { ["HUB"] = "SPOKE" },
denyExports: ["orders.secret"],
denyImports: [],
allowExports: ["orders.*"],
allowImports: []);
var result = mapper.Map("HUB", "orders.new", LeafMapDirection.Outbound);
result.Account.ShouldBe("SPOKE");
result.Subject.ShouldBe("orders.new");
mapper.IsSubjectAllowed("orders.new", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("orders.secret", LeafMapDirection.Outbound).ShouldBeFalse();
mapper.IsSubjectAllowed("users.new", LeafMapDirection.Outbound).ShouldBeFalse();
}
// Go: auth.go:127 SubjectPermission.Allow — literal subjects in allow-list
[Fact]
public void Allow_export_with_literal_subject()
{
var mapper = new LeafHubSpokeMapper(
new Dictionary<string, string>(),
denyExports: [],
denyImports: [],
allowExports: ["status.health"],
allowImports: []);
mapper.IsSubjectAllowed("status.health", LeafMapDirection.Outbound).ShouldBeTrue();
mapper.IsSubjectAllowed("status.ready", LeafMapDirection.Outbound).ShouldBeFalse();
}
// ── Integration: ExportSubjects allow-list blocks hub→leaf ────────
// Go: auth.go:127 SubjectPermission.Allow — integration with server
[Fact]
public async Task ExportSubjects_allow_list_restricts_hub_to_leaf_forwarding()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
ExportSubjects = ["allowed.>"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("allowed.data");
await using var blockedSub = await leafConn.SubscribeCoreAsync<string>("blocked.data");
await leafConn.PingAsync();
await Task.Delay(500);
await hubConn.PublishAsync("allowed.data", "yes");
await hubConn.PublishAsync("blocked.data", "no");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("yes");
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await blockedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// Go: auth.go:127 SubjectPermission.Allow — import allow-list integration
[Fact]
public async Task ImportSubjects_allow_list_restricts_leaf_to_hub_forwarding()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
ImportSubjects = ["allowed.>"],
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
try
{
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
await hubConn.ConnectAsync();
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
await leafConn.ConnectAsync();
await using var allowedSub = await hubConn.SubscribeCoreAsync<string>("allowed.data");
await using var blockedSub = await hubConn.SubscribeCoreAsync<string>("blocked.data");
await hubConn.PingAsync();
await Task.Delay(500);
await leafConn.PublishAsync("allowed.data", "yes");
await leafConn.PublishAsync("blocked.data", "no");
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("yes");
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
await Should.ThrowAsync<OperationCanceledException>(async () =>
await blockedSub.Msgs.ReadAsync(leakCts.Token));
}
finally
{
await spokeCts.CancelAsync();
spoke.Dispose();
spokeCts.Dispose();
}
}
finally
{
await hubCts.CancelAsync();
hub.Dispose();
hubCts.Dispose();
}
}
// ── Wire-level: ExportSubjects blocks LS+ propagation ────────────
// Go: auth.go:127 SubjectPermission.Allow — subscription propagation filtered by allow-list
[Fact]
public async Task ExportSubjects_blocks_subscription_propagation_for_non_allowed()
{
var options = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
ExportSubjects = ["allowed.*"],
};
var manager = new LeafNodeManager(
options,
new ServerStats(),
"HUB1",
_ => { },
_ => { },
NullLogger<LeafNodeManager>.Instance);
await manager.StartAsync(CancellationToken.None);
try
{
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", cts.Token);
var line = await ReadLineAsync(remoteSocket, cts.Token);
line.ShouldStartWith("LEAF ");
await Task.Delay(200);
// Propagate allowed subscription
manager.PropagateLocalSubscription("$G", "allowed.data", null);
await Task.Delay(100);
var lsLine = await ReadLineAsync(remoteSocket, cts.Token);
lsLine.ShouldBe("LS+ $G allowed.data");
// Propagate non-allowed subscription — should NOT appear on wire
manager.PropagateLocalSubscription("$G", "blocked.data", null);
// Verify by sending another allowed subscription
manager.PropagateLocalSubscription("$G", "allowed.check", null);
await Task.Delay(100);
var nextLine = await ReadLineAsync(remoteSocket, cts.Token);
nextLine.ShouldBe("LS+ $G allowed.check");
}
finally
{
await manager.DisposeAsync();
}
}
// ── Helpers ────────────────────────────────────────────────────────
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
{
var bytes = new List<byte>(64);
var single = new byte[1];
while (true)
{
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
if (read == 0)
break;
if (single[0] == (byte)'\n')
break;
if (single[0] != (byte)'\r')
bytes.Add(single[0]);
}
return Encoding.ASCII.GetString([.. bytes]);
}
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
}

View File

@@ -1,142 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for leaf node TLS certificate hot-reload (Gap 12.1).
/// Verifies that <see cref="LeafNodeManager.UpdateTlsConfig"/> correctly tracks cert/key
/// paths, increments the reload counter only on genuine changes, and returns accurate
/// <see cref="LeafTlsReloadResult"/> values.
/// Go reference: leafnode.go — reloadTLSConfig, TestLeafNodeTLSCertReload.
/// </summary>
public class LeafTlsReloadTests
{
private static LeafNodeManager CreateManager() =>
new(
options: new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
stats: new ServerStats(),
serverId: "test-server",
remoteSubSink: _ => { },
messageSink: _ => { },
logger: NullLogger<LeafNodeManager>.Instance);
// Go: TestLeafNodeTLSCertReload — leafnode_test.go, first reload call
[Fact]
public void UpdateTlsConfig_NewCert_ReturnsChanged()
{
var manager = CreateManager();
var result = manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
result.Changed.ShouldBeTrue();
result.NewCertPath.ShouldBe("/certs/leaf.pem");
}
// Go: TestLeafNodeTLSCertReload — no-op reload when cert unchanged
[Fact]
public void UpdateTlsConfig_SameCert_ReturnsUnchanged()
{
var manager = CreateManager();
manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
var result = manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
result.Changed.ShouldBeFalse();
}
// Go: TestLeafNodeTLSCertReload — cert rotation path
[Fact]
public void UpdateTlsConfig_ChangedCert_UpdatesPath()
{
var manager = CreateManager();
manager.UpdateTlsConfig("/certs/old.pem", "/certs/old-key.pem");
manager.UpdateTlsConfig("/certs/new.pem", "/certs/new-key.pem");
manager.CurrentCertPath.ShouldBe("/certs/new.pem");
}
// Go: TestLeafNodeTLSCertReload — disabling TLS by passing null cert
[Fact]
public void UpdateTlsConfig_ClearCert_ReturnsChanged()
{
var manager = CreateManager();
manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
var result = manager.UpdateTlsConfig(null, null);
result.Changed.ShouldBeTrue();
result.NewCertPath.ShouldBeNull();
}
// Go: TestLeafNodeTLS — IsTlsEnabled when no cert configured
[Fact]
public void IsTlsEnabled_NoCert_ReturnsFalse()
{
var manager = CreateManager();
manager.IsTlsEnabled.ShouldBeFalse();
}
// Go: TestLeafNodeTLS — IsTlsEnabled when cert is configured
[Fact]
public void IsTlsEnabled_WithCert_ReturnsTrue()
{
var manager = CreateManager();
manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
manager.IsTlsEnabled.ShouldBeTrue();
}
// Go: TestLeafNodeTLSCertReload — reload counter increments on each genuine change
[Fact]
public void TlsReloadCount_IncrementedOnChange()
{
var manager = CreateManager();
manager.TlsReloadCount.ShouldBe(0);
manager.UpdateTlsConfig("/certs/a.pem", "/certs/a-key.pem");
manager.TlsReloadCount.ShouldBe(1);
manager.UpdateTlsConfig("/certs/b.pem", "/certs/b-key.pem");
manager.TlsReloadCount.ShouldBe(2);
}
// Go: TestLeafNodeTLSCertReload — reload counter unchanged when config identical
[Fact]
public void TlsReloadCount_NotIncrementedOnNoChange()
{
var manager = CreateManager();
manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
manager.TlsReloadCount.ShouldBe(1);
}
// Go: TestLeafNodeTLSCertReload — result carries previous cert path
[Fact]
public void UpdateTlsConfig_ReportsPreviousPath()
{
var manager = CreateManager();
manager.UpdateTlsConfig("/certs/first.pem", "/certs/first-key.pem");
var result = manager.UpdateTlsConfig("/certs/second.pem", "/certs/second-key.pem");
result.PreviousCertPath.ShouldBe("/certs/first.pem");
result.NewCertPath.ShouldBe("/certs/second.pem");
}
// Go: TestLeafNodeTLSCertReload — key path tracked alongside cert path
[Fact]
public void UpdateTlsConfig_UpdatesKeyPath()
{
var manager = CreateManager();
manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem");
manager.CurrentKeyPath.ShouldBe("/certs/leaf-key.pem");
}
}

View File

@@ -1,180 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for leaf node reconnect state validation (Gap 12.3).
/// Verifies that <see cref="LeafNodeManager.ValidateRemoteLeafNode"/>,
/// <see cref="LeafNodeManager.IsSelfConnect"/>, <see cref="LeafNodeManager.HasConnection"/>,
/// and <see cref="LeafNodeManager.GetConnectionByRemoteId"/> enforce self-connect detection,
/// duplicate-connection rejection, and JetStream domain conflict detection.
/// Go reference: leafnode.go addLeafNodeConnection — duplicate and domain checks.
/// </summary>
public class LeafValidationTests
{
private static LeafNodeManager CreateManager(string serverId = "server-A") =>
new(
options: new LeafNodeOptions { Host = "127.0.0.1", Port = 0 },
stats: new ServerStats(),
serverId: serverId,
remoteSubSink: _ => { },
messageSink: _ => { },
logger: NullLogger<LeafNodeManager>.Instance);
/// <summary>
/// Creates a connected socket pair and returns the server-side socket.
/// The caller is responsible for disposing both sockets.
/// </summary>
private static async Task<(Socket serverSide, Socket clientSide, TcpListener listener)> CreateSocketPairAsync()
{
var tcpListener = new TcpListener(IPAddress.Loopback, 0);
tcpListener.Start();
var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await clientSocket.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)tcpListener.LocalEndpoint).Port);
var serverSocket = await tcpListener.AcceptSocketAsync();
tcpListener.Stop();
return (serverSocket, clientSocket, tcpListener);
}
private static async Task<LeafConnection> CreateConnectionWithRemoteIdAsync(string remoteId, string? jsDomain = null)
{
var (serverSide, clientSide, _) = await CreateSocketPairAsync();
clientSide.Dispose(); // only need the server side for the LeafConnection
var conn = new LeafConnection(serverSide)
{
RemoteId = remoteId,
JetStreamDomain = jsDomain,
};
return conn;
}
// Go: leafnode.go addLeafNodeConnection — happy path with distinct server IDs
[Fact]
public async Task ValidateRemoteLeafNode_Valid_ReturnsValid()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
var result = manager.ValidateRemoteLeafNode("server-C", "$G", null);
result.Valid.ShouldBeTrue();
result.Error.ShouldBeNull();
result.ErrorCode.ShouldBe(LeafValidationError.None);
}
// Go: leafnode.go — loop detection: reject when remote ID matches own server ID
[Fact]
public void ValidateRemoteLeafNode_SelfConnect_ReturnsError()
{
var manager = CreateManager("server-A");
var result = manager.ValidateRemoteLeafNode("server-A", "$G", null);
result.Valid.ShouldBeFalse();
result.ErrorCode.ShouldBe(LeafValidationError.SelfConnect);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go addLeafNodeConnection checkForDup — existing connection from same remote
[Fact]
public async Task ValidateRemoteLeafNode_DuplicateConnection_ReturnsError()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
var result = manager.ValidateRemoteLeafNode("server-B", "$G", null);
result.Valid.ShouldBeFalse();
result.ErrorCode.ShouldBe(LeafValidationError.DuplicateConnection);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go addLeafNodeConnection — JetStream domain conflict between existing and incoming
[Fact]
public async Task ValidateRemoteLeafNode_JsDomainConflict_ReturnsError()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(conn);
// server-C tries to connect with a different JS domain
var result = manager.ValidateRemoteLeafNode("server-C", "$G", "domain-spoke");
result.Valid.ShouldBeFalse();
result.ErrorCode.ShouldBe(LeafValidationError.JetStreamDomainConflict);
result.Error.ShouldNotBeNull();
}
// Go: leafnode.go — null JS domain is never a conflict
[Fact]
public async Task ValidateRemoteLeafNode_NullJsDomain_Valid()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B", jsDomain: "domain-hub");
manager.InjectConnectionForTesting(conn);
// Incoming with null domain — no domain conflict check performed
var result = manager.ValidateRemoteLeafNode("server-C", "$G", null);
result.Valid.ShouldBeTrue();
result.ErrorCode.ShouldBe(LeafValidationError.None);
}
// Go: leafnode.go — IsSelfConnect true when IDs match
[Fact]
public void IsSelfConnect_MatchingId_ReturnsTrue()
{
var manager = CreateManager("server-A");
manager.IsSelfConnect("server-A").ShouldBeTrue();
}
// Go: leafnode.go — IsSelfConnect false when IDs differ
[Fact]
public void IsSelfConnect_DifferentId_ReturnsFalse()
{
var manager = CreateManager("server-A");
manager.IsSelfConnect("server-B").ShouldBeFalse();
}
// Go: leafnode.go — HasConnection true when remote ID is registered
[Fact]
public async Task HasConnection_Existing_ReturnsTrue()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
manager.HasConnection("server-B").ShouldBeTrue();
}
// Go: leafnode.go — HasConnection false when remote ID is not registered
[Fact]
public void HasConnection_Missing_ReturnsFalse()
{
var manager = CreateManager("server-A");
manager.HasConnection("server-B").ShouldBeFalse();
}
// Go: leafnode.go — GetConnectionByRemoteId returns the matching connection
[Fact]
public async Task GetConnectionByRemoteId_Found_ReturnsConnection()
{
var manager = CreateManager("server-A");
await using var conn = await CreateConnectionWithRemoteIdAsync("server-B");
manager.InjectConnectionForTesting(conn);
var found = manager.GetConnectionByRemoteId("server-B");
found.ShouldNotBeNull();
found.RemoteId.ShouldBe("server-B");
}
}

View File

@@ -1,230 +0,0 @@
using SystemWebSocket = System.Net.WebSockets.WebSocket;
using System.Net.WebSockets;
using NSubstitute;
using NATS.Server.LeafNodes;
namespace NATS.Server.Tests.LeafNodes;
/// <summary>
/// Unit tests for <see cref="WebSocketStreamAdapter"/> (Gap 12.5).
/// Verifies stream capability flags, read/write delegation to WebSocket,
/// telemetry counters, and IsConnected state reflection.
/// Go reference: leafnode.go wsCreateLeafConnection, client.go wsRead/wsWrite.
/// </summary>
public class LeafWebSocketTests
{
// -------------------------------------------------------------------------
// Helper
// -------------------------------------------------------------------------
private static SystemWebSocket CreateMockWebSocket(byte[]? readData = null)
{
var ws = Substitute.For<SystemWebSocket>();
ws.State.Returns(WebSocketState.Open);
if (readData != null)
{
ws.ReceiveAsync(Arg.Any<Memory<byte>>(), Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var mem = callInfo.ArgAt<Memory<byte>>(0);
var toCopy = Math.Min(readData.Length, mem.Length);
readData.AsSpan(0, toCopy).CopyTo(mem.Span);
return new ValueTask<ValueWebSocketReceiveResult>(
new ValueWebSocketReceiveResult(toCopy, WebSocketMessageType.Binary, true));
});
}
return ws;
}
// -------------------------------------------------------------------------
// Tests 1-3: Stream capability flags
// -------------------------------------------------------------------------
// Go reference: client.go wsRead — reads are supported
[Fact]
public void CanRead_ReturnsTrue()
{
var ws = CreateMockWebSocket();
var adapter = new WebSocketStreamAdapter(ws);
adapter.CanRead.ShouldBeTrue();
}
// Go reference: client.go wsWrite — writes are supported
[Fact]
public void CanWrite_ReturnsTrue()
{
var ws = CreateMockWebSocket();
var adapter = new WebSocketStreamAdapter(ws);
adapter.CanWrite.ShouldBeTrue();
}
// Go reference: leafnode.go wsCreateLeafConnection — WebSocket is not seekable
[Fact]
public void CanSeek_ReturnsFalse()
{
var ws = CreateMockWebSocket();
var adapter = new WebSocketStreamAdapter(ws);
adapter.CanSeek.ShouldBeFalse();
}
// -------------------------------------------------------------------------
// Test 4: ReadAsync delegates to WebSocket
// -------------------------------------------------------------------------
// Go reference: client.go wsRead — receive next message from WebSocket
[Fact]
public async Task ReadAsync_ReceivesFromWebSocket()
{
var expected = "hello"u8.ToArray();
var ws = CreateMockWebSocket(readData: expected);
var adapter = new WebSocketStreamAdapter(ws);
var buffer = new byte[16];
var read = await adapter.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None);
read.ShouldBe(expected.Length);
buffer[..read].ShouldBe(expected);
}
// -------------------------------------------------------------------------
// Test 5: WriteAsync delegates to WebSocket
// -------------------------------------------------------------------------
// Go reference: client.go wsWrite — send data as a single binary frame
[Fact]
public async Task WriteAsync_SendsToWebSocket()
{
var ws = Substitute.For<SystemWebSocket>();
ws.State.Returns(WebSocketState.Open);
var capturedData = new List<byte>();
ws.SendAsync(
Arg.Any<ReadOnlyMemory<byte>>(),
WebSocketMessageType.Binary,
true,
Arg.Any<CancellationToken>())
.Returns(callInfo =>
{
var mem = callInfo.ArgAt<ReadOnlyMemory<byte>>(0);
capturedData.AddRange(mem.ToArray());
return ValueTask.CompletedTask;
});
var adapter = new WebSocketStreamAdapter(ws);
var payload = "world"u8.ToArray();
await adapter.WriteAsync(payload, 0, payload.Length, CancellationToken.None);
await ws.Received(1).SendAsync(
Arg.Any<ReadOnlyMemory<byte>>(),
WebSocketMessageType.Binary,
true,
Arg.Any<CancellationToken>());
capturedData.ShouldBe(payload);
}
// -------------------------------------------------------------------------
// Test 6: BytesRead tracking
// -------------------------------------------------------------------------
// Go reference: client.go wsRead — track inbound byte count
[Fact]
public async Task BytesRead_TracksTotal()
{
var payload = new byte[] { 1, 2, 3, 4, 5 };
var ws = CreateMockWebSocket(readData: payload);
var adapter = new WebSocketStreamAdapter(ws);
var buffer = new byte[16];
var bytesRead = await adapter.ReadAsync(buffer.AsMemory(), CancellationToken.None);
bytesRead.ShouldBeGreaterThan(0);
adapter.BytesRead.ShouldBe(payload.Length);
}
// -------------------------------------------------------------------------
// Test 7: BytesWritten tracking
// -------------------------------------------------------------------------
// Go reference: client.go wsWrite — track outbound byte count
[Fact]
public async Task BytesWritten_TracksTotal()
{
var ws = Substitute.For<SystemWebSocket>();
ws.State.Returns(WebSocketState.Open);
ws.SendAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<WebSocketMessageType>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.CompletedTask);
var adapter = new WebSocketStreamAdapter(ws);
var payload = new byte[] { 10, 20, 30 };
await adapter.WriteAsync(payload, 0, payload.Length, CancellationToken.None);
adapter.BytesWritten.ShouldBe(payload.Length);
}
// -------------------------------------------------------------------------
// Test 8: MessagesRead counter
// -------------------------------------------------------------------------
// Go reference: client.go wsRead — each completed WebSocket message increments counter
[Fact]
public async Task MessagesRead_Incremented()
{
var payload = new byte[] { 0xAA, 0xBB };
var ws = CreateMockWebSocket(readData: payload);
var adapter = new WebSocketStreamAdapter(ws);
var buffer = new byte[16];
_ = await adapter.ReadAsync(buffer.AsMemory(), CancellationToken.None);
adapter.MessagesRead.ShouldBe(1);
}
// -------------------------------------------------------------------------
// Test 9: MessagesWritten counter
// -------------------------------------------------------------------------
// Go reference: client.go wsWrite — each SendAsync call is one message
[Fact]
public async Task MessagesWritten_Incremented()
{
var ws = Substitute.For<SystemWebSocket>();
ws.State.Returns(WebSocketState.Open);
ws.SendAsync(Arg.Any<ReadOnlyMemory<byte>>(), Arg.Any<WebSocketMessageType>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.CompletedTask);
var adapter = new WebSocketStreamAdapter(ws);
await adapter.WriteAsync(ReadOnlyMemory<byte>.Empty, CancellationToken.None);
await adapter.WriteAsync(ReadOnlyMemory<byte>.Empty, CancellationToken.None);
adapter.MessagesWritten.ShouldBe(2);
}
// -------------------------------------------------------------------------
// Test 10: IsConnected reflects WebSocket.State
// -------------------------------------------------------------------------
// Go reference: leafnode.go wsCreateLeafConnection — connection liveness check
[Fact]
public void IsConnected_ReflectsWebSocketState()
{
var openWs = Substitute.For<SystemWebSocket>();
openWs.State.Returns(WebSocketState.Open);
var closedWs = Substitute.For<SystemWebSocket>();
closedWs.State.Returns(WebSocketState.Closed);
var openAdapter = new WebSocketStreamAdapter(openWs);
var closedAdapter = new WebSocketStreamAdapter(closedWs);
openAdapter.IsConnected.ShouldBeTrue();
closedAdapter.IsConnected.ShouldBeFalse();
}
}

View File

@@ -1,151 +0,0 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class LeafProtocolTests
{
[Fact]
public async Task Leaf_link_propagates_subscription_and_message_flow()
{
await using var fx = await LeafProtocolTestFixture.StartHubSpokeAsync();
await fx.SubscribeSpokeAsync("leaf.>");
await fx.PublishHubAsync("leaf.msg", "x");
(await fx.ReadSpokeMessageAsync()).ShouldContain("x");
}
}
internal sealed class LeafProtocolTestFixture : IAsyncDisposable
{
private readonly NatsServer _hub;
private readonly NatsServer _spoke;
private readonly CancellationTokenSource _hubCts;
private readonly CancellationTokenSource _spokeCts;
private Socket? _spokeSubscriber;
private Socket? _hubPublisher;
private LeafProtocolTestFixture(NatsServer hub, NatsServer spoke, CancellationTokenSource hubCts, CancellationTokenSource spokeCts)
{
_hub = hub;
_spoke = spoke;
_hubCts = hubCts;
_spokeCts = spokeCts;
}
public static async Task<LeafProtocolTestFixture> StartHubSpokeAsync()
{
var hubOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
},
};
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
var hubCts = new CancellationTokenSource();
_ = hub.StartAsync(hubCts.Token);
await hub.WaitForReadyAsync();
var spokeOptions = new NatsOptions
{
Host = "127.0.0.1",
Port = 0,
LeafNode = new LeafNodeOptions
{
Host = "127.0.0.1",
Port = 0,
Remotes = [hub.LeafListen!],
},
};
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
var spokeCts = new CancellationTokenSource();
_ = spoke.StartAsync(spokeCts.Token);
await spoke.WaitForReadyAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
return new LeafProtocolTestFixture(hub, spoke, hubCts, spokeCts);
}
public async Task SubscribeSpokeAsync(string subject)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, _spoke.Port);
_spokeSubscriber = sock;
_ = await ReadLineAsync(sock); // INFO
await sock.SendAsync(Encoding.ASCII.GetBytes($"CONNECT {{}}\r\nSUB {subject} 1\r\nPING\r\n"));
await ReadUntilAsync(sock, "PONG");
}
public async Task PublishHubAsync(string subject, string payload)
{
var sock = _hubPublisher;
if (sock == null)
{
sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, _hub.Port);
_hubPublisher = sock;
_ = await ReadLineAsync(sock); // INFO
await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
await ReadUntilAsync(sock, "PONG");
}
await sock.SendAsync(Encoding.ASCII.GetBytes($"PUB {subject} {payload.Length}\r\n{payload}\r\nPING\r\n"));
await ReadUntilAsync(sock, "PONG");
}
public Task<string> ReadSpokeMessageAsync()
{
if (_spokeSubscriber == null)
throw new InvalidOperationException("Spoke subscriber was not initialized.");
return ReadUntilAsync(_spokeSubscriber, "MSG ");
}
public async ValueTask DisposeAsync()
{
_spokeSubscriber?.Dispose();
_hubPublisher?.Dispose();
await _hubCts.CancelAsync();
await _spokeCts.CancelAsync();
_hub.Dispose();
_spoke.Dispose();
_hubCts.Dispose();
_spokeCts.Dispose();
}
private static async Task<string> ReadLineAsync(Socket sock)
{
var buf = new byte[4096];
var n = await sock.ReceiveAsync(buf, SocketFlags.None);
return Encoding.ASCII.GetString(buf, 0, n);
}
private static async Task<string> ReadUntilAsync(Socket sock, string expected)
{
var sb = new StringBuilder();
var buf = new byte[4096];
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
{
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
if (n == 0)
break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
}