feat: Wave 6 batch 2 — accounts/auth, gateways, routes, JetStream API, JetStream cluster tests

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
This commit is contained in:
Joseph Doherty
2026-02-23 22:35:06 -05:00
parent 9554d53bf5
commit f1353868af
23 changed files with 13844 additions and 74 deletions

View File

@@ -7,23 +7,14 @@ using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Accounts;
/// <summary>
/// Tests for cross-account stream export/import delivery and account isolation semantics.
/// Reference: Go accounts_test.go TestAccountIsolationExportImport, TestMultiAccountsIsolation.
/// Tests for cross-account stream/service export/import delivery, authorization, and mapping.
/// Reference: Go accounts_test.go TestAccountIsolationExportImport, TestMultiAccountsIsolation,
/// TestImportAuthorized, TestSimpleMapping, TestAddServiceExport, TestAddStreamExport,
/// TestServiceExportWithWildcards, TestServiceImportWithWildcards, etc.
/// </summary>
public class AccountImportExportTests
{
/// <summary>
/// Verifies that stream export/import wiring allows messages published in the
/// exporter account to be delivered to subscribers in the importing account.
/// Mirrors Go TestAccountIsolationExportImport (conf variant) at the server API level.
///
/// Setup: Account A exports "events.>", Account B imports "events.>" from A.
/// When a message is published to "events.order" in Account A, a shadow subscription
/// in Account A (wired for the import) should forward to Account B subscribers.
/// Since stream import shadow subscription wiring is not yet integrated in ProcessMessage,
/// this test exercises the export/import API and ProcessServiceImport path to verify
/// cross-account delivery mechanics.
/// </summary>
// Go: TestAccountIsolationExportImport server/accounts_test.go:111
[Fact]
public void Stream_export_import_delivers_cross_account()
{
@@ -36,7 +27,7 @@ public class AccountImportExportTests
exporter.AddStreamExport("events.>", null);
exporter.Exports.Streams.ShouldContainKey("events.>");
// Account B imports "events.>" from Account A, mapped to "imported.events.>"
// Account B imports "events.>" from Account A
importer.AddStreamImport(exporter, "events.>", "imported.events.>");
importer.Imports.Streams.Count.ShouldBe(1);
importer.Imports.Streams[0].From.ShouldBe("events.>");
@@ -44,11 +35,9 @@ public class AccountImportExportTests
importer.Imports.Streams[0].SourceAccount.ShouldBe(exporter);
// Also set up a service export/import to verify cross-account message delivery
// through the ProcessServiceImport path (which IS wired in ProcessMessage).
exporter.AddServiceExport("svc.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "svc.>");
// Subscribe in the exporter account's SubList to receive forwarded messages
var received = new List<(string Subject, string Sid)>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, sid, _, _, _) =>
@@ -57,30 +46,16 @@ public class AccountImportExportTests
var exportSub = new Subscription { Subject = "svc.order", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
// Process a service import: simulates client in B publishing "requests.order"
// which should transform to "svc.order" and deliver to A's subscriber
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.order", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
// Verify the message crossed accounts
received.Count.ShouldBe(1);
received[0].Subject.ShouldBe("svc.order");
received[0].Sid.ShouldBe("s1");
}
/// <summary>
/// Verifies that account isolation prevents cross-account delivery when multiple
/// accounts use wildcard subscriptions and NO imports/exports are configured.
/// Extends the basic isolation test in AccountIsolationTests by testing with
/// three accounts and wildcard (">") subscriptions, matching the Go
/// TestMultiAccountsIsolation pattern where multiple importing accounts must
/// remain isolated from each other.
///
/// Setup: Three accounts (A, B, C), no exports/imports. Each account subscribes
/// to "orders.>" via its own SubList. Publishing in A should only match A's
/// subscribers; B and C should receive nothing.
/// </summary>
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
[Fact]
public void Account_isolation_prevents_cross_account_delivery()
{
@@ -90,11 +65,9 @@ public class AccountImportExportTests
var accountB = server.GetOrCreateAccount("acct-b");
var accountC = server.GetOrCreateAccount("acct-c");
// Each account has its own independent SubList
accountA.SubList.ShouldNotBeSameAs(accountB.SubList);
accountB.SubList.ShouldNotBeSameAs(accountC.SubList);
// Set up wildcard subscribers in all three accounts
var receivedA = new List<string>();
var receivedB = new List<string>();
var receivedC = new List<string>();
@@ -106,46 +79,303 @@ public class AccountImportExportTests
var clientC = new TestNatsClient(3, accountC);
clientC.OnMessage = (subject, _, _, _, _) => receivedC.Add(subject);
// Subscribe to wildcard "orders.>" in each account's SubList
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 — only A's SubList is matched
var resultA = accountA.SubList.Match("orders.client.stream.entry");
resultA.PlainSubs.Length.ShouldBe(1);
foreach (var sub in resultA.PlainSubs)
{
sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
}
sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null, default, default);
// Account A received the message
receivedA.Count.ShouldBe(1);
receivedA[0].ShouldBe("orders.client.stream.entry");
// Accounts B and C did NOT receive anything (isolation)
receivedB.Count.ShouldBe(0);
receivedC.Count.ShouldBe(0);
}
// Now publish in Account B's subject space
var resultB = accountB.SubList.Match("orders.other.stream.entry");
resultB.PlainSubs.Length.ShouldBe(1);
// Go: TestAddStreamExport server/accounts_test.go:1560
[Fact]
public void Add_stream_export_public()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foreach (var sub in resultB.PlainSubs)
foo.AddStreamExport("foo", null);
foo.Exports.Streams.ShouldContainKey("foo");
// Public export (no approved list) should be authorized for anyone
var bar = server.GetOrCreateAccount("bar");
foo.Exports.Streams["foo"].Auth.IsAuthorized(bar).ShouldBeTrue();
}
// Go: TestAddStreamExport server/accounts_test.go:1560
[Fact]
public void Add_stream_export_with_approved_accounts()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var baz = server.GetOrCreateAccount("baz");
foo.AddStreamExport("events.>", [bar]);
foo.Exports.Streams["events.>"].Auth.IsAuthorized(bar).ShouldBeTrue();
foo.Exports.Streams["events.>"].Auth.IsAuthorized(baz).ShouldBeFalse();
}
// Go: TestAddServiceExport server/accounts_test.go:1282
[Fact]
public void Add_service_export_singleton()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("help", ServiceResponseType.Singleton, null);
foo.Exports.Services.ShouldContainKey("help");
foo.Exports.Services["help"].ResponseType.ShouldBe(ServiceResponseType.Singleton);
}
// Go: TestAddServiceExport server/accounts_test.go:1282
[Fact]
public void Add_service_export_streamed()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("data.feed", ServiceResponseType.Streamed, null);
foo.Exports.Services["data.feed"].ResponseType.ShouldBe(ServiceResponseType.Streamed);
}
// Go: TestAddServiceExport server/accounts_test.go:1282
[Fact]
public void Add_service_export_chunked()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("photos", ServiceResponseType.Chunked, null);
foo.Exports.Services["photos"].ResponseType.ShouldBe(ServiceResponseType.Chunked);
}
// Go: TestServiceExportWithWildcards server/accounts_test.go:1319
[Fact]
public void Service_export_with_wildcard_subject()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
foo.AddServiceExport("svc.*", ServiceResponseType.Singleton, null);
foo.Exports.Services.ShouldContainKey("svc.*");
}
// Go: TestImportAuthorized server/accounts_test.go:761
[Fact]
public void Stream_import_requires_matching_export()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
// Without export, import should fail
Should.Throw<InvalidOperationException>(() =>
bar.AddStreamImport(foo, "foo", "import"));
}
// Go: TestImportAuthorized server/accounts_test.go:761
[Fact]
public void Stream_import_with_public_export_succeeds()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
foo.AddStreamExport("foo", null); // public
bar.AddStreamImport(foo, "foo", "import");
bar.Imports.Streams.Count.ShouldBe(1);
bar.Imports.Streams[0].From.ShouldBe("foo");
}
// Go: TestImportAuthorized server/accounts_test.go:761
[Fact]
public void Stream_import_from_restricted_export_unauthorized_account_fails()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var baz = server.GetOrCreateAccount("baz");
foo.AddStreamExport("data.>", [bar]); // only bar authorized
// bar can import
bar.AddStreamImport(foo, "data.>", "imported");
bar.Imports.Streams.Count.ShouldBe(1);
// baz cannot import
Should.Throw<UnauthorizedAccessException>(() =>
baz.AddStreamImport(foo, "data.>", "imported"));
}
// Go: TestServiceImportWithWildcards server/accounts_test.go:1463
[Fact]
public void Service_import_delivers_to_exporter_via_wildcard()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
exporter.SubList.Insert(new Subscription { Subject = "api.orders", Sid = "s1", Client = mockClient });
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.orders", null, default, default);
received.Count.ShouldBe(1);
received[0].ShouldBe("api.orders");
}
// Go: TestSimpleMapping server/accounts_test.go:845
[Fact]
public void Service_import_maps_literal_subjects()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
foo.AddServiceExport("help.request", ServiceResponseType.Singleton, null);
bar.AddServiceImport(foo, "local.help", "help.request");
var received = new List<string>();
var mockClient = new TestNatsClient(1, foo);
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
foo.SubList.Insert(new Subscription { Subject = "help.request", Sid = "s1", Client = mockClient });
var si = bar.Imports.Services["local.help"][0];
server.ProcessServiceImport(si, "local.help", null, default, default);
received.Count.ShouldBe(1);
received[0].ShouldBe("help.request");
}
// Go: TestAccountDuplicateServiceImportSubject server/accounts_test.go:2411
[Fact]
public void Duplicate_service_import_same_from_subject_adds_to_list()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var baz = server.GetOrCreateAccount("baz");
foo.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
baz.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
bar.AddServiceImport(foo, "requests.a", "svc.a");
bar.AddServiceImport(baz, "requests.a", "svc.a");
// Both imports should be stored under the same "from" key
bar.Imports.Services["requests.a"].Count.ShouldBe(2);
}
// Go: TestCrossAccountRequestReply server/accounts_test.go:1597
[Fact]
public void Service_import_preserves_reply_to()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.time", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "time.request", "api.time");
string? capturedReply = null;
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (_, _, replyTo, _, _) => capturedReply = replyTo;
exporter.SubList.Insert(new Subscription { Subject = "api.time", Sid = "s1", Client = mockClient });
var si = importer.Imports.Services["time.request"][0];
server.ProcessServiceImport(si, "time.request", "_INBOX.abc123", default, default);
capturedReply.ShouldBe("_INBOX.abc123");
}
// Go: TestAccountRemoveServiceImport server/accounts_test.go:2447
[Fact]
public void Service_import_invalid_flag_prevents_delivery()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
exporter.SubList.Insert(new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient });
// Mark the import as invalid
var si = importer.Imports.Services["requests.>"][0];
si.Invalid = true;
server.ProcessServiceImport(si, "requests.test", null, default, default);
received.Count.ShouldBe(0);
}
// Go: TestAccountCheckStreamImportsEqual server/accounts_test.go:2274
[Fact]
public void Stream_import_tracks_source_account()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
foo.AddStreamExport("data.>", null);
bar.AddStreamImport(foo, "data.>", "feed.>");
var si = bar.Imports.Streams[0];
si.SourceAccount.ShouldBeSameAs(foo);
si.From.ShouldBe("data.>");
si.To.ShouldBe("feed.>");
}
// Go: TestExportAuth — revoked accounts cannot import
[Fact]
public void Revoked_account_cannot_access_export()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var auth = new ExportAuth
{
sub.Client?.SendMessage("orders.other.stream.entry", sub.Sid, null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
}
RevokedAccounts = new Dictionary<string, long> { [bar.Name] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
};
// Account B received the message
receivedB.Count.ShouldBe(1);
receivedB[0].ShouldBe("orders.other.stream.entry");
auth.IsAuthorized(bar).ShouldBeFalse();
}
// Account A still has only its original message, Account C still empty
receivedA.Count.ShouldBe(1);
receivedC.Count.ShouldBe(0);
// Go: TestExportAuth — public export with no restrictions
[Fact]
public void Public_export_authorizes_any_account()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
var auth = new ExportAuth(); // No restrictions
auth.IsAuthorized(foo).ShouldBeTrue();
auth.IsAuthorized(bar).ShouldBeTrue();
}
private static NatsServer CreateTestServer()

