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; /// /// 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 /// 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("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("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("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("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("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("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("mixed.sub.test"); await using var queueSub = await spokeConn.SubscribeCoreAsync("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("qdist.test", queueGroup: "workers"); await using var qSub2 = await conn2.SubscribeCoreAsync("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("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($"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("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(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("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(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("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.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.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(); 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(); 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(); 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(); 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("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(), 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(), 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 { ["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 { ["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("fan.out.test"); await using var sub2 = await spokeConn.SubscribeCoreAsync("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 ReadRawLineAsync(Socket socket, CancellationToken ct = default) { var bytes = new List(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 ReadStreamLineAsync(NetworkStream stream, CancellationToken ct = default) { var bytes = new List(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 predicate, int timeoutMs = 5000) { await PollHelper.WaitOrThrowAsync(predicate, $"Condition not met within {timeoutMs}ms.", timeoutMs: timeoutMs); } } /// /// Shared fixture for LeafNodeGoParityTests. Creates a hub and a spoke server /// connected via the NATS leaf node protocol. /// 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 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(); } }