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
522 lines
18 KiB
C#
522 lines
18 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server;
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Imports;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Tests.Accounts;
|
|
|
|
/// <summary>
|
|
/// Tests for account creation, registration, isolation, and basic account lifecycle.
|
|
/// Reference: Go accounts_test.go — TestRegisterDuplicateAccounts, TestAccountIsolation,
|
|
/// TestAccountFromOptions, TestAccountSimpleConfig, TestAccountParseConfig,
|
|
/// TestMultiAccountsIsolation, TestNewAccountAndRequireNewAlwaysError, etc.
|
|
/// </summary>
|
|
public class AccountIsolationTests
|
|
{
|
|
private static int GetFreePort()
|
|
{
|
|
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
|
return ((IPEndPoint)sock.LocalEndPoint!).Port;
|
|
}
|
|
|
|
private static NatsServer CreateTestServer(NatsOptions? options = null)
|
|
{
|
|
var port = GetFreePort();
|
|
options ??= new NatsOptions();
|
|
options.Port = port;
|
|
return new NatsServer(options, NullLoggerFactory.Instance);
|
|
}
|
|
|
|
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
|
{
|
|
var port = GetFreePort();
|
|
options.Port = port;
|
|
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
|
var cts = new CancellationTokenSource();
|
|
_ = server.StartAsync(cts.Token);
|
|
await server.WaitForReadyAsync();
|
|
return (server, port, cts);
|
|
}
|
|
|
|
private static bool ExceptionChainContains(Exception ex, string substring)
|
|
{
|
|
Exception? current = ex;
|
|
while (current != null)
|
|
{
|
|
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
current = current.InnerException;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Go: TestRegisterDuplicateAccounts server/accounts_test.go:50
|
|
[Fact]
|
|
public void Register_duplicate_account_returns_existing()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var foo = server.GetOrCreateAccount("$foo");
|
|
var foo2 = server.GetOrCreateAccount("$foo");
|
|
|
|
// GetOrCreateAccount returns the same instance if already registered
|
|
foo.ShouldBeSameAs(foo2);
|
|
}
|
|
|
|
// Go: TestAccountIsolation server/accounts_test.go:57
|
|
[Fact]
|
|
public void Account_isolation_separate_sublists()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var fooAcc = server.GetOrCreateAccount("$foo");
|
|
var barAcc = server.GetOrCreateAccount("$bar");
|
|
|
|
// Accounts must have different SubLists
|
|
fooAcc.SubList.ShouldNotBeSameAs(barAcc.SubList);
|
|
fooAcc.Name.ShouldBe("$foo");
|
|
barAcc.Name.ShouldBe("$bar");
|
|
}
|
|
|
|
// Go: TestAccountIsolation server/accounts_test.go:57
|
|
[Fact]
|
|
public void Account_isolation_messages_do_not_cross()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var fooAcc = server.GetOrCreateAccount("$foo");
|
|
var barAcc = server.GetOrCreateAccount("$bar");
|
|
|
|
var receivedFoo = new List<string>();
|
|
var receivedBar = new List<string>();
|
|
|
|
var clientFoo = new TestNatsClient(1, fooAcc);
|
|
clientFoo.OnMessage = (subject, _, _, _, _) => receivedFoo.Add(subject);
|
|
var clientBar = new TestNatsClient(2, barAcc);
|
|
clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject);
|
|
|
|
// Subscribe to "foo" in both accounts
|
|
barAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientBar });
|
|
fooAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientFoo });
|
|
|
|
// Publish to foo account's SubList
|
|
var result = fooAcc.SubList.Match("foo");
|
|
foreach (var sub in result.PlainSubs)
|
|
sub.Client?.SendMessage("foo", sub.Sid, null, default, default);
|
|
|
|
// Only foo account subscriber should receive
|
|
receivedFoo.Count.ShouldBe(1);
|
|
receivedBar.Count.ShouldBe(0);
|
|
}
|
|
|
|
// Go: TestAccountFromOptions server/accounts_test.go:386
|
|
[Fact]
|
|
public void Account_from_options_creates_accounts()
|
|
{
|
|
using var server = CreateTestServer(new NatsOptions
|
|
{
|
|
Accounts = new Dictionary<string, AccountConfig>
|
|
{
|
|
["foo"] = new AccountConfig(),
|
|
["bar"] = new AccountConfig(),
|
|
},
|
|
});
|
|
|
|
var fooAcc = server.GetOrCreateAccount("foo");
|
|
var barAcc = server.GetOrCreateAccount("bar");
|
|
|
|
fooAcc.ShouldNotBeNull();
|
|
barAcc.ShouldNotBeNull();
|
|
fooAcc.SubList.ShouldNotBeNull();
|
|
barAcc.SubList.ShouldNotBeNull();
|
|
}
|
|
|
|
// Go: TestAccountFromOptions server/accounts_test.go:386
|
|
[Fact]
|
|
public void Account_from_options_applies_config()
|
|
{
|
|
using var server = CreateTestServer(new NatsOptions
|
|
{
|
|
Accounts = new Dictionary<string, AccountConfig>
|
|
{
|
|
["limited"] = new AccountConfig { MaxConnections = 5, MaxSubscriptions = 10 },
|
|
},
|
|
});
|
|
|
|
var acc = server.GetOrCreateAccount("limited");
|
|
acc.MaxConnections.ShouldBe(5);
|
|
acc.MaxSubscriptions.ShouldBe(10);
|
|
}
|
|
|
|
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
|
|
[Fact]
|
|
public async Task Multi_accounts_isolation_only_correct_importer_receives()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "public", Password = "public", Account = "PUBLIC" },
|
|
new User { Username = "client", Password = "client", Account = "CLIENT" },
|
|
new User { Username = "client2", Password = "client2", Account = "CLIENT2" },
|
|
],
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var publicNc = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://public:public@127.0.0.1:{port}",
|
|
});
|
|
await using var clientNc = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://client:client@127.0.0.1:{port}",
|
|
});
|
|
await using var client2Nc = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://client2:client2@127.0.0.1:{port}",
|
|
});
|
|
|
|
await publicNc.ConnectAsync();
|
|
await clientNc.ConnectAsync();
|
|
await client2Nc.ConnectAsync();
|
|
|
|
// Subscribe on both client accounts
|
|
await using var clientSub = await clientNc.SubscribeCoreAsync<string>("orders.>");
|
|
await using var client2Sub = await client2Nc.SubscribeCoreAsync<string>("orders.>");
|
|
await clientNc.PingAsync();
|
|
await client2Nc.PingAsync();
|
|
|
|
// Publish from the same account as client - CLIENT should get it
|
|
await clientNc.PublishAsync("orders.entry", "test1");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
|
var msg = await clientSub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("test1");
|
|
|
|
// CLIENT2 should NOT receive messages from CLIENT account
|
|
using var shortTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
try
|
|
{
|
|
await client2Sub.Msgs.ReadAsync(shortTimeout.Token);
|
|
throw new Exception("CLIENT2 should not have received a message from CLIENT account");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestAccountIsolation server/accounts_test.go:57 (integration variant)
|
|
[Fact]
|
|
public async Task Same_account_receives_messages_integration()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
|
new User { Username = "charlie", Password = "pass", Account = "acct-a" },
|
|
],
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var alice = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:pass@127.0.0.1:{port}",
|
|
});
|
|
await using var charlie = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://charlie:pass@127.0.0.1:{port}",
|
|
});
|
|
|
|
await alice.ConnectAsync();
|
|
await charlie.ConnectAsync();
|
|
|
|
await using var sub = await charlie.SubscribeCoreAsync<string>("test.subject");
|
|
await charlie.PingAsync();
|
|
|
|
await alice.PublishAsync("test.subject", "from-alice");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
|
msg.Data.ShouldBe("from-alice");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestAccountIsolation server/accounts_test.go:57 (integration variant)
|
|
[Fact]
|
|
public async Task Different_account_does_not_receive_messages_integration()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
|
new User { Username = "bob", Password = "pass", Account = "acct-b" },
|
|
],
|
|
});
|
|
|
|
try
|
|
{
|
|
await using var alice = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:pass@127.0.0.1:{port}",
|
|
});
|
|
await using var bob = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://bob:pass@127.0.0.1:{port}",
|
|
});
|
|
|
|
await alice.ConnectAsync();
|
|
await bob.ConnectAsync();
|
|
|
|
await using var sub = await bob.SubscribeCoreAsync<string>("test.subject");
|
|
await bob.PingAsync();
|
|
|
|
await alice.PublishAsync("test.subject", "from-alice");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
|
try
|
|
{
|
|
await sub.Msgs.ReadAsync(timeout.Token);
|
|
throw new Exception("Bob should not have received a message from a different account");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
|
|
[Fact]
|
|
public void Three_account_isolation_with_wildcard_subs()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var accountA = server.GetOrCreateAccount("acct-a");
|
|
var accountB = server.GetOrCreateAccount("acct-b");
|
|
var accountC = server.GetOrCreateAccount("acct-c");
|
|
|
|
accountA.SubList.ShouldNotBeSameAs(accountB.SubList);
|
|
accountB.SubList.ShouldNotBeSameAs(accountC.SubList);
|
|
|
|
var receivedA = new List<string>();
|
|
var receivedB = new List<string>();
|
|
var receivedC = new List<string>();
|
|
|
|
var clientA = new TestNatsClient(1, accountA);
|
|
clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
|
|
var clientB = new TestNatsClient(2, accountB);
|
|
clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);
|
|
var clientC = new TestNatsClient(3, accountC);
|
|
clientC.OnMessage = (subject, _, _, _, _) => receivedC.Add(subject);
|
|
|
|
accountA.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "a1", Client = clientA });
|
|
accountB.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "b1", Client = clientB });
|
|
accountC.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "c1", Client = clientC });
|
|
|
|
// Publish in Account A's subject space
|
|
var resultA = accountA.SubList.Match("orders.client.stream.entry");
|
|
foreach (var sub in resultA.PlainSubs)
|
|
sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null, default, default);
|
|
|
|
receivedA.Count.ShouldBe(1);
|
|
receivedB.Count.ShouldBe(0);
|
|
receivedC.Count.ShouldBe(0);
|
|
|
|
// Publish in Account B
|
|
var resultB = accountB.SubList.Match("orders.other.stream.entry");
|
|
foreach (var sub in resultB.PlainSubs)
|
|
sub.Client?.SendMessage("orders.other.stream.entry", sub.Sid, null, default, default);
|
|
|
|
receivedA.Count.ShouldBe(1); // unchanged
|
|
receivedB.Count.ShouldBe(1);
|
|
receivedC.Count.ShouldBe(0);
|
|
}
|
|
|
|
// Go: TestAccountGlobalDefault server/accounts_test.go:2254
|
|
[Fact]
|
|
public void Global_account_has_default_name()
|
|
{
|
|
Account.GlobalAccountName.ShouldBe("$G");
|
|
}
|
|
|
|
// Go: TestRegisterDuplicateAccounts server/accounts_test.go:50
|
|
[Fact]
|
|
public void GetOrCreateAccount_returns_same_instance()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var acc1 = server.GetOrCreateAccount("test-acc");
|
|
var acc2 = server.GetOrCreateAccount("test-acc");
|
|
|
|
acc1.ShouldBeSameAs(acc2);
|
|
}
|
|
|
|
// Go: TestAccountIsolation server/accounts_test.go:57 — verifies accounts are different objects
|
|
[Fact]
|
|
public void Accounts_are_distinct_objects()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
|
|
foo.ShouldNotBeSameAs(bar);
|
|
foo.Name.ShouldNotBe(bar.Name);
|
|
}
|
|
|
|
// Go: TestAccountMapsUsers server/accounts_test.go:2138
|
|
[Fact]
|
|
public async Task Users_mapped_to_correct_accounts()
|
|
{
|
|
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
|
{
|
|
Users =
|
|
[
|
|
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
|
new User { Username = "bob", Password = "pass", Account = "acct-b" },
|
|
],
|
|
});
|
|
|
|
try
|
|
{
|
|
// Both should connect successfully to their respective accounts
|
|
await using var alice = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://alice:pass@127.0.0.1:{port}",
|
|
});
|
|
await using var bob = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://bob:pass@127.0.0.1:{port}",
|
|
});
|
|
|
|
await alice.ConnectAsync();
|
|
await alice.PingAsync();
|
|
await bob.ConnectAsync();
|
|
await bob.PingAsync();
|
|
|
|
// Verify isolation: publish in A, subscribe in B should not receive
|
|
await using var bobSub = await bob.SubscribeCoreAsync<string>("mapped.test");
|
|
await bob.PingAsync();
|
|
|
|
await alice.PublishAsync("mapped.test", "hello");
|
|
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
try
|
|
{
|
|
await bobSub.Msgs.ReadAsync(timeout.Token);
|
|
throw new Exception("Bob should not receive messages from Alice's account");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected — accounts are isolated
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
|
|
// Go: TestAccountIsolation server/accounts_test.go:57 — wildcard subscriber in isolated account
|
|
[Fact]
|
|
public void Wildcard_subscriber_in_isolated_account_no_cross_delivery()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var fooAcc = server.GetOrCreateAccount("$foo");
|
|
var barAcc = server.GetOrCreateAccount("$bar");
|
|
|
|
var receivedBar = new List<string>();
|
|
var clientBar = new TestNatsClient(2, barAcc);
|
|
clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject);
|
|
|
|
// Bar subscribes to wildcard
|
|
barAcc.SubList.Insert(new Subscription { Subject = ">", Sid = "1", Client = clientBar });
|
|
|
|
// Publish in foo account
|
|
var result = fooAcc.SubList.Match("anything.goes.here");
|
|
result.PlainSubs.Length.ShouldBe(0); // No subscribers in foo
|
|
|
|
// Bar should still have no messages
|
|
receivedBar.Count.ShouldBe(0);
|
|
}
|
|
|
|
// Go: TestAccountIsolation server/accounts_test.go:57 — multiple subs same account
|
|
[Fact]
|
|
public void Multiple_subscribers_same_account_all_receive()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var acc = server.GetOrCreateAccount("test");
|
|
|
|
var received1 = new List<string>();
|
|
var received2 = new List<string>();
|
|
|
|
var client1 = new TestNatsClient(1, acc);
|
|
client1.OnMessage = (subject, _, _, _, _) => received1.Add(subject);
|
|
var client2 = new TestNatsClient(2, acc);
|
|
client2.OnMessage = (subject, _, _, _, _) => received2.Add(subject);
|
|
|
|
acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s1", Client = client1 });
|
|
acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s2", Client = client2 });
|
|
|
|
var result = acc.SubList.Match("events.order.created");
|
|
result.PlainSubs.Length.ShouldBe(2);
|
|
|
|
foreach (var sub in result.PlainSubs)
|
|
sub.Client?.SendMessage("events.order.created", sub.Sid, null, default, default);
|
|
|
|
received1.Count.ShouldBe(1);
|
|
received2.Count.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal test double for INatsClient used in isolation tests.
|
|
/// </summary>
|
|
private sealed class TestNatsClient(ulong id, Account account) : INatsClient
|
|
{
|
|
public ulong Id => id;
|
|
public ClientKind Kind => ClientKind.Client;
|
|
public Account? Account => account;
|
|
public Protocol.ClientOptions? ClientOpts => null;
|
|
public ClientPermissions? Permissions => null;
|
|
public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? OnMessage { get; set; }
|
|
|
|
public void SendMessage(string subject, string sid, string? replyTo,
|
|
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
|
{
|
|
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
|
|
}
|
|
|
|
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
|
|
public void RemoveSubscription(string sid) { }
|
|
}
|
|
}
|