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.
1758 lines
70 KiB
C#
1758 lines
70 KiB
C#
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.LeafNodes.Tests.LeafNode;
|
|
|
|
/// <summary>
|
|
/// Go-parity tests for leaf node functionality.
|
|
/// Covers: solicited connections, retry/backoff, loop detection, subject filtering,
|
|
/// queue group distribution, JetStream domain forwarding, daisy-chain topologies,
|
|
/// proxy protocol, compression negotiation stubs, and TLS handshake-first stubs.
|
|
///
|
|
/// Go reference: server/leafnode_test.go, server/leafnode_proxy_test.go,
|
|
/// server/jetstream_leafnode_test.go
|
|
/// </summary>
|
|
public class LeafNodeGoParityTests
|
|
{
|
|
// ---------------------------------------------------------------------------
|
|
// Connection lifecycle — basic hub/spoke
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeBasicAuthSingleton (leafnode_test.go:602)
|
|
[Fact]
|
|
public async Task Hub_and_spoke_establish_leaf_connection()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
Interlocked.Read(ref fx.Hub.Stats.Leafs).ShouldBe(1L);
|
|
Interlocked.Read(ref fx.Spoke.Stats.Leafs).ShouldBe(1L);
|
|
}
|
|
|
|
// Go: TestLeafNodeRTT (leafnode_test.go:488)
|
|
[Fact]
|
|
public async Task Hub_and_spoke_both_report_one_leaf_connection()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
Interlocked.Read(ref fx.Hub.Stats.Leafs).ShouldBeGreaterThan(0);
|
|
Interlocked.Read(ref fx.Spoke.Stats.Leafs).ShouldBeGreaterThan(0);
|
|
}
|
|
|
|
// Go: TestLeafNodeHubWithGateways (leafnode_test.go:1584)
|
|
[Fact]
|
|
public async Task Leaf_count_increments_when_spoke_connects()
|
|
{
|
|
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();
|
|
|
|
await WaitForConditionAsync(() => Interlocked.Read(ref hub.Stats.Leafs) >= 1);
|
|
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
|
|
|
|
await spokeCts.CancelAsync();
|
|
spoke.Dispose();
|
|
|
|
await WaitForConditionAsync(() => Interlocked.Read(ref hub.Stats.Leafs) == 0);
|
|
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(0);
|
|
|
|
await hubCts.CancelAsync();
|
|
hub.Dispose();
|
|
spokeCts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodeTwoRemotesBindToSameHubAccount (leafnode_test.go:2210)
|
|
[Fact]
|
|
public async Task Two_spokes_can_connect_to_same_hub()
|
|
{
|
|
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();
|
|
|
|
await WaitForConditionAsync(() => Interlocked.Read(ref hub.Stats.Leafs) >= 2);
|
|
Interlocked.Read(ref hub.Stats.Leafs).ShouldBeGreaterThanOrEqualTo(2);
|
|
Interlocked.Read(ref spoke1.Stats.Leafs).ShouldBeGreaterThan(0);
|
|
Interlocked.Read(ref spoke2.Stats.Leafs).ShouldBeGreaterThan(0);
|
|
|
|
await spoke2Cts.CancelAsync();
|
|
await spoke1Cts.CancelAsync();
|
|
await hubCts.CancelAsync();
|
|
spoke2.Dispose();
|
|
spoke1.Dispose();
|
|
hub.Dispose();
|
|
spoke2Cts.Dispose();
|
|
spoke1Cts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodeOriginClusterInfo (leafnode_test.go:1942)
|
|
[Fact]
|
|
public async Task Hub_and_spoke_have_distinct_server_ids()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
fx.Hub.ServerId.ShouldNotBeNullOrEmpty();
|
|
fx.Spoke.ServerId.ShouldNotBeNullOrEmpty();
|
|
fx.Hub.ServerId.ShouldNotBe(fx.Spoke.ServerId);
|
|
}
|
|
|
|
// Go: TestLeafNodeBannerNoClusterNameIfNoCluster (leafnode_test.go:9803)
|
|
[Fact]
|
|
public async Task LeafListen_endpoint_is_non_empty_and_parseable()
|
|
{
|
|
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.LeafListen.ShouldNotBeNull();
|
|
server.LeafListen.ShouldStartWith("127.0.0.1:");
|
|
var portStr = server.LeafListen.Split(':')[1];
|
|
int.TryParse(portStr, out var port).ShouldBeTrue();
|
|
port.ShouldBeGreaterThan(0);
|
|
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
cts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodeNoDuplicateWithinCluster (leafnode_test.go:2286)
|
|
[Fact]
|
|
public async Task Server_without_leaf_config_has_null_leaf_listen()
|
|
{
|
|
var options = new NatsOptions { 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.LeafListen.ShouldBeNull();
|
|
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
cts.Dispose();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Message forwarding — hub-to-spoke and spoke-to-hub
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeRemoteIsHub (leafnode_test.go:1177)
|
|
[Fact]
|
|
public async Task Hub_publishes_and_spoke_subscriber_receives()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
|
|
await using var sub = await spokeConn.SubscribeCoreAsync<string>("hub.to.spoke");
|
|
await spokeConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnHubAsync("hub.to.spoke");
|
|
|
|
await hubConn.PublishAsync("hub.to.spoke", "hello-from-hub");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("hello-from-hub");
|
|
}
|
|
|
|
// Go: TestLeafNodeStreamImport (leafnode_test.go:3441)
|
|
[Fact]
|
|
public async Task Spoke_publishes_and_hub_subscriber_receives()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
|
|
await using var sub = await hubConn.SubscribeCoreAsync<string>("spoke.to.hub");
|
|
await hubConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnSpokeAsync("spoke.to.hub");
|
|
|
|
await spokeConn.PublishAsync("spoke.to.hub", "hello-from-spoke");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("hello-from-spoke");
|
|
}
|
|
|
|
// Go: TestLeafNodePubAllowedPruning (leafnode_test.go:1452)
|
|
[Fact]
|
|
public async Task Hub_publishes_rapidly_and_all_messages_arrive_on_spoke()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
|
|
await using var sub = await spokeConn.SubscribeCoreAsync<string>("rapid.leaf.test");
|
|
await spokeConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnHubAsync("rapid.leaf.test");
|
|
|
|
const int count = 50;
|
|
for (var i = 0; i < count; i++)
|
|
await hubConn.PublishAsync("rapid.leaf.test", $"msg-{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);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interest propagation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeInterestPropagationDaisychain (leafnode_test.go:3953)
|
|
[Fact]
|
|
public async Task Three_server_daisy_chain_establishes_all_leaf_connections()
|
|
{
|
|
// A (hub) <- B (spoke/hub) <- C (leaf spoke)
|
|
// The Go test also verifies interest propagates all the way from C to A.
|
|
// The .NET port establishes the connections but multi-hop interest propagation
|
|
// across the full daisy chain is tested separately via the existing
|
|
// LeafNodeAdvancedTests.Daisy_chain_A_to_B_to_C_establishes_leaf_connections.
|
|
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 all three leaf connections to be established.
|
|
// B has TWO leaf connections: one outbound to A, one inbound from C.
|
|
await PollHelper.WaitUntilAsync(() => serverA.Stats.Leafs > 0
|
|
&& Interlocked.Read(ref serverB.Stats.Leafs) >= 2
|
|
&& serverC.Stats.Leafs > 0, timeoutMs: 10000);
|
|
|
|
// Verify the connection counts match the expected topology
|
|
Interlocked.Read(ref serverA.Stats.Leafs).ShouldBe(1); // A has 1 inbound from B
|
|
Interlocked.Read(ref serverB.Stats.Leafs).ShouldBeGreaterThanOrEqualTo(2); // B has outbound+inbound
|
|
Interlocked.Read(ref serverC.Stats.Leafs).ShouldBe(1); // C has 1 outbound to B
|
|
|
|
// Each server should have a unique ID
|
|
serverA.ServerId.ShouldNotBe(serverB.ServerId);
|
|
serverB.ServerId.ShouldNotBe(serverC.ServerId);
|
|
serverA.ServerId.ShouldNotBe(serverC.ServerId);
|
|
|
|
// B-C connection: C subscribes and B sees remote interest immediately (single hop)
|
|
await using var connC = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{serverC.Port}" });
|
|
await connC.ConnectAsync();
|
|
await using var sub = await connC.SubscribeCoreAsync<string>("bc.test");
|
|
await connC.PingAsync();
|
|
await WaitForConditionAsync(() => serverB.HasRemoteInterest("bc.test"), timeoutMs: 5000);
|
|
serverB.HasRemoteInterest("bc.test").ShouldBeTrue();
|
|
|
|
await cCts.CancelAsync();
|
|
await bCts.CancelAsync();
|
|
await aCts.CancelAsync();
|
|
serverC.Dispose();
|
|
serverB.Dispose();
|
|
serverA.Dispose();
|
|
cCts.Dispose();
|
|
bCts.Dispose();
|
|
aCts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodeStreamAndShadowSubs (leafnode_test.go:6176)
|
|
[Fact]
|
|
public async Task Wildcard_subscription_on_spoke_receives_from_hub()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var sub = await spokeConn.SubscribeCoreAsync<string>("wildcard.*.sub");
|
|
await spokeConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnHubAsync("wildcard.xyz.sub");
|
|
|
|
await hubConn.PublishAsync("wildcard.xyz.sub", "wildcard-match");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("wildcard-match");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Queue group distribution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeQueueGroupDistribution (leafnode_test.go:4021)
|
|
[Fact]
|
|
public async Task Queue_subscriber_on_spoke_receives_from_hub()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var qSub = await spokeConn.SubscribeCoreAsync<string>("queue.test", queueGroup: "workers");
|
|
await spokeConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnHubAsync("queue.test");
|
|
|
|
await hubConn.PublishAsync("queue.test", "work-item");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await qSub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("work-item");
|
|
}
|
|
|
|
// Go: TestLeafNodeDupeDeliveryQueueSubAndPlainSub (leafnode_test.go:9634)
|
|
[Fact]
|
|
public async Task Both_plain_and_queue_subscriber_on_spoke_receive_from_hub()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var plainSub = await spokeConn.SubscribeCoreAsync<string>("mixed.sub.test");
|
|
await using var queueSub = await spokeConn.SubscribeCoreAsync<string>("mixed.sub.test", queueGroup: "grp");
|
|
await spokeConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnHubAsync("mixed.sub.test");
|
|
|
|
await hubConn.PublishAsync("mixed.sub.test", "to-both");
|
|
|
|
using var c1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var plainMsg = await plainSub.Msgs.ReadAsync(c1.Token);
|
|
plainMsg.Data.ShouldBe("to-both");
|
|
|
|
using var c2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var queueMsg = await queueSub.Msgs.ReadAsync(c2.Token);
|
|
queueMsg.Data.ShouldBe("to-both");
|
|
}
|
|
|
|
// Go: TestLeafNodeQueueGroupDistribution (leafnode_test.go:4021) — two-spoke variant
|
|
[Fact]
|
|
public async Task Queue_subs_on_two_spokes_both_have_interest_on_hub()
|
|
{
|
|
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 s1Cts = new CancellationTokenSource();
|
|
_ = spoke1.StartAsync(s1Cts.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 s2Cts = new CancellationTokenSource();
|
|
_ = spoke2.StartAsync(s2Cts.Token);
|
|
await spoke2.WaitForReadyAsync();
|
|
|
|
await WaitForConditionAsync(() => Interlocked.Read(ref hub.Stats.Leafs) >= 2);
|
|
|
|
await using var conn1 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke1.Port}" });
|
|
await conn1.ConnectAsync();
|
|
await using var conn2 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke2.Port}" });
|
|
await conn2.ConnectAsync();
|
|
|
|
await using var qSub1 = await conn1.SubscribeCoreAsync<string>("qdist.test", queueGroup: "workers");
|
|
await using var qSub2 = await conn2.SubscribeCoreAsync<string>("qdist.test", queueGroup: "workers");
|
|
await conn1.PingAsync();
|
|
await conn2.PingAsync();
|
|
|
|
await WaitForConditionAsync(() => hub.HasRemoteInterest("qdist.test"));
|
|
hub.HasRemoteInterest("qdist.test").ShouldBeTrue();
|
|
|
|
await s2Cts.CancelAsync();
|
|
await s1Cts.CancelAsync();
|
|
await hubCts.CancelAsync();
|
|
spoke2.Dispose();
|
|
spoke1.Dispose();
|
|
hub.Dispose();
|
|
s2Cts.Dispose();
|
|
s1Cts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subscription propagation and interest lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeUnsubOnRouteDisconnect (leafnode_test.go:3621)
|
|
[Fact]
|
|
public async Task After_unsub_on_spoke_hub_loses_remote_interest()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
|
|
await using var sub = await spokeConn.SubscribeCoreAsync<string>("sub.lifecycle");
|
|
await spokeConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnHubAsync("sub.lifecycle");
|
|
fx.Hub.HasRemoteInterest("sub.lifecycle").ShouldBeTrue();
|
|
|
|
await sub.DisposeAsync();
|
|
await spokeConn.PingAsync();
|
|
|
|
await WaitForConditionAsync(() => !fx.Hub.HasRemoteInterest("sub.lifecycle"));
|
|
fx.Hub.HasRemoteInterest("sub.lifecycle").ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestLeafNodePermissionsConcurrentAccess (leafnode_test.go:1389)
|
|
[Fact]
|
|
public async Task Concurrent_subscribe_and_unsubscribe_on_spoke_does_not_corrupt_state()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
var tasks = Enumerable.Range(0, 8).Select(i => Task.Run(async () =>
|
|
{
|
|
await using var conn = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fx.Spoke.Port}",
|
|
});
|
|
await conn.ConnectAsync();
|
|
var sub = await conn.SubscribeCoreAsync<string>($"concurrent.leaf.{i}");
|
|
await conn.PingAsync();
|
|
await PollHelper.YieldForAsync(30);
|
|
await sub.DisposeAsync();
|
|
await conn.PingAsync();
|
|
})).ToList();
|
|
|
|
await Task.WhenAll(tasks);
|
|
await PollHelper.YieldForAsync(200);
|
|
|
|
// All subs should be gone from hub's perspective
|
|
for (var i = 0; i < 8; i++)
|
|
fx.Hub.HasRemoteInterest($"concurrent.leaf.{i}").ShouldBeFalse();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subject deny filtering (DenyExports / DenyImports)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodePermissions (leafnode_test.go:1267)
|
|
[Fact]
|
|
public async Task DenyExports_prevents_spoke_messages_reaching_hub()
|
|
{
|
|
// DenyExports on the spoke blocks messages from flowing leaf→hub on denied subjects.
|
|
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!],
|
|
DenyExports = ["denied.subject"],
|
|
},
|
|
};
|
|
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
|
var spokeCts = new CancellationTokenSource();
|
|
_ = spoke.StartAsync(spokeCts.Token);
|
|
await spoke.WaitForReadyAsync();
|
|
|
|
await WaitForConditionAsync(() => hub.Stats.Leafs >= 1);
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
|
|
// Subscribe on hub for the denied subject
|
|
await using var hubSub = await hubConn.SubscribeCoreAsync<string>("denied.subject");
|
|
await hubConn.PingAsync();
|
|
|
|
// Publish from spoke on denied subject — should NOT arrive on hub
|
|
await spokeConn.PublishAsync("denied.subject", "should-be-blocked");
|
|
|
|
using var blockCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(400));
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await hubSub.Msgs.ReadAsync(blockCts.Token));
|
|
|
|
await spokeCts.CancelAsync();
|
|
await hubCts.CancelAsync();
|
|
spoke.Dispose();
|
|
hub.Dispose();
|
|
spokeCts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodePermissions (leafnode_test.go:1267) — import side
|
|
[Fact]
|
|
public async Task DenyImports_prevents_hub_messages_reaching_spoke_subscribers()
|
|
{
|
|
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!],
|
|
DenyImports = ["denied.import"],
|
|
},
|
|
};
|
|
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
|
var spokeCts = new CancellationTokenSource();
|
|
_ = spoke.StartAsync(spokeCts.Token);
|
|
await spoke.WaitForReadyAsync();
|
|
|
|
await WaitForConditionAsync(() => hub.Stats.Leafs >= 1);
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
|
|
await using var spokeSub = await spokeConn.SubscribeCoreAsync<string>("denied.import");
|
|
await spokeConn.PingAsync();
|
|
|
|
// Publish from hub on denied import — message forwarded but blocked on inbound
|
|
await hubConn.PublishAsync("denied.import", "blocked-import");
|
|
|
|
using var blockCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(400));
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await spokeSub.Msgs.ReadAsync(blockCts.Token));
|
|
|
|
await spokeCts.CancelAsync();
|
|
await hubCts.CancelAsync();
|
|
spoke.Dispose();
|
|
hub.Dispose();
|
|
spokeCts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodePermissions (leafnode_test.go:1267) — allowed subjects pass through
|
|
[Fact]
|
|
public async Task Allowed_subjects_still_flow_when_deny_list_exists()
|
|
{
|
|
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!],
|
|
DenyExports = ["blocked.only"],
|
|
},
|
|
};
|
|
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
|
var spokeCts = new CancellationTokenSource();
|
|
_ = spoke.StartAsync(spokeCts.Token);
|
|
await spoke.WaitForReadyAsync();
|
|
|
|
await WaitForConditionAsync(() => hub.Stats.Leafs >= 1);
|
|
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
|
|
await using var hubSub = await hubConn.SubscribeCoreAsync<string>("allowed.subject");
|
|
await hubConn.PingAsync();
|
|
|
|
await WaitForConditionAsync(() => spoke.HasRemoteInterest("allowed.subject"));
|
|
await spokeConn.PublishAsync("allowed.subject", "allowed-payload");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await hubSub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("allowed-payload");
|
|
|
|
await spokeCts.CancelAsync();
|
|
await hubCts.CancelAsync();
|
|
spoke.Dispose();
|
|
hub.Dispose();
|
|
spokeCts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Loop detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeLoop (leafnode_test.go:837)
|
|
[Fact]
|
|
public void Loop_detector_marks_and_identifies_own_server_id()
|
|
{
|
|
var subject = "orders.created";
|
|
var serverId = "SERVER-ABC";
|
|
|
|
var marked = LeafLoopDetector.Mark(subject, serverId);
|
|
LeafLoopDetector.HasLoopMarker(marked).ShouldBeTrue();
|
|
LeafLoopDetector.IsLooped(marked, serverId).ShouldBeTrue();
|
|
LeafLoopDetector.IsLooped(marked, "OTHER-SERVER").ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestLeafNodeLoopDetectionOnActualLoop (leafnode_test.go:9410)
|
|
[Fact]
|
|
public void Loop_detector_unmarks_nested_markers()
|
|
{
|
|
var original = "events.stream";
|
|
var nested = LeafLoopDetector.Mark(
|
|
LeafLoopDetector.Mark(original, "S1"), "S2");
|
|
|
|
LeafLoopDetector.TryUnmark(nested, out var result).ShouldBeTrue();
|
|
result.ShouldBe(original);
|
|
}
|
|
|
|
// Go: TestLeafNodeLoopFromDAG (leafnode_test.go:899)
|
|
[Fact]
|
|
public void Loop_detector_does_not_mark_plain_subjects()
|
|
{
|
|
LeafLoopDetector.HasLoopMarker("foo.bar").ShouldBeFalse();
|
|
LeafLoopDetector.HasLoopMarker("$G").ShouldBeFalse();
|
|
LeafLoopDetector.HasLoopMarker("orders.>").ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestLeafNodeLoopDetectedOnAcceptSide (leafnode_test.go:1522)
|
|
[Fact]
|
|
public void Loop_detector_LDS_prefix_is_dollar_LDS_dot()
|
|
{
|
|
var marked = LeafLoopDetector.Mark("test", "SRV1");
|
|
marked.ShouldStartWith("$LDS.SRV1.");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Solicited connection retry / backoff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: leafnode.go reconnect with exponential backoff
|
|
[Fact]
|
|
public void Backoff_sequence_is_1_2_4_8_16_32_60_60()
|
|
{
|
|
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));
|
|
LeafNodeManager.ComputeBackoff(7).ShouldBe(TimeSpan.FromSeconds(60));
|
|
LeafNodeManager.ComputeBackoff(100).ShouldBe(TimeSpan.FromSeconds(60));
|
|
}
|
|
|
|
// Go: leafnode.go — negative attempt treated as 0
|
|
[Fact]
|
|
public void Backoff_with_negative_attempt_returns_initial_delay()
|
|
{
|
|
LeafNodeManager.ComputeBackoff(-1).ShouldBe(LeafNodeManager.InitialRetryDelay);
|
|
LeafNodeManager.ComputeBackoff(-100).ShouldBe(LeafNodeManager.InitialRetryDelay);
|
|
}
|
|
|
|
// Go: leafnode.go — max cap
|
|
[Fact]
|
|
public void Backoff_max_is_sixty_seconds()
|
|
{
|
|
LeafNodeManager.MaxRetryDelay.ShouldBe(TimeSpan.FromSeconds(60));
|
|
}
|
|
|
|
// Go: TestLeafNodeRemoteWrongPort (leafnode_test.go:1095)
|
|
[Fact]
|
|
public async Task Manager_with_unreachable_remote_does_not_establish_connections()
|
|
{
|
|
var options = new LeafNodeOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Remotes = ["127.0.0.1:19997"], // 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);
|
|
await PollHelper.YieldForAsync(300);
|
|
stats.Leafs.ShouldBe(0);
|
|
|
|
await cts.CancelAsync();
|
|
await manager.DisposeAsync();
|
|
}
|
|
|
|
// Go: leafnode.go — cancellation stops retry loop
|
|
[Fact]
|
|
public async Task Manager_cancellation_stops_retry_loop()
|
|
{
|
|
var options = new LeafNodeOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
Remotes = ["127.0.0.1:19996"],
|
|
};
|
|
|
|
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);
|
|
await PollHelper.YieldForAsync(150);
|
|
|
|
await cts.CancelAsync();
|
|
await manager.DisposeAsync(); // Must not hang
|
|
|
|
stats.Leafs.ShouldBe(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Raw wire protocol (LeafConnection)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeNoPingBeforeConnect (leafnode_test.go:3713)
|
|
[Fact]
|
|
public async Task Outbound_handshake_exchanges_LEAF_lines()
|
|
{
|
|
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 accepted = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(accepted);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var handshakeTask = leaf.PerformOutboundHandshakeAsync("HUB-ID", to.Token);
|
|
(await ReadRawLineAsync(clientSocket, to.Token)).ShouldBe("LEAF HUB-ID");
|
|
await WriteRawLineAsync(clientSocket, "LEAF SPOKE-ID", to.Token);
|
|
await handshakeTask;
|
|
|
|
leaf.RemoteId.ShouldBe("SPOKE-ID");
|
|
}
|
|
|
|
// Go: TestLeafNodeCloseTLSConnection (leafnode_test.go:968)
|
|
[Fact]
|
|
public async Task Inbound_handshake_exchanges_LEAF_lines_in_reverse()
|
|
{
|
|
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 accepted = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(accepted);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var handshakeTask = leaf.PerformInboundHandshakeAsync("SERVER-ID", to.Token);
|
|
await WriteRawLineAsync(remoteSocket, "LEAF CLIENT-ID", to.Token);
|
|
(await ReadRawLineAsync(remoteSocket, to.Token)).ShouldBe("LEAF SERVER-ID");
|
|
await handshakeTask;
|
|
|
|
leaf.RemoteId.ShouldBe("CLIENT-ID");
|
|
}
|
|
|
|
// Go: TestLeafNodeLMsgSplit (leafnode_test.go:2387)
|
|
[Fact]
|
|
public async Task LeafConnection_sends_LS_plus_for_subscription()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token); // consume LEAF L
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
await leaf.SendLsPlusAsync("$G", "test.sub", null, to.Token);
|
|
(await ReadRawLineAsync(remote, to.Token)).ShouldBe("LS+ $G test.sub");
|
|
}
|
|
|
|
// Go: TestLeafNodeRouteParseLSUnsub (leafnode_test.go:2486)
|
|
[Fact]
|
|
public async Task LeafConnection_sends_LS_minus_for_unsubscription()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token);
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
await leaf.SendLsPlusAsync("$G", "evt.sub", null, to.Token);
|
|
await ReadRawLineAsync(remote, to.Token); // consume LS+
|
|
await leaf.SendLsMinusAsync("$G", "evt.sub", null, to.Token);
|
|
(await ReadRawLineAsync(remote, to.Token)).ShouldBe("LS- $G evt.sub");
|
|
}
|
|
|
|
// Go: TestLeafNodeLMsgSplit (leafnode_test.go:2387)
|
|
[Fact]
|
|
public async Task LeafConnection_sends_LMSG_with_payload()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token);
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
var payload = "hello-leaf"u8.ToArray();
|
|
await leaf.SendMessageAsync("$G", "msg.subject", "reply-1", payload, to.Token);
|
|
|
|
var controlLine = await ReadRawLineAsync(remote, to.Token);
|
|
controlLine.ShouldBe($"LMSG $G msg.subject reply-1 {payload.Length}");
|
|
}
|
|
|
|
// Go: TestLeafNodeLMsgSplit (leafnode_test.go:2387) — no-reply variant
|
|
[Fact]
|
|
public async Task LeafConnection_sends_LMSG_with_dash_when_no_reply()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token);
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
var payload = "data"u8.ToArray();
|
|
await leaf.SendMessageAsync("$G", "no.reply.sub", null, payload, to.Token);
|
|
|
|
var controlLine = await ReadRawLineAsync(remote, to.Token);
|
|
controlLine.ShouldBe($"LMSG $G no.reply.sub - {payload.Length}");
|
|
}
|
|
|
|
// Go: TestLeafNodeTmpClients (leafnode_test.go:1663)
|
|
[Fact]
|
|
public async Task LeafConnection_read_loop_fires_subscription_callback_on_LS_plus()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token);
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
var received = new List<RemoteSubscription>();
|
|
leaf.RemoteSubscriptionReceived = sub => { received.Add(sub); return Task.CompletedTask; };
|
|
leaf.StartLoop(to.Token);
|
|
|
|
await WriteRawLineAsync(remote, "LS+ $G orders.>", to.Token);
|
|
await WaitForConditionAsync(() => received.Count >= 1);
|
|
|
|
received[0].Subject.ShouldBe("orders.>");
|
|
received[0].Account.ShouldBe("$G");
|
|
received[0].IsRemoval.ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestLeafNodeRouteParseLSUnsub (leafnode_test.go:2486)
|
|
[Fact]
|
|
public async Task LeafConnection_read_loop_fires_removal_callback_on_LS_minus()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token);
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
var received = new List<RemoteSubscription>();
|
|
leaf.RemoteSubscriptionReceived = sub => { received.Add(sub); return Task.CompletedTask; };
|
|
leaf.StartLoop(to.Token);
|
|
|
|
await WriteRawLineAsync(remote, "LS+ $G foo.events", to.Token);
|
|
await WaitForConditionAsync(() => received.Count >= 1);
|
|
await WriteRawLineAsync(remote, "LS- $G foo.events", to.Token);
|
|
await WaitForConditionAsync(() => received.Count >= 2);
|
|
|
|
received[1].Subject.ShouldBe("foo.events");
|
|
received[1].IsRemoval.ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestLeafNodeLMsgSplit (leafnode_test.go:2387) — inbound
|
|
[Fact]
|
|
public async Task LeafConnection_read_loop_fires_message_callback_on_LMSG()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token);
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
var messages = new List<LeafMessage>();
|
|
leaf.MessageReceived = msg => { messages.Add(msg); return Task.CompletedTask; };
|
|
leaf.StartLoop(to.Token);
|
|
|
|
var payload = "incoming-data"u8.ToArray();
|
|
await WriteRawLineAsync(remote, $"LMSG $G inbound.subject reply {payload.Length}", to.Token);
|
|
await remote.SendAsync(payload, SocketFlags.None, to.Token);
|
|
await remote.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, to.Token);
|
|
|
|
await WaitForConditionAsync(() => messages.Count >= 1);
|
|
|
|
messages[0].Subject.ShouldBe("inbound.subject");
|
|
messages[0].ReplyTo.ShouldBe("reply");
|
|
messages[0].Account.ShouldBe("$G");
|
|
Encoding.ASCII.GetString(messages[0].Payload.Span).ShouldBe("incoming-data");
|
|
}
|
|
|
|
// Go: TestLeafNodeTmpClients (leafnode_test.go:1663) — queue variant
|
|
[Fact]
|
|
public async Task LeafConnection_read_loop_parses_LS_plus_with_queue_group()
|
|
{
|
|
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await remote.ConnectAsync(IPAddress.Loopback, port);
|
|
using var leafSocket = await listener.AcceptSocketAsync();
|
|
|
|
await using var leaf = new LeafConnection(leafSocket);
|
|
using var to = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
|
|
var hs = leaf.PerformOutboundHandshakeAsync("L", to.Token);
|
|
await ReadRawLineAsync(remote, to.Token);
|
|
await WriteRawLineAsync(remote, "LEAF R", to.Token);
|
|
await hs;
|
|
|
|
var received = new List<RemoteSubscription>();
|
|
leaf.RemoteSubscriptionReceived = sub => { received.Add(sub); return Task.CompletedTask; };
|
|
leaf.StartLoop(to.Token);
|
|
|
|
await WriteRawLineAsync(remote, "LS+ $G work.tasks myWorkers", to.Token);
|
|
await WaitForConditionAsync(() => received.Count >= 1);
|
|
|
|
received[0].Subject.ShouldBe("work.tasks");
|
|
received[0].Queue.ShouldBe("myWorkers");
|
|
received[0].Account.ShouldBe("$G");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JetStream domain propagation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeJetStreamDomainMapCrossTalk (leafnode_test.go:5948)
|
|
[Fact]
|
|
public async Task JetStream_domain_included_in_outbound_handshake()
|
|
{
|
|
var hubOptions = new NatsOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
LeafNode = new LeafNodeOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
JetStreamDomain = "hub-js-domain",
|
|
},
|
|
};
|
|
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
|
var hubCts = new CancellationTokenSource();
|
|
_ = hub.StartAsync(hubCts.Token);
|
|
await hub.WaitForReadyAsync();
|
|
|
|
// Connect a raw socket to the hub leaf port and perform handshake
|
|
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);
|
|
|
|
var outMsg = Encoding.ASCII.GetBytes("LEAF spoke-server domain=spoke-domain\r\n");
|
|
await stream.WriteAsync(outMsg);
|
|
await stream.FlushAsync();
|
|
|
|
var response = await ReadStreamLineAsync(stream);
|
|
response.ShouldStartWith("LEAF ");
|
|
response.ShouldContain("domain=hub-js-domain");
|
|
|
|
await hubCts.CancelAsync();
|
|
hub.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodeJetStreamDomainMapCrossTalk (leafnode_test.go:5948)
|
|
[Fact]
|
|
public async Task Leaf_with_JetStream_enabled_hub_connects_and_hub_reports_js_enabled()
|
|
{
|
|
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-leaf-go-parity-{Guid.NewGuid():N}");
|
|
try
|
|
{
|
|
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();
|
|
|
|
await WaitForConditionAsync(() => hub.Stats.Leafs >= 1 && spoke.Stats.Leafs >= 1);
|
|
|
|
hub.Stats.JetStreamEnabled.ShouldBeTrue();
|
|
spoke.Stats.JetStreamEnabled.ShouldBeFalse();
|
|
Interlocked.Read(ref hub.Stats.Leafs).ShouldBe(1);
|
|
|
|
await spokeCts.CancelAsync();
|
|
await hubCts.CancelAsync();
|
|
spoke.Dispose();
|
|
hub.Dispose();
|
|
spokeCts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(storeDir))
|
|
Directory.Delete(storeDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
// Go: TestLeafNodeStreamImport (leafnode_test.go:3441)
|
|
[Fact]
|
|
public async Task Spoke_without_JetStream_still_forwards_messages_to_JetStream_hub()
|
|
{
|
|
var storeDir = Path.Combine(Path.GetTempPath(), $"nats-leaf-fwd-{Guid.NewGuid():N}");
|
|
try
|
|
{
|
|
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();
|
|
|
|
await WaitForConditionAsync(() => hub.Stats.Leafs >= 1);
|
|
|
|
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>("leaf.forward.test");
|
|
await hubConn.PingAsync();
|
|
|
|
await WaitForConditionAsync(() => spoke.HasRemoteInterest("leaf.forward.test"));
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
await spokeConn.PublishAsync("leaf.forward.test", "forwarded");
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(cts.Token);
|
|
msg.Data.ShouldBe("forwarded");
|
|
|
|
await spokeCts.CancelAsync();
|
|
await hubCts.CancelAsync();
|
|
spoke.Dispose();
|
|
hub.Dispose();
|
|
spokeCts.Dispose();
|
|
hubCts.Dispose();
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(storeDir))
|
|
Directory.Delete(storeDir, recursive: true);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LeafNodeManager — subject filter propagation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodePermissions (leafnode_test.go:1267) — DenyExports filter
|
|
[Fact]
|
|
public void LeafNodeManager_deny_exports_filter_rejects_matching_subjects()
|
|
{
|
|
var mapper = new LeafHubSpokeMapper(
|
|
new Dictionary<string, string>(),
|
|
denyExports: ["secret.*", "internal"],
|
|
denyImports: []);
|
|
|
|
mapper.IsSubjectAllowed("secret.data", LeafMapDirection.Outbound).ShouldBeFalse();
|
|
mapper.IsSubjectAllowed("internal", LeafMapDirection.Outbound).ShouldBeFalse();
|
|
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestLeafNodePermissions (leafnode_test.go:1267) — DenyImports filter
|
|
[Fact]
|
|
public void LeafNodeManager_deny_imports_filter_rejects_matching_subjects()
|
|
{
|
|
var mapper = new LeafHubSpokeMapper(
|
|
new Dictionary<string, string>(),
|
|
denyExports: [],
|
|
denyImports: ["admin.*", "sys"]);
|
|
|
|
mapper.IsSubjectAllowed("admin.kick", LeafMapDirection.Inbound).ShouldBeFalse();
|
|
mapper.IsSubjectAllowed("sys", LeafMapDirection.Inbound).ShouldBeFalse();
|
|
mapper.IsSubjectAllowed("user.events", LeafMapDirection.Inbound).ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestLeafNodeHubWithGateways (leafnode_test.go:1584) — account mapping
|
|
[Fact]
|
|
public void LeafHubSpokeMapper_maps_accounts_in_outbound_direction()
|
|
{
|
|
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>
|
|
{
|
|
["HUB_ACCT"] = "SPOKE_ACCT",
|
|
});
|
|
|
|
var result = mapper.Map("HUB_ACCT", "foo.bar", LeafMapDirection.Outbound);
|
|
result.Account.ShouldBe("SPOKE_ACCT");
|
|
result.Subject.ShouldBe("foo.bar");
|
|
}
|
|
|
|
// Go: TestLeafNodeHubWithGateways (leafnode_test.go:1584) — inbound mapping
|
|
[Fact]
|
|
public void LeafHubSpokeMapper_maps_accounts_in_inbound_direction()
|
|
{
|
|
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>
|
|
{
|
|
["HUB_ACCT"] = "SPOKE_ACCT",
|
|
});
|
|
|
|
var result = mapper.Map("SPOKE_ACCT", "foo.bar", LeafMapDirection.Inbound);
|
|
result.Account.ShouldBe("HUB_ACCT");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compression negotiation — stubs (feature not yet implemented in .NET)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeCompressionOptions (leafnode_test.go:6966)
|
|
[Fact]
|
|
public void Compression_mode_constants_match_Go_reference_strings()
|
|
{
|
|
// These values mirror the Go CompressionS2Auto/CompressionS2Fast/etc.
|
|
// constants used in TestLeafNodeCompressionOptions.
|
|
// The .NET implementation does not yet support S2 compression on leaf
|
|
// connections, so we stub the expected mode strings here as assertions
|
|
// about the Go reference values.
|
|
const string compressionOff = "off";
|
|
const string compressionAuto = "s2_auto";
|
|
const string compressionFast = "s2_fast";
|
|
const string compressionBetter = "s2_better";
|
|
const string compressionBest = "s2_best";
|
|
|
|
compressionOff.ShouldBe("off");
|
|
compressionAuto.ShouldBe("s2_auto");
|
|
compressionFast.ShouldBe("s2_fast");
|
|
compressionBetter.ShouldBe("s2_better");
|
|
compressionBest.ShouldBe("s2_best");
|
|
}
|
|
|
|
// Go: TestLeafNodeCompression (leafnode_test.go:7247)
|
|
[Fact]
|
|
public void Compression_options_struct_allows_default_mode()
|
|
{
|
|
// Stub: validates that LeafNodeOptions can be constructed without
|
|
// a compression mode (compression is opt-in in .NET port).
|
|
var options = new LeafNodeOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 0,
|
|
};
|
|
// No compression fields yet — verify options are valid defaults
|
|
options.DenyExports.ShouldNotBeNull();
|
|
options.DenyImports.ShouldNotBeNull();
|
|
options.Remotes.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TLS handshake-first — stubs (feature not yet implemented in .NET)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeTLSHandshakeFirstVerifyNoInfoSent (leafnode_test.go:6718)
|
|
[Fact]
|
|
public async Task Standard_leaf_listener_sends_LEAF_handshake_on_connection()
|
|
{
|
|
// In Go, TLS-handshake-first mode suppresses INFO until after TLS.
|
|
// In .NET, the standard (non-TLS-first) mode sends LEAF immediately.
|
|
// This test verifies the normal behavior: the server responds with LEAF.
|
|
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();
|
|
|
|
var leafParts = server.LeafListen!.Split(':');
|
|
using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await client.ConnectAsync(IPAddress.Parse(leafParts[0]), int.Parse(leafParts[1]));
|
|
using var stream = new NetworkStream(client, ownsSocket: false);
|
|
|
|
// Send LEAF greeting
|
|
await stream.WriteAsync(Encoding.ASCII.GetBytes("LEAF client-id\r\n"));
|
|
await stream.FlushAsync();
|
|
|
|
using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
|
var response = await ReadStreamLineAsync(stream, readCts.Token);
|
|
response.ShouldStartWith("LEAF ");
|
|
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
cts.Dispose();
|
|
}
|
|
|
|
// Go: TestLeafNodeTLSHandshakeFirst (leafnode_test.go:6808) — stub
|
|
[Fact]
|
|
public void TLS_handshake_first_option_is_not_set_by_default()
|
|
{
|
|
// Go reference: TLSHandshakeFirst field in LeafNodeOpts defaults to false.
|
|
// Stub: verifies our LeafNodeOptions does not have this as a required field.
|
|
var options = new LeafNodeOptions();
|
|
// If we add TlsHandshakeFirst later, assert it defaults to false.
|
|
// For now just assert the object can be constructed cleanly.
|
|
options.ShouldNotBeNull();
|
|
options.Host.ShouldNotBeNull();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP proxy — stubs (feature not yet implemented in .NET)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeHttpProxyConfigParsing (leafnode_proxy_test.go:210)
|
|
[Fact]
|
|
public void RemoteLeafOptions_can_hold_proxy_like_url_config()
|
|
{
|
|
// Stub: verifies that RemoteLeafOptions can represent a WebSocket URL
|
|
// entry as needed by the Go proxy tests. Full HTTP CONNECT proxy tunneling
|
|
// is not yet implemented.
|
|
var remote = new RemoteLeafOptions
|
|
{
|
|
Urls = ["ws://proxy.example.com:8080"],
|
|
LocalAccount = "MyAccount",
|
|
};
|
|
|
|
remote.Urls.ShouldContain("ws://proxy.example.com:8080");
|
|
remote.LocalAccount.ShouldBe("MyAccount");
|
|
}
|
|
|
|
// Go: TestLeafNodeHttpProxyValidationProgrammatic (leafnode_proxy_test.go:701)
|
|
[Fact]
|
|
public void RemoteLeafOptions_accepts_list_of_urls()
|
|
{
|
|
var remote = new RemoteLeafOptions
|
|
{
|
|
Urls = ["nats://hub1.example.com:7422", "nats://hub2.example.com:7422"],
|
|
};
|
|
|
|
remote.Urls.Count.ShouldBe(2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JetStream leafnode — stubs referencing jetstream_leafnode_test.go
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestJetStreamLeafNodeUniqueServerNameCrossJSDomain (jetstream_leafnode_test.go:31)
|
|
[Fact]
|
|
public async Task Hub_and_spoke_have_unique_server_names()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
fx.Hub.ServerId.ShouldNotBe(fx.Spoke.ServerId);
|
|
}
|
|
|
|
// Go: TestJetStreamLeafNodeCredsDenies (jetstream_leafnode_test.go:729)
|
|
[Fact]
|
|
public async Task LeafNode_options_has_empty_deny_lists_by_default()
|
|
{
|
|
var options = new LeafNodeOptions();
|
|
options.DenyExports.ShouldBeEmpty();
|
|
options.DenyImports.ShouldBeEmpty();
|
|
options.ExportSubjects.ShouldBeEmpty();
|
|
options.ImportSubjects.ShouldBeEmpty();
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
// Go: TestJetStreamLeafNodeDefaultDomainCfg (jetstream_leafnode_test.go:796)
|
|
[Fact]
|
|
public async Task LeafNode_jetstream_domain_can_be_set_programmatically()
|
|
{
|
|
var options = new LeafNodeOptions { JetStreamDomain = "my-domain" };
|
|
options.JetStreamDomain.ShouldBe("my-domain");
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
// Go: TestLeafNodeConfigureWriteDeadline (leafnode_test.go:10802)
|
|
[Fact]
|
|
public void LeafNodeOptions_write_deadline_defaults_to_zero()
|
|
{
|
|
var options = new LeafNodeOptions();
|
|
options.WriteDeadline.ShouldBe(TimeSpan.Zero);
|
|
}
|
|
|
|
// Go: TestLeafNodeConfigureWriteTimeoutPolicy (leafnode_test.go:10827)
|
|
[Fact]
|
|
public void LeafNodeOptions_write_deadline_can_be_set()
|
|
{
|
|
var options = new LeafNodeOptions { WriteDeadline = TimeSpan.FromSeconds(10) };
|
|
options.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
// Go: TestLeafNodeValidateAuthOptions (leafnode_test.go:583)
|
|
[Fact]
|
|
public void LeafNodeOptions_auth_fields_are_null_by_default()
|
|
{
|
|
var options = new LeafNodeOptions();
|
|
options.Username.ShouldBeNull();
|
|
options.Password.ShouldBeNull();
|
|
options.Users.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestLeafNodeBasicAuthSingleton (leafnode_test.go:602)
|
|
[Fact]
|
|
public void LeafNodeOptions_credentials_can_be_set()
|
|
{
|
|
var options = new LeafNodeOptions { Username = "leaf-user", Password = "leaf-pass" };
|
|
options.Username.ShouldBe("leaf-user");
|
|
options.Password.ShouldBe("leaf-pass");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Random IP / random remotes — stubs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeRandomRemotes (leafnode_test.go:98)
|
|
[Fact]
|
|
public void RemoteLeafOptions_DontRandomize_defaults_to_false()
|
|
{
|
|
var remote = new RemoteLeafOptions();
|
|
remote.DontRandomize.ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestLeafNodeRandomRemotes (leafnode_test.go:98)
|
|
[Fact]
|
|
public void RemoteLeafOptions_DontRandomize_can_be_set_to_true()
|
|
{
|
|
var remote = new RemoteLeafOptions { DontRandomize = true };
|
|
remote.DontRandomize.ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth timeout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeValidateAuthOptions (leafnode_test.go:583)
|
|
[Fact]
|
|
public void LeafNodeOptions_auth_timeout_defaults_to_zero()
|
|
{
|
|
var options = new LeafNodeOptions();
|
|
options.AuthTimeout.ShouldBe(0.0);
|
|
}
|
|
|
|
// Go: TestLeafNodeValidateAuthOptions (leafnode_test.go:583)
|
|
[Fact]
|
|
public void LeafNodeOptions_auth_timeout_can_be_set()
|
|
{
|
|
var options = new LeafNodeOptions { AuthTimeout = 2.5 };
|
|
options.AuthTimeout.ShouldBe(2.5);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Advertise
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodeTLSSaveName (leafnode_test.go:1050)
|
|
[Fact]
|
|
public void LeafNodeOptions_advertise_can_be_set()
|
|
{
|
|
var options = new LeafNodeOptions { Advertise = "external-host:5222" };
|
|
options.Advertise.ShouldBe("external-host:5222");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multiple subscribers on same subject
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Go: TestLeafNodePermissionWithLiteralSubjectAndQueueInterest (leafnode_test.go:9935)
|
|
[Fact]
|
|
public async Task Multiple_subscribers_on_spoke_all_receive_from_hub()
|
|
{
|
|
await using var fx = await LeafGoFixture.StartAsync();
|
|
|
|
await using var spokeConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Spoke.Port}" });
|
|
await spokeConn.ConnectAsync();
|
|
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fx.Hub.Port}" });
|
|
await hubConn.ConnectAsync();
|
|
|
|
await using var sub1 = await spokeConn.SubscribeCoreAsync<string>("fan.out.test");
|
|
await using var sub2 = await spokeConn.SubscribeCoreAsync<string>("fan.out.test");
|
|
await spokeConn.PingAsync();
|
|
await fx.WaitForRemoteInterestOnHubAsync("fan.out.test");
|
|
|
|
await hubConn.PublishAsync("fan.out.test", "fan-out-msg");
|
|
|
|
using var c1 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
(await sub1.Msgs.ReadAsync(c1.Token)).Data.ShouldBe("fan-out-msg");
|
|
|
|
using var c2 = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
(await sub2.Msgs.ReadAsync(c2.Token)).Data.ShouldBe("fan-out-msg");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private static async Task<string> ReadRawLineAsync(Socket socket, CancellationToken ct = default)
|
|
{
|
|
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 WriteRawLineAsync(Socket socket, string line, CancellationToken ct)
|
|
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
|
|
|
private static async Task<string> ReadStreamLineAsync(NetworkStream stream, CancellationToken ct = default)
|
|
{
|
|
var bytes = new List<byte>(64);
|
|
var single = new byte[1];
|
|
while (true)
|
|
{
|
|
var read = await stream.ReadAsync(single, 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 async Task WaitForConditionAsync(Func<bool> predicate, int timeoutMs = 5000)
|
|
{
|
|
await PollHelper.WaitOrThrowAsync(predicate, $"Condition not met within {timeoutMs}ms.", timeoutMs: timeoutMs);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared fixture for LeafNodeGoParityTests. Creates a hub and a spoke server
|
|
/// connected via the NATS leaf node protocol.
|
|
/// </summary>
|
|
internal sealed class LeafGoFixture : IAsyncDisposable
|
|
{
|
|
private readonly CancellationTokenSource _hubCts;
|
|
private readonly CancellationTokenSource _spokeCts;
|
|
|
|
private LeafGoFixture(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<LeafGoFixture> 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();
|
|
|
|
await PollHelper.WaitUntilAsync(() => !((hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0)), timeoutMs: 5000);
|
|
|
|
return new LeafGoFixture(hub, spoke, hubCts, spokeCts);
|
|
}
|
|
|
|
public async Task WaitForRemoteInterestOnHubAsync(string subject)
|
|
{
|
|
await PollHelper.WaitOrThrowAsync(() => Hub.HasRemoteInterest(subject), $"Timed out waiting for hub remote interest on '{subject}'.", timeoutMs: 5000);
|
|
}
|
|
|
|
public async Task WaitForRemoteInterestOnSpokeAsync(string subject)
|
|
{
|
|
await PollHelper.WaitOrThrowAsync(() => Spoke.HasRemoteInterest(subject), $"Timed out waiting for spoke remote interest on '{subject}'.", timeoutMs: 5000);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _spokeCts.CancelAsync();
|
|
await _hubCts.CancelAsync();
|
|
Spoke.Dispose();
|
|
Hub.Dispose();
|
|
_spokeCts.Dispose();
|
|
_hubCts.Dispose();
|
|
}
|
|
}
|