Add comprehensive Go-parity test coverage across 5 subsystems: - Accounts/Auth: isolation, import/export, auth mechanisms, permissions (82 tests) - Gateways: connection, forwarding, interest mode, config (106 tests) - Routes: connection, subscription, forwarding, config validation (78 tests) - JetStream API: stream/consumer CRUD, pub/sub, features, admin (234 tests) - JetStream Cluster: streams, consumers, failover, meta (108 tests) Total: ~608 new test annotations across 22 files (+13,844 lines) All tests pass individually; suite total: 2,283 passing, 3 skipped
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.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();
|
|
}
|
|
}
|