View File

@@ -0,0 +1,521 @@
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) { }
}
}

View File

@@ -0,0 +1,599 @@
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.Protocol;
namespace NATS.Server.Tests.Accounts;
/// <summary>
/// Tests for authentication mechanisms: username/password, token, NKey-based auth,
/// no-auth-user fallback, multi-user, and AuthService orchestration.
/// Reference: Go auth_test.go — TestUserClone*, TestNoAuthUser, TestUserConnectionDeadline, etc.
/// Reference: Go accounts_test.go — TestAccountMapsUsers.
/// </summary>
public class AuthMechanismTests
{
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 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: TestUserCloneNilPermissions server/auth_test.go:34
[Fact]
public void User_with_nil_permissions()
{
var user = new User
{
Username = "foo",
Password = "bar",
};
user.Permissions.ShouldBeNull();
}
// Go: TestUserClone server/auth_test.go:53
[Fact]
public void User_with_permissions_has_correct_fields()
{
var user = new User
{
Username = "foo",
Password = "bar",
Permissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["foo"] },
Subscribe = new SubjectPermission { Allow = ["bar"] },
},
};
user.Username.ShouldBe("foo");
user.Password.ShouldBe("bar");
user.Permissions.ShouldNotBeNull();
user.Permissions.Publish!.Allow![0].ShouldBe("foo");
user.Permissions.Subscribe!.Allow![0].ShouldBe("bar");
}
// Go: TestUserClonePermissionsNoLists server/auth_test.go:80
[Fact]
public void User_with_empty_permissions()
{
var user = new User
{
Username = "foo",
Password = "bar",
Permissions = new Permissions(),
};
user.Permissions!.Publish.ShouldBeNull();
user.Permissions!.Subscribe.ShouldBeNull();
}
// Go: TestNoAuthUser (token auth success) server/auth_test.go:225
[Fact]
public async Task Token_auth_success()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Authorization = "s3cr3t",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://s3cr3t@127.0.0.1:{port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: auth mechanism — token auth failure
[Fact]
public async Task Token_auth_failure_disconnects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Authorization = "s3cr3t",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://wrongtoken@127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: auth mechanism — user/password success
[Fact]
public async Task UserPassword_auth_success()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Username = "admin",
Password = "secret",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:secret@127.0.0.1:{port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: auth mechanism — user/password failure
[Fact]
public async Task UserPassword_auth_failure_disconnects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Username = "admin",
Password = "secret",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://admin:wrong@127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestNoAuthUser server/auth_test.go:225 — multi-user auth
[Fact]
public async Task MultiUser_auth_each_user_succeeds()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "alice", Password = "pass1" },
new User { Username = "bob", Password = "pass2" },
],
});
try
{
await using var alice = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:pass1@127.0.0.1:{port}",
});
await using var bob = new NatsConnection(new NatsOpts
{
Url = $"nats://bob:pass2@127.0.0.1:{port}",
});
await alice.ConnectAsync();
await alice.PingAsync();
await bob.ConnectAsync();
await bob.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestNoAuthUser server/auth_test.go:225 — wrong user password
[Fact]
public async Task MultiUser_wrong_password_fails()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "alice", Password = "pass1" },
],
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:wrong@127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected 'Authorization Violation', but got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: auth mechanism — no credentials with auth required
[Fact]
public async Task No_credentials_when_auth_required_disconnects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Authorization = "s3cr3t",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected 'Authorization Violation', but got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: auth mechanism — no auth configured allows all
[Fact]
public async Task No_auth_configured_allows_all()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions());
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{port}",
});
await client.ConnectAsync();
await client.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestNoAuthUser server/auth_test.go:225 — no_auth_user fallback
[Fact]
public async Task NoAuthUser_fallback_allows_unauthenticated_connection()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "foo", Password = "pwd1", Account = "FOO" },
new User { Username = "bar", Password = "pwd2", Account = "BAR" },
],
NoAuthUser = "foo",
});
try
{
// Connect without credentials — should use no_auth_user "foo"
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://127.0.0.1:{port}",
});
await client.ConnectAsync();
await client.PingAsync();
// Explicit auth also still works
await using var bar = new NatsConnection(new NatsOpts
{
Url = $"nats://bar:pwd2@127.0.0.1:{port}",
});
await bar.ConnectAsync();
await bar.PingAsync();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestNoAuthUser server/auth_test.go:225 — invalid pwd with no_auth_user still fails
[Fact]
public async Task NoAuthUser_wrong_password_still_fails()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "foo", Password = "pwd1", Account = "FOO" },
new User { Username = "bar", Password = "pwd2", Account = "BAR" },
],
NoAuthUser = "foo",
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://bar:wrong@127.0.0.1:{port}",
MaxReconnectRetry = 0,
});
var ex = await Should.ThrowAsync<NatsException>(async () =>
{
await client.ConnectAsync();
await client.PingAsync();
});
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
$"Expected auth violation, got: {ex}");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: AuthService — tests the build logic for auth service
[Fact]
public void AuthService_build_with_no_auth_returns_not_required()
{
var authService = AuthService.Build(new NatsOptions());
authService.IsAuthRequired.ShouldBeFalse();
authService.NonceRequired.ShouldBeFalse();
}
// Go: AuthService — tests the build logic for token auth
[Fact]
public void AuthService_build_with_token_marks_auth_required()
{
var authService = AuthService.Build(new NatsOptions { Authorization = "secret" });
authService.IsAuthRequired.ShouldBeTrue();
authService.NonceRequired.ShouldBeFalse();
}
// Go: AuthService — tests the build logic for user/password auth
[Fact]
public void AuthService_build_with_user_password_marks_auth_required()
{
var authService = AuthService.Build(new NatsOptions
{
Username = "admin",
Password = "secret",
});
authService.IsAuthRequired.ShouldBeTrue();
authService.NonceRequired.ShouldBeFalse();
}
// Go: AuthService — tests the build logic for nkey auth
[Fact]
public void AuthService_build_with_nkeys_marks_nonce_required()
{
var authService = AuthService.Build(new NatsOptions
{
NKeys = [new NKeyUser { Nkey = "UABC123" }],
});
authService.IsAuthRequired.ShouldBeTrue();
authService.NonceRequired.ShouldBeTrue();
}
// Go: AuthService — tests the build logic for multi-user auth
[Fact]
public void AuthService_build_with_users_marks_auth_required()
{
var authService = AuthService.Build(new NatsOptions
{
Users = [new User { Username = "alice", Password = "pass" }],
});
authService.IsAuthRequired.ShouldBeTrue();
}
// Go: AuthService.Authenticate — token match
[Fact]
public void AuthService_authenticate_token_success()
{
var authService = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
var result = authService.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Token = "mytoken" },
Nonce = [],
});
result.ShouldNotBeNull();
result.Identity.ShouldBe("token");
}
// Go: AuthService.Authenticate — token mismatch
[Fact]
public void AuthService_authenticate_token_failure()
{
var authService = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
var result = authService.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Token = "wrong" },
Nonce = [],
});
result.ShouldBeNull();
}
// Go: AuthService.Authenticate — user/password match
[Fact]
public void AuthService_authenticate_user_password_success()
{
var authService = AuthService.Build(new NatsOptions
{
Users = [new User { Username = "alice", Password = "pass", Account = "acct-a" }],
});
var result = authService.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "pass" },
Nonce = [],
});
result.ShouldNotBeNull();
result.Identity.ShouldBe("alice");
result.AccountName.ShouldBe("acct-a");
}
// Go: AuthService.Authenticate — user/password mismatch
[Fact]
public void AuthService_authenticate_user_password_failure()
{
var authService = AuthService.Build(new NatsOptions
{
Users = [new User { Username = "alice", Password = "pass" }],
});
var result = authService.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions { Username = "alice", Password = "wrong" },
Nonce = [],
});
result.ShouldBeNull();
}
// Go: AuthService.Authenticate — no auth user fallback
[Fact]
public void AuthService_authenticate_no_auth_user_fallback()
{
var authService = AuthService.Build(new NatsOptions
{
Users =
[
new User { Username = "foo", Password = "pwd1", Account = "FOO" },
],
NoAuthUser = "foo",
});
// No credentials provided — should fall back to no_auth_user
var result = authService.Authenticate(new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = [],
});
result.ShouldNotBeNull();
result.Identity.ShouldBe("foo");
result.AccountName.ShouldBe("FOO");
}
// Go: AuthService.GenerateNonce — nonce generation
[Fact]
public void AuthService_generates_unique_nonces()
{
var authService = AuthService.Build(new NatsOptions
{
NKeys = [new NKeyUser { Nkey = "UABC" }],
});
var nonce1 = authService.GenerateNonce();
var nonce2 = authService.GenerateNonce();
nonce1.Length.ShouldBe(11);
nonce2.Length.ShouldBe(11);
// Extremely unlikely to be the same
nonce1.ShouldNotBe(nonce2);
}
// Go: AuthService.EncodeNonce — nonce encoding
[Fact]
public void AuthService_nonce_encoding_is_url_safe_base64()
{
var authService = AuthService.Build(new NatsOptions());
var nonce = new byte[] { 0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA, 0xF9, 0xF8, 0xF7, 0xF6, 0xF5 };
var encoded = authService.EncodeNonce(nonce);
// Should not contain standard base64 padding or non-URL-safe characters
encoded.ShouldNotContain("=");
encoded.ShouldNotContain("+");
encoded.ShouldNotContain("/");
}
}

