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:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user