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.Gateways; using NATS.Server.Subscriptions; namespace NATS.Server.Gateways.Tests.Gateways; /// /// Gateway interest-only mode, account interest, subject interest propagation, /// and subscription lifecycle tests. /// Ported from golang/nats-server/server/gateway_test.go. /// public class GatewayInterestModeTests { // ── Remote Interest Tracking via SubList ───────────────────────────── // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void Remote_interest_tracked_for_literal_subject() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.created", null, "gw1", "$G")); subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); subList.HasRemoteInterest("$G", "orders.updated").ShouldBeFalse(); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void Remote_interest_tracked_for_wildcard_subject() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G")); subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); subList.HasRemoteInterest("$G", "orders.updated").ShouldBeTrue(); subList.HasRemoteInterest("$G", "orders.deep.nested").ShouldBeFalse(); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void Remote_interest_tracked_for_fwc_subject() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("events.>", null, "gw1", "$G")); subList.HasRemoteInterest("$G", "events.one").ShouldBeTrue(); subList.HasRemoteInterest("$G", "events.one.two.three").ShouldBeTrue(); subList.HasRemoteInterest("$G", "other").ShouldBeFalse(); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void Remote_interest_scoped_to_account() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "ACCT_A")); subList.HasRemoteInterest("ACCT_A", "orders.created").ShouldBeTrue(); subList.HasRemoteInterest("ACCT_B", "orders.created").ShouldBeFalse(); subList.HasRemoteInterest("$G", "orders.created").ShouldBeFalse(); } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 [Fact] public void Remote_interest_removed_on_aminus() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "$G")); subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); subList.ApplyRemoteSub(RemoteSubscription.Removal("orders.>", null, "gw1", "$G")); subList.HasRemoteInterest("$G", "orders.created").ShouldBeFalse(); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void Multiple_remote_interests_from_different_routes() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G")); subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw2", "$G")); subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); subList.MatchRemote("$G", "orders.created").Count.ShouldBe(2); } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 [Fact] public void Removing_one_route_interest_keeps_other() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G")); subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw2", "$G")); subList.ApplyRemoteSub(RemoteSubscription.Removal("orders.*", null, "gw1", "$G")); subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); subList.MatchRemote("$G", "orders.created").Count.ShouldBe(1); } // ── Interest Change Events ────────────────────────────────────────── // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 [Fact] public void Interest_change_event_fired_on_remote_add() { using var subList = new SubList(); var changes = new List(); subList.InterestChanged += change => changes.Add(change); subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); changes.Count.ShouldBe(1); changes[0].Kind.ShouldBe(InterestChangeKind.RemoteAdded); changes[0].Subject.ShouldBe("test.>"); changes[0].Account.ShouldBe("$G"); } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 [Fact] public void Interest_change_event_fired_on_remote_remove() { using var subList = new SubList(); var changes = new List(); subList.InterestChanged += change => changes.Add(change); subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); subList.ApplyRemoteSub(RemoteSubscription.Removal("test.>", null, "gw1", "$G")); changes.Count.ShouldBe(2); changes[1].Kind.ShouldBe(InterestChangeKind.RemoteRemoved); } // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 [Fact] public void Duplicate_remote_add_does_not_fire_extra_event() { using var subList = new SubList(); var addCount = 0; subList.InterestChanged += change => { if (change.Kind == InterestChangeKind.RemoteAdded) addCount++; }; subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); addCount.ShouldBe(1); } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 [Fact] public void Remove_nonexistent_subscription_does_not_fire_event() { using var subList = new SubList(); var removeCount = 0; subList.InterestChanged += change => { if (change.Kind == InterestChangeKind.RemoteRemoved) removeCount++; }; subList.ApplyRemoteSub(RemoteSubscription.Removal("nonexistent", null, "gw1", "$G")); removeCount.ShouldBe(0); } // ── Queue Weight in MatchRemote ───────────────────────────────────── // Go: TestGatewayTotalQSubs server/gateway_test.go:2484 [Fact] public void Match_remote_expands_queue_weight() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G", QueueWeight: 3)); var matches = subList.MatchRemote("$G", "foo"); matches.Count.ShouldBe(3); } // Go: TestGatewayTotalQSubs server/gateway_test.go:2484 [Fact] public void Match_remote_default_weight_is_one() { using var subList = new SubList(); subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); var matches = subList.MatchRemote("$G", "foo"); matches.Count.ShouldBe(1); } // ── End-to-End Interest Propagation via Gateway ───────────────────── // Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755 [Fact] public async Task Local_subscription_propagated_to_remote_via_gateway() { await using var fixture = await InterestModeFixture.StartAsync(); await using var localConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await localConn.ConnectAsync(); await using var sub = await localConn.SubscribeCoreAsync("prop.test"); await localConn.PingAsync(); // The remote server should see the interest using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && !fixture.Remote.HasRemoteInterest("prop.test")) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); fixture.Remote.HasRemoteInterest("prop.test").ShouldBeTrue(); } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 [Fact] public async Task Unsubscribe_propagated_to_remote_via_gateway() { await using var fixture = await InterestModeFixture.StartAsync(); await using var localConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Local.Port}", }); await localConn.ConnectAsync(); var sub = await localConn.SubscribeCoreAsync("unsub.test"); await localConn.PingAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && !fixture.Remote.HasRemoteInterest("unsub.test")) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); fixture.Remote.HasRemoteInterest("unsub.test").ShouldBeTrue(); // Unsubscribe await sub.DisposeAsync(); await localConn.PingAsync(); // Wait for interest to be removed using var unsubTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!unsubTimeout.IsCancellationRequested && fixture.Remote.HasRemoteInterest("unsub.test")) await Task.Delay(50, unsubTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); fixture.Remote.HasRemoteInterest("unsub.test").ShouldBeFalse(); } // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 [Fact] public async Task Remote_wildcard_subscription_establishes_interest() { await using var fixture = await InterestModeFixture.StartAsync(); await using var remoteConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await remoteConn.ConnectAsync(); await using var sub = await remoteConn.SubscribeCoreAsync("interest.>"); await remoteConn.PingAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("interest.test")) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); fixture.Local.HasRemoteInterest("interest.test").ShouldBeTrue(); fixture.Local.HasRemoteInterest("interest.deep.nested").ShouldBeTrue(); } // Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755 [Fact] public async Task Multiple_subscribers_same_subject_produces_single_interest() { await using var fixture = await InterestModeFixture.StartAsync(); await using var conn1 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await conn1.ConnectAsync(); await using var conn2 = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{fixture.Remote.Port}", }); await conn2.ConnectAsync(); await using var sub1 = await conn1.SubscribeCoreAsync("multi.interest"); await using var sub2 = await conn2.SubscribeCoreAsync("multi.interest"); await conn1.PingAsync(); await conn2.PingAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("multi.interest")) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); fixture.Local.HasRemoteInterest("multi.interest").ShouldBeTrue(); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public async Task Account_scoped_interest_propagated_via_gateway() { var users = new User[] { new() { Username = "acct_user", Password = "pass", Account = "MYACCT" }, }; await using var fixture = await InterestModeFixture.StartWithUsersAsync(users); await using var conn = new NatsConnection(new NatsOpts { Url = $"nats://acct_user:pass@127.0.0.1:{fixture.Remote.Port}", }); await conn.ConnectAsync(); await using var sub = await conn.SubscribeCoreAsync("acct.interest"); await conn.PingAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("MYACCT", "acct.interest")) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); fixture.Local.HasRemoteInterest("MYACCT", "acct.interest").ShouldBeTrue(); } // ── RemoteSubscription Record Tests ───────────────────────────────── // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void RemoteSubscription_record_equality() { var a = new RemoteSubscription("foo", null, "gw1", "$G"); var b = new RemoteSubscription("foo", null, "gw1", "$G"); a.ShouldBe(b); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void RemoteSubscription_removal_factory() { var removal = RemoteSubscription.Removal("foo", "bar", "gw1", "$G"); removal.IsRemoval.ShouldBeTrue(); removal.Subject.ShouldBe("foo"); removal.Queue.ShouldBe("bar"); removal.RouteId.ShouldBe("gw1"); } // Go: TestGatewayAccountInterest server/gateway_test.go:1794 [Fact] public void RemoteSubscription_default_account_is_global() { var sub = new RemoteSubscription("foo", null, "gw1"); sub.Account.ShouldBe("$G"); } // Go: TestGatewayTotalQSubs server/gateway_test.go:2484 [Fact] public void RemoteSubscription_default_queue_weight_is_one() { var sub = new RemoteSubscription("foo", "bar", "gw1"); sub.QueueWeight.ShouldBe(1); } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 [Fact] public void RemoteSubscription_default_is_not_removal() { var sub = new RemoteSubscription("foo", null, "gw1"); sub.IsRemoval.ShouldBeFalse(); } // ── Subscription Propagation by GatewayManager ────────────────────── // Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755 [Fact] public async Task Gateway_manager_propagate_subscription_sends_aplus() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; var options = new GatewayOptions { Name = "LOCAL", Host = "127.0.0.1", Port = 0, Remotes = [$"127.0.0.1:{port}"], }; var manager = new GatewayManager( options, new ServerStats(), "SERVER1", _ => { }, _ => { }, NullLogger.Instance); await manager.StartAsync(CancellationToken.None); // Accept the connection from gateway manager using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using var gwSocket = await listener.AcceptSocketAsync(cts.Token); // Exchange handshakes var line = await ReadLineAsync(gwSocket, cts.Token); line.ShouldStartWith("GATEWAY "); await WriteLineAsync(gwSocket, "GATEWAY REMOTE1", cts.Token); // Wait for connection to be registered await Task.Delay(200); // Propagate a subscription manager.PropagateLocalSubscription("$G", "orders.>", null); // Read the A+ message await Task.Delay(100); var aplusLine = await ReadLineAsync(gwSocket, cts.Token); aplusLine.ShouldBe("A+ $G orders.>"); await manager.DisposeAsync(); } // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 [Fact] public async Task Gateway_manager_propagate_unsubscription_sends_aminus() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var port = ((IPEndPoint)listener.LocalEndpoint).Port; var options = new GatewayOptions { Name = "LOCAL", Host = "127.0.0.1", Port = 0, Remotes = [$"127.0.0.1:{port}"], }; var manager = new GatewayManager( options, new ServerStats(), "SERVER1", _ => { }, _ => { }, NullLogger.Instance); await manager.StartAsync(CancellationToken.None); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); using var gwSocket = await listener.AcceptSocketAsync(cts.Token); var line = await ReadLineAsync(gwSocket, cts.Token); line.ShouldStartWith("GATEWAY "); await WriteLineAsync(gwSocket, "GATEWAY REMOTE1", cts.Token); await Task.Delay(200); manager.PropagateLocalUnsubscription("$G", "orders.>", null); await Task.Delay(100); var aminusLine = await ReadLineAsync(gwSocket, cts.Token); aminusLine.ShouldBe("A- $G orders.>"); await manager.DisposeAsync(); } // ── Helpers ───────────────────────────────────────────────────────── private static async Task ReadLineAsync(Socket socket, CancellationToken ct) { 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 WriteLineAsync(Socket socket, string line, CancellationToken ct) => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); } /// /// Shared fixture for interest mode tests. /// internal sealed class InterestModeFixture : IAsyncDisposable { private readonly CancellationTokenSource _localCts; private readonly CancellationTokenSource _remoteCts; private InterestModeFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts) { Local = local; Remote = remote; _localCts = localCts; _remoteCts = remoteCts; } public NatsServer Local { get; } public NatsServer Remote { get; } public static Task StartAsync() => StartWithUsersAsync(null); public static async Task StartWithUsersAsync(IReadOnlyList? users) { var localOptions = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Gateway = new GatewayOptions { Name = "LOCAL", Host = "127.0.0.1", Port = 0, }, }; var local = new NatsServer(localOptions, NullLoggerFactory.Instance); var localCts = new CancellationTokenSource(); _ = local.StartAsync(localCts.Token); await local.WaitForReadyAsync(); var remoteOptions = new NatsOptions { Host = "127.0.0.1", Port = 0, Users = users, Gateway = new GatewayOptions { Name = "REMOTE", Host = "127.0.0.1", Port = 0, Remotes = [local.GatewayListen!], }, }; var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); var remoteCts = new CancellationTokenSource(); _ = remote.StartAsync(remoteCts.Token); await remote.WaitForReadyAsync(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); return new InterestModeFixture(local, remote, localCts, remoteCts); } public async ValueTask DisposeAsync() { await _localCts.CancelAsync(); await _remoteCts.CancelAsync(); Local.Dispose(); Remote.Dispose(); _localCts.Dispose(); _remoteCts.Dispose(); } }