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:
@@ -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()
|
||||
|
||||
521
tests/NATS.Server.Tests/Accounts/AccountIsolationTests.cs
Normal file
521
tests/NATS.Server.Tests/Accounts/AccountIsolationTests.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
599
tests/NATS.Server.Tests/Accounts/AuthMechanismTests.cs
Normal file
599
tests/NATS.Server.Tests/Accounts/AuthMechanismTests.cs
Normal 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("/");
|
||||
}
|
||||
}
|
||||
442
tests/NATS.Server.Tests/Accounts/PermissionTests.cs
Normal file
442
tests/NATS.Server.Tests/Accounts/PermissionTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user