Files
natsdotnet/tests/NATS.Server.Auth.Tests/Accounts/AccountIsolationTests.cs
Joseph Doherty 0be321fa53 perf: batch flush signaling and fetch path optimizations (Round 6)
Implement Go's pcd (per-client deferred flush) pattern to reduce write-loop
wakeups during fan-out delivery, optimize ack reply string construction with
stack-based formatting, cache CompiledFilter on ConsumerHandle, and pool
fetch message lists. Durable consumer fetch improves from 0.60x to 0.74x Go.
2026-03-13 09:35:57 -04:00

528 lines
19 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;
using NATS.Server.TestUtilities;
namespace NATS.Server.Auth.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 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<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 — 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<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 — 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<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 — 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<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 void SendMessageNoFlush(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
}
public void SignalFlush() { }
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
public void RemoveSubscription(string sid) { }
}
}