View File

@@ -0,0 +1,442 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;
namespace NATS.Server.Tests.Accounts;
/// <summary>
/// Tests for publish/subscribe permission enforcement, account-level limits,
/// and per-user permission isolation.
/// Reference: Go auth_test.go — TestUserClone* (permission structure tests)
/// Reference: Go accounts_test.go — account limits (max connections, max subscriptions).
/// </summary>
public class PermissionTests
{
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 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: Permissions — publish allow list
[Fact]
public void Publish_allow_list_only()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>", "bar.*"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("foo.bar.baz").ShouldBeTrue();
perms.IsPublishAllowed("bar.one").ShouldBeTrue();
perms.IsPublishAllowed("baz.one").ShouldBeFalse();
}
// Go: Permissions — publish deny list
[Fact]
public void Publish_deny_list_only()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Deny = ["secret.>"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
perms.IsPublishAllowed("secret.data").ShouldBeFalse();
perms.IsPublishAllowed("secret.nested.deep").ShouldBeFalse();
}
// Go: Permissions — publish allow + deny combined
[Fact]
public void Publish_allow_and_deny_combined()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission
{
Allow = ["events.>"],
Deny = ["events.internal.>"],
},
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("events.public.data").ShouldBeTrue();
perms.IsPublishAllowed("events.internal.secret").ShouldBeFalse();
}
// Go: Permissions — subscribe allow list
[Fact]
public void Subscribe_allow_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Allow = ["data.>"] },
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
}
// Go: Permissions — subscribe deny list
[Fact]
public void Subscribe_deny_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Deny = ["admin.>"] },
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.updates").ShouldBeTrue();
perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse();
}
// Go: Permissions — null permissions allow everything
[Fact]
public void Null_permissions_allows_everything()
{
var perms = ClientPermissions.Build(null);
perms.ShouldBeNull();
}
// Go: Permissions — empty permissions allows everything
[Fact]
public void Empty_permissions_allows_everything()
{
var perms = ClientPermissions.Build(new Permissions());
perms.ShouldBeNull();
}
// Go: Permissions — subscribe allow + deny combined
[Fact]
public void Subscribe_allow_and_deny_combined()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission
{
Allow = ["data.>"],
Deny = ["data.secret.>"],
},
});
perms.ShouldNotBeNull();
perms.IsSubscribeAllowed("data.public").ShouldBeTrue();
perms.IsSubscribeAllowed("data.secret.key").ShouldBeFalse();
}
// Go: Permissions — separate publish and subscribe permissions
[Fact]
public void Separate_publish_and_subscribe_permissions()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["pub.>"] },
Subscribe = new SubjectPermission { Allow = ["sub.>"] },
});
perms.ShouldNotBeNull();
perms.IsPublishAllowed("pub.data").ShouldBeTrue();
perms.IsPublishAllowed("sub.data").ShouldBeFalse();
perms.IsSubscribeAllowed("sub.data").ShouldBeTrue();
perms.IsSubscribeAllowed("pub.data").ShouldBeFalse();
}
// Go: Account limits — max connections
[Fact]
public void Account_enforces_max_connections()
{
var acc = new Account("test") { MaxConnections = 2 };
acc.AddClient(1).ShouldBeTrue();
acc.AddClient(2).ShouldBeTrue();
acc.AddClient(3).ShouldBeFalse(); // exceeds limit
acc.ClientCount.ShouldBe(2);
}
// Go: Account limits — unlimited connections
[Fact]
public void Account_unlimited_connections_when_zero()
{
var acc = new Account("test") { MaxConnections = 0 };
for (ulong i = 1; i <= 100; i++)
acc.AddClient(i).ShouldBeTrue();
acc.ClientCount.ShouldBe(100);
}
// Go: Account limits — max subscriptions
[Fact]
public void Account_enforces_max_subscriptions()
{
var acc = new Account("test") { MaxSubscriptions = 2 };
acc.IncrementSubscriptions().ShouldBeTrue();
acc.IncrementSubscriptions().ShouldBeTrue();
acc.IncrementSubscriptions().ShouldBeFalse();
}
// Go: Account limits — subscription decrement frees slot
[Fact]
public void Account_decrement_subscriptions_frees_slot()
{
var acc = new Account("test") { MaxSubscriptions = 1 };
acc.IncrementSubscriptions().ShouldBeTrue();
acc.DecrementSubscriptions();
acc.IncrementSubscriptions().ShouldBeTrue(); // slot freed
}
// Go: Account limits — max connections via integration
[Fact]
public void Account_remove_client_frees_slot()
{
var acc = new Account("test") { MaxConnections = 1 };
acc.AddClient(1).ShouldBeTrue();
acc.AddClient(2).ShouldBeFalse(); // full
acc.RemoveClient(1);
acc.AddClient(2).ShouldBeTrue(); // slot freed
}
// Go: Account limits — default permissions on account
[Fact]
public void Account_default_permissions()
{
var acc = new Account("test")
{
DefaultPermissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["pub.>"] },
},
};
acc.DefaultPermissions.ShouldNotBeNull();
acc.DefaultPermissions.Publish!.Allow![0].ShouldBe("pub.>");
}
// Go: Account stats tracking
[Fact]
public void Account_tracks_message_stats()
{
var acc = new Account("stats-test");
acc.InMsgs.ShouldBe(0L);
acc.OutMsgs.ShouldBe(0L);
acc.InBytes.ShouldBe(0L);
acc.OutBytes.ShouldBe(0L);
acc.IncrementInbound(5, 1024);
acc.IncrementOutbound(3, 512);
acc.InMsgs.ShouldBe(5L);
acc.InBytes.ShouldBe(1024L);
acc.OutMsgs.ShouldBe(3L);
acc.OutBytes.ShouldBe(512L);
}
// Go: Account — user with publish permission can publish
[Fact]
public async Task User_with_publish_permission_can_publish_and_subscribe()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User
{
Username = "limited",
Password = "pass",
Permissions = new Permissions
{
Publish = new SubjectPermission { Allow = ["allowed.>"] },
Subscribe = new SubjectPermission { Allow = [">"] },
},
},
],
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://limited:pass@127.0.0.1:{port}",
});
await client.ConnectAsync();
// Subscribe to allowed subjects
await using var sub = await client.SubscribeCoreAsync<string>("allowed.test");
await client.PingAsync();
// Publish to allowed subject
await client.PublishAsync("allowed.test", "hello");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("hello");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: Account — user with publish deny
[Fact]
public async Task User_with_publish_deny_blocks_denied_subjects()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User
{
Username = "limited",
Password = "pass",
Permissions = new Permissions
{
Publish = new SubjectPermission
{
Allow = [">"],
Deny = ["secret.>"],
},
Subscribe = new SubjectPermission { Allow = [">"] },
},
},
],
});
try
{
await using var client = new NatsConnection(new NatsOpts
{
Url = $"nats://limited:pass@127.0.0.1:{port}",
});
await client.ConnectAsync();
// Subscribe to catch anything
await using var sub = await client.SubscribeCoreAsync<string>("secret.data");
await client.PingAsync();
// Publish to denied subject — server should silently drop
await client.PublishAsync("secret.data", "shouldnt-arrive");
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
try
{
await sub.Msgs.ReadAsync(timeout.Token);
throw new Exception("Should not have received message on denied subject");
}
catch (OperationCanceledException)
{
// Expected — message was blocked by permissions
}
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: Account — user revocation
[Fact]
public void Account_user_revocation()
{
var acc = new Account("test");
acc.IsUserRevoked("user1", 100).ShouldBeFalse();
acc.RevokeUser("user1", 200);
acc.IsUserRevoked("user1", 100).ShouldBeTrue(); // issued before revocation
acc.IsUserRevoked("user1", 200).ShouldBeTrue(); // issued at revocation time
acc.IsUserRevoked("user1", 300).ShouldBeFalse(); // issued after revocation
}
// Go: Account — wildcard user revocation
[Fact]
public void Account_wildcard_user_revocation()
{
var acc = new Account("test");
acc.RevokeUser("*", 500);
acc.IsUserRevoked("anyuser", 400).ShouldBeTrue();
acc.IsUserRevoked("anyuser", 600).ShouldBeFalse();
}
// Go: Account — JetStream stream reservation
[Fact]
public void Account_jetstream_stream_reservation()
{
var acc = new Account("test") { MaxJetStreamStreams = 2 };
acc.TryReserveStream().ShouldBeTrue();
acc.TryReserveStream().ShouldBeTrue();
acc.TryReserveStream().ShouldBeFalse(); // limit reached
acc.JetStreamStreamCount.ShouldBe(2);
acc.ReleaseStream();
acc.JetStreamStreamCount.ShouldBe(1);
acc.TryReserveStream().ShouldBeTrue(); // slot freed
}
// Go: Account limits — permissions cache behavior
[Fact]
public void Permission_cache_returns_consistent_results()
{
var perms = ClientPermissions.Build(new Permissions
{
Publish = new SubjectPermission { Allow = ["foo.>"] },
});
perms.ShouldNotBeNull();
// First call populates cache
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
// Second call uses cache — should return same result
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
// Different subject also cached
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
}
// Go: Permissions — delivery allowed check
[Fact]
public void Delivery_allowed_respects_deny_list()
{
var perms = ClientPermissions.Build(new Permissions
{
Subscribe = new SubjectPermission { Deny = ["blocked.>"] },
});
perms.ShouldNotBeNull();
perms.IsDeliveryAllowed("normal.subject").ShouldBeTrue();
perms.IsDeliveryAllowed("blocked.secret").ShouldBeFalse();
}
}