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; using NATS.Server.TestUtilities; namespace NATS.Server.Auth.Tests.Accounts; /// /// Tests for account creation, registration, isolation, and basic account lifecycle. /// Reference: Go accounts_test.go — TestRegisterDuplicateAccounts, TestAccountIsolation, /// TestAccountFromOptions, TestAccountSimpleConfig, TestAccountParseConfig, /// TestMultiAccountsIsolation, TestNewAccountAndRequireNewAlwaysError, etc. /// public class AccountIsolationTests { private static NatsServer CreateTestServer(NatsOptions? options = null) { var port = TestPortAllocator.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 = TestPortAllocator.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(); var receivedBar = new List(); 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 { ["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 { ["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("orders.>"); await using var client2Sub = await client2Nc.SubscribeCoreAsync("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 — timeout confirms cross-account isolation prevented delivery return; } } 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("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("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 — timeout confirms different-account isolation blocks delivery return; } } 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(); var receivedB = new List(); var receivedC = new List(); 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("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 — timeout confirms subject-mapped accounts remain isolated return; } } 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(); 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(); var received2 = new List(); 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); } /// /// Minimal test double for INatsClient used in isolation tests. /// 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, ReadOnlyMemory>? OnMessage { get; set; } public void SendMessage(string subject, string sid, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload) { OnMessage?.Invoke(subject, sid, replyTo, headers, payload); } public void SendMessageNoFlush(string subject, string sid, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload) { OnMessage?.Invoke(subject, sid, replyTo, headers, payload); } public void SignalFlush() { } public bool QueueOutbound(ReadOnlyMemory data) => true; public void RemoveSubscription(string sid) { } } }