Move 25 gateway-related test files from NATS.Server.Tests into a dedicated NATS.Server.Gateways.Tests project. Update namespaces, replace private ReadUntilAsync with SocketTestHelper from TestUtilities, inline TestServerFactory usage, add InternalsVisibleTo, and register the project in the solution file. All 261 tests pass.
776 lines
29 KiB
C#
776 lines
29 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.Gateways;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Gateways.Tests.Gateways;
|
|
|
|
/// <summary>
|
|
/// Gateway message forwarding, reply mapping, queue subscription delivery,
|
|
/// and cross-cluster pub/sub tests.
|
|
/// Ported from golang/nats-server/server/gateway_test.go.
|
|
/// </summary>
|
|
public class GatewayForwardingTests
|
|
{
|
|
// ── Basic Message Forwarding ────────────────────────────────────────
|
|
|
|
// Go: TestGatewayBasic server/gateway_test.go:399
|
|
[Fact]
|
|
public async Task Message_published_on_local_arrives_at_remote_subscriber()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("fwd.test");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("fwd.test");
|
|
|
|
await publisher.PublishAsync("fwd.test", "hello-world");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("hello-world");
|
|
}
|
|
|
|
// Go: TestGatewayBasic server/gateway_test.go:399
|
|
[Fact]
|
|
public async Task Message_published_on_remote_arrives_at_local_subscriber()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("fwd.reverse");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnRemoteAsync("fwd.reverse");
|
|
|
|
await publisher.PublishAsync("fwd.reverse", "reverse-msg");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("reverse-msg");
|
|
}
|
|
|
|
// Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993
|
|
[Fact]
|
|
public async Task Message_forwarded_only_once_to_remote_subscriber()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("once.test");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("once.test");
|
|
|
|
await publisher.PublishAsync("once.test", "exactly-once");
|
|
await publisher.PingAsync();
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("exactly-once");
|
|
|
|
// Wait and verify no duplicates
|
|
await Task.Delay(300);
|
|
using var noMoreTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await sub.Msgs.ReadAsync(noMoreTimeout.Token));
|
|
}
|
|
|
|
// Go: TestGatewaySendsToNonLocalSubs server/gateway_test.go:3140
|
|
[Fact]
|
|
public async Task Message_without_local_subscriber_forwarded_to_remote()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
// Subscribe only on remote, no local subscriber
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("only.remote");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("only.remote");
|
|
|
|
await publisher.PublishAsync("only.remote", "no-local");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("no-local");
|
|
}
|
|
|
|
// Go: TestGatewayDoesntSendBackToItself server/gateway_test.go:2150
|
|
[Fact]
|
|
public async Task Both_local_and_remote_subscribers_receive_message_published_locally()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var remoteConn = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await remoteConn.ConnectAsync();
|
|
|
|
await using var localConn = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await localConn.ConnectAsync();
|
|
|
|
await using var remoteSub = await remoteConn.SubscribeCoreAsync<string>("both.test");
|
|
await remoteConn.PingAsync();
|
|
|
|
await using var localSub = await localConn.SubscribeCoreAsync<string>("both.test");
|
|
await localConn.PingAsync();
|
|
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("both.test");
|
|
|
|
await localConn.PublishAsync("both.test", "shared");
|
|
await localConn.PingAsync();
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var localMsg = await localSub.Msgs.ReadAsync(timeout.Token);
|
|
localMsg.Data.ShouldBe("shared");
|
|
|
|
var remoteMsg = await remoteSub.Msgs.ReadAsync(timeout.Token);
|
|
remoteMsg.Data.ShouldBe("shared");
|
|
}
|
|
|
|
// ── Wildcard Subject Forwarding ─────────────────────────────────────
|
|
|
|
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
|
|
[Fact]
|
|
public async Task Wildcard_subscription_receives_matching_gateway_messages()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("wc.>");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("wc.test.one");
|
|
|
|
await publisher.PublishAsync("wc.test.one", "wildcard-msg");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Subject.ShouldBe("wc.test.one");
|
|
msg.Data.ShouldBe("wildcard-msg");
|
|
}
|
|
|
|
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
|
|
[Fact]
|
|
public async Task Partial_wildcard_subscription_receives_gateway_messages()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("orders.*");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("orders.created");
|
|
|
|
await publisher.PublishAsync("orders.created", "order-1");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Subject.ShouldBe("orders.created");
|
|
msg.Data.ShouldBe("order-1");
|
|
}
|
|
|
|
// ── Reply Subject Mapping (_GR_. Prefix) ────────────────────────────
|
|
|
|
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
|
|
[Fact]
|
|
public void Reply_mapper_adds_gr_prefix_with_cluster_id()
|
|
{
|
|
var mapped = ReplyMapper.ToGatewayReply("_INBOX.abc", "CLUSTER-A");
|
|
mapped.ShouldNotBeNull();
|
|
mapped.ShouldStartWith("_GR_.");
|
|
mapped.ShouldContain("CLUSTER-A");
|
|
}
|
|
|
|
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
|
|
[Fact]
|
|
public void Reply_mapper_restores_original_reply()
|
|
{
|
|
var original = "_INBOX.abc123";
|
|
var mapped = ReplyMapper.ToGatewayReply(original, "C1");
|
|
mapped.ShouldNotBeNull();
|
|
|
|
ReplyMapper.TryRestoreGatewayReply(mapped, out var restored).ShouldBeTrue();
|
|
restored.ShouldBe(original);
|
|
}
|
|
|
|
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
|
|
[Fact]
|
|
public void Reply_mapper_handles_nested_gr_prefixes()
|
|
{
|
|
var original = "_INBOX.reply1";
|
|
var once = ReplyMapper.ToGatewayReply(original, "CLUSTER-A");
|
|
var twice = ReplyMapper.ToGatewayReply(once, "CLUSTER-B");
|
|
|
|
ReplyMapper.TryRestoreGatewayReply(twice!, out var restored).ShouldBeTrue();
|
|
restored.ShouldBe(original);
|
|
}
|
|
|
|
// Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586
|
|
[Fact]
|
|
public void Reply_mapper_returns_null_for_null_input()
|
|
{
|
|
var result = ReplyMapper.ToGatewayReply(null, "CLUSTER");
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
// Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586
|
|
[Fact]
|
|
public void Reply_mapper_returns_empty_for_empty_input()
|
|
{
|
|
var result = ReplyMapper.ToGatewayReply("", "CLUSTER");
|
|
result.ShouldBe("");
|
|
}
|
|
|
|
// Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586
|
|
[Fact]
|
|
public void Has_gateway_reply_prefix_detects_gr_prefix()
|
|
{
|
|
ReplyMapper.HasGatewayReplyPrefix("_GR_.CLUSTER.inbox").ShouldBeTrue();
|
|
ReplyMapper.HasGatewayReplyPrefix("_INBOX.abc").ShouldBeFalse();
|
|
ReplyMapper.HasGatewayReplyPrefix(null).ShouldBeFalse();
|
|
ReplyMapper.HasGatewayReplyPrefix("").ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165
|
|
[Fact]
|
|
public void Restore_returns_false_for_non_gr_subject()
|
|
{
|
|
ReplyMapper.TryRestoreGatewayReply("_INBOX.abc", out _).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestGatewayReplyMapTracking server/gateway_test.go:6017
|
|
[Fact]
|
|
public void Restore_returns_false_for_malformed_gr_subject()
|
|
{
|
|
// _GR_. with no cluster separator
|
|
ReplyMapper.TryRestoreGatewayReply("_GR_.nodot", out _).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestGatewayReplyMapTracking server/gateway_test.go:6017
|
|
[Fact]
|
|
public void Restore_returns_false_for_gr_prefix_with_nothing_after_separator()
|
|
{
|
|
ReplyMapper.TryRestoreGatewayReply("_GR_.CLUSTER.", out _).ShouldBeFalse();
|
|
}
|
|
|
|
// ── Queue Subscription Forwarding ───────────────────────────────────
|
|
|
|
// Go: TestGatewayQueueSub server/gateway_test.go:2265
|
|
[Fact]
|
|
public async Task Queue_subscription_interest_tracked_on_remote()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G"));
|
|
|
|
subList.HasRemoteInterest("$G", "foo").ShouldBeTrue();
|
|
subList.MatchRemote("$G", "foo").Count.ShouldBe(1);
|
|
}
|
|
|
|
// Go: TestGatewayQueueSub server/gateway_test.go:2265
|
|
[Fact]
|
|
public async Task Queue_subscription_with_multiple_groups_all_tracked()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G"));
|
|
subList.ApplyRemoteSub(new RemoteSubscription("foo", "baz", "gw1", "$G"));
|
|
|
|
subList.MatchRemote("$G", "foo").Count.ShouldBe(2);
|
|
}
|
|
|
|
// Go: TestGatewayQueueSub server/gateway_test.go:2265
|
|
[Fact]
|
|
public async Task Queue_sub_removal_clears_remote_interest()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G"));
|
|
subList.HasRemoteInterest("$G", "foo").ShouldBeTrue();
|
|
|
|
subList.ApplyRemoteSub(RemoteSubscription.Removal("foo", "bar", "gw1", "$G"));
|
|
subList.HasRemoteInterest("$G", "foo").ShouldBeFalse();
|
|
}
|
|
|
|
// ── GatewayManager Forwarding ───────────────────────────────────────
|
|
|
|
// Go: TestGatewayBasic server/gateway_test.go:399
|
|
[Fact]
|
|
public async Task Gateway_manager_forward_message_increments_js_counter()
|
|
{
|
|
var manager = new GatewayManager(
|
|
new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 },
|
|
new ServerStats(),
|
|
"S1",
|
|
_ => { },
|
|
_ => { },
|
|
NullLogger<GatewayManager>.Instance);
|
|
|
|
await manager.ForwardJetStreamClusterMessageAsync(
|
|
new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()),
|
|
default);
|
|
|
|
manager.ForwardedJetStreamClusterMessages.ShouldBe(1);
|
|
}
|
|
|
|
// Go: TestGatewayBasic server/gateway_test.go:399
|
|
[Fact]
|
|
public async Task Gateway_manager_forward_js_message_multiple_times()
|
|
{
|
|
var manager = new GatewayManager(
|
|
new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 },
|
|
new ServerStats(),
|
|
"S1",
|
|
_ => { },
|
|
_ => { },
|
|
NullLogger<GatewayManager>.Instance);
|
|
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
await manager.ForwardJetStreamClusterMessageAsync(
|
|
new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()),
|
|
default);
|
|
}
|
|
|
|
manager.ForwardedJetStreamClusterMessages.ShouldBe(5);
|
|
}
|
|
|
|
// ── Multiple Messages ───────────────────────────────────────────────
|
|
|
|
// Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993
|
|
[Fact]
|
|
public async Task Multiple_messages_forwarded_across_gateway()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("multi.test");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("multi.test");
|
|
|
|
const int count = 10;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
await publisher.PublishAsync("multi.test", $"msg-{i}");
|
|
}
|
|
|
|
await publisher.PingAsync();
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var received = new List<string>();
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
received.Add(msg.Data!);
|
|
}
|
|
|
|
received.Count.ShouldBe(count);
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
received.ShouldContain($"msg-{i}");
|
|
}
|
|
}
|
|
|
|
// Go: TestGatewayAccountUnsub server/gateway_test.go:1912
|
|
// Verifies that a message published on local with a reply-to subject is forwarded
|
|
// to the remote with the reply-to intact, allowing manual request-reply across gateway.
|
|
[Fact]
|
|
public async Task Message_with_reply_to_forwarded_across_gateway()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var remoteConn = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await remoteConn.ConnectAsync();
|
|
|
|
// Subscribe on remote for requests
|
|
await using var sub = await remoteConn.SubscribeCoreAsync<string>("svc.request");
|
|
await remoteConn.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("svc.request");
|
|
|
|
// Publish from local with a reply-to subject via raw socket
|
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await sock.ConnectAsync(IPAddress.Loopback, fixture.Local.Port);
|
|
var infoBuf = new byte[4096];
|
|
_ = await sock.ReceiveAsync(infoBuf); // read INFO
|
|
await sock.SendAsync(Encoding.ASCII.GetBytes(
|
|
"CONNECT {}\r\nPUB svc.request _INBOX.reply123 12\r\nrequest-data\r\nPING\r\n"));
|
|
|
|
// Wait for PONG to confirm the message was processed
|
|
var pongBuf = new byte[4096];
|
|
var pongTotal = new StringBuilder();
|
|
using var pongCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!pongTotal.ToString().Contains("PONG"))
|
|
{
|
|
var n = await sock.ReceiveAsync(pongBuf, SocketFlags.None, pongCts.Token);
|
|
if (n == 0) break;
|
|
pongTotal.Append(Encoding.ASCII.GetString(pongBuf, 0, n));
|
|
}
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("request-data");
|
|
msg.ReplyTo.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
// ── Account Scoped Forwarding ───────────────────────────────────────
|
|
|
|
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
|
|
[Fact]
|
|
public async Task Messages_forwarded_within_same_account_only()
|
|
{
|
|
var users = new User[]
|
|
{
|
|
new() { Username = "user_a", Password = "pass", Account = "ACCT_A" },
|
|
new() { Username = "user_b", Password = "pass", Account = "ACCT_B" },
|
|
};
|
|
|
|
await using var fixture = await ForwardingFixture.StartWithUsersAsync(users);
|
|
|
|
await using var remoteA = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://user_a:pass@127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await remoteA.ConnectAsync();
|
|
|
|
await using var remoteB = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://user_b:pass@127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await remoteB.ConnectAsync();
|
|
|
|
await using var publisherA = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://user_a:pass@127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisherA.ConnectAsync();
|
|
|
|
await using var subA = await remoteA.SubscribeCoreAsync<string>("acct.test");
|
|
await using var subB = await remoteB.SubscribeCoreAsync<string>("acct.test");
|
|
await remoteA.PingAsync();
|
|
await remoteB.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("ACCT_A", "acct.test");
|
|
|
|
await publisherA.PublishAsync("acct.test", "for-account-a");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msgA = await subA.Msgs.ReadAsync(timeout.Token);
|
|
msgA.Data.ShouldBe("for-account-a");
|
|
|
|
// Account B should not receive
|
|
using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await subB.Msgs.ReadAsync(noMsgTimeout.Token));
|
|
}
|
|
|
|
// Go: TestGatewayAccountInterest server/gateway_test.go:1794
|
|
[Fact]
|
|
public async Task Non_matching_subject_not_forwarded_after_interest_established()
|
|
{
|
|
await using var fixture = await ForwardingFixture.StartAsync();
|
|
|
|
await using var subscriber = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Remote.Port}",
|
|
});
|
|
await subscriber.ConnectAsync();
|
|
|
|
await using var publisher = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{fixture.Local.Port}",
|
|
});
|
|
await publisher.ConnectAsync();
|
|
|
|
// Subscribe to a specific subject
|
|
await using var sub = await subscriber.SubscribeCoreAsync<string>("specific.topic");
|
|
await subscriber.PingAsync();
|
|
await fixture.WaitForRemoteInterestOnLocalAsync("specific.topic");
|
|
|
|
// Publish to a different subject
|
|
await publisher.PublishAsync("other.topic", "should-not-arrive");
|
|
await publisher.PingAsync();
|
|
|
|
await Task.Delay(300);
|
|
using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await sub.Msgs.ReadAsync(noMsgTimeout.Token));
|
|
}
|
|
|
|
// Go: TestGatewayNoCrashOnInvalidSubject server/gateway_test.go:6279
|
|
[Fact]
|
|
public void GatewayMessage_record_stores_all_fields()
|
|
{
|
|
var payload = new byte[] { 1, 2, 3 };
|
|
var msg = new GatewayMessage("test.subject", "_INBOX.reply", payload, "MYACCT");
|
|
|
|
msg.Subject.ShouldBe("test.subject");
|
|
msg.ReplyTo.ShouldBe("_INBOX.reply");
|
|
msg.Payload.Length.ShouldBe(3);
|
|
msg.Account.ShouldBe("MYACCT");
|
|
}
|
|
|
|
// Go: TestGatewayBasic server/gateway_test.go:399
|
|
[Fact]
|
|
public void GatewayMessage_defaults_account_to_global()
|
|
{
|
|
var msg = new GatewayMessage("test.subject", null, new byte[] { });
|
|
msg.Account.ShouldBe("$G");
|
|
}
|
|
|
|
// ── Interest-Only Mode and ShouldForwardInterestOnly ────────────────
|
|
|
|
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
|
|
[Fact]
|
|
public void Should_forward_interest_only_returns_true_when_interest_exists()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A"));
|
|
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "A", "orders.created").ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
|
|
[Fact]
|
|
public void Should_forward_interest_only_returns_false_without_interest()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A"));
|
|
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "A", "payments.created").ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934
|
|
[Fact]
|
|
public void Should_forward_interest_only_for_different_account_returns_false()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A"));
|
|
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "B", "orders.created").ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
|
|
[Fact]
|
|
public void Should_forward_with_wildcard_interest()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("test.*", null, "gw1", "$G"));
|
|
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.one").ShouldBeTrue();
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.two").ShouldBeTrue();
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.one").ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestGatewaySubjectInterest server/gateway_test.go:1972
|
|
[Fact]
|
|
public void Should_forward_with_fwc_interest()
|
|
{
|
|
using var subList = new SubList();
|
|
subList.ApplyRemoteSub(new RemoteSubscription("events.>", null, "gw1", "$G"));
|
|
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "events.a.b.c").ShouldBeTrue();
|
|
GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.x").ShouldBeFalse();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared fixture for forwarding tests that need two running server clusters.
|
|
/// </summary>
|
|
internal sealed class ForwardingFixture : IAsyncDisposable
|
|
{
|
|
private readonly CancellationTokenSource _localCts;
|
|
private readonly CancellationTokenSource _remoteCts;
|
|
|
|
private ForwardingFixture(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<ForwardingFixture> StartAsync()
|
|
=> StartWithUsersAsync(null);
|
|
|
|
public static async Task<ForwardingFixture> StartWithUsersAsync(IReadOnlyList<User>? 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 ForwardingFixture(local, remote, localCts, remoteCts);
|
|
}
|
|
|
|
public async Task WaitForRemoteInterestOnLocalAsync(string subject)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!timeout.IsCancellationRequested)
|
|
{
|
|
if (Local.HasRemoteInterest(subject))
|
|
return;
|
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'.");
|
|
}
|
|
|
|
public async Task WaitForRemoteInterestOnLocalAsync(string account, string subject)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!timeout.IsCancellationRequested)
|
|
{
|
|
if (Local.HasRemoteInterest(account, subject))
|
|
return;
|
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}.");
|
|
}
|
|
|
|
public async Task WaitForRemoteInterestOnRemoteAsync(string subject)
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
while (!timeout.IsCancellationRequested)
|
|
{
|
|
if (Remote.HasRemoteInterest(subject))
|
|
return;
|
|
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
|
}
|
|
|
|
throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'.");
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _localCts.CancelAsync();
|
|
await _remoteCts.CancelAsync();
|
|
Local.Dispose();
|
|
Remote.Dispose();
|
|
_localCts.Dispose();
|
|
_remoteCts.Dispose();
|
|
}
|
|
}
|