refactor: extract NATS.Server.Auth.Tests project
Move 50 auth/accounts/permissions/JWT/NKey test files from NATS.Server.Tests into a dedicated NATS.Server.Auth.Tests project. Update namespaces, replace private GetFreePort/ReadUntilAsync helpers with TestUtilities calls, replace Task.Delay with TaskCompletionSource in test doubles, and add InternalsVisibleTo. 690 tests pass.
This commit is contained in:
101
tests/NATS.Server.Auth.Tests/AccountIsolationTests.cs
Normal file
101
tests/NATS.Server.Auth.Tests/AccountIsolationTests.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountIsolationTests : IAsyncLifetime
|
||||
{
|
||||
private NatsServer _server = null!;
|
||||
private int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task _serverTask = null!;
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Port = _port,
|
||||
Users =
|
||||
[
|
||||
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
||||
new User { Username = "bob", Password = "pass", Account = "acct-b" },
|
||||
new User { Username = "charlie", Password = "pass", Account = "acct-a" },
|
||||
],
|
||||
}, NullLoggerFactory.Instance);
|
||||
|
||||
_serverTask = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_account_receives_messages()
|
||||
{
|
||||
// Alice and Charlie are in acct-a
|
||||
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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Different_account_does_not_receive_messages()
|
||||
{
|
||||
// Alice is in acct-a, Bob is in acct-b
|
||||
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");
|
||||
|
||||
// Bob should NOT receive this — wait briefly then verify nothing arrived
|
||||
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 — no message received means accounts are properly isolated
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
tests/NATS.Server.Auth.Tests/AccountResolverTests.cs
Normal file
68
tests/NATS.Server.Auth.Tests/AccountResolverTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Store_and_fetch_roundtrip()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
|
||||
const string jwt = "eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig";
|
||||
|
||||
await resolver.StoreAsync(nkey, jwt);
|
||||
var fetched = await resolver.FetchAsync(nkey);
|
||||
|
||||
fetched.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_unknown_key_returns_null()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
|
||||
var result = await resolver.FetchAsync("UNKNOWN_NKEY");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_overwrites_existing_entry()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
|
||||
const string originalJwt = "original.jwt.token";
|
||||
const string updatedJwt = "updated.jwt.token";
|
||||
|
||||
await resolver.StoreAsync(nkey, originalJwt);
|
||||
await resolver.StoreAsync(nkey, updatedJwt);
|
||||
var fetched = await resolver.FetchAsync(nkey);
|
||||
|
||||
fetched.ShouldBe(updatedJwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadOnly_returns_false()
|
||||
{
|
||||
IAccountResolver resolver = new MemAccountResolver();
|
||||
|
||||
resolver.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_accounts_are_stored_independently()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey1 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ1";
|
||||
const string nkey2 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ2";
|
||||
const string jwt1 = "jwt.for.account.one";
|
||||
const string jwt2 = "jwt.for.account.two";
|
||||
|
||||
await resolver.StoreAsync(nkey1, jwt1);
|
||||
await resolver.StoreAsync(nkey2, jwt2);
|
||||
|
||||
(await resolver.FetchAsync(nkey1)).ShouldBe(jwt1);
|
||||
(await resolver.FetchAsync(nkey2)).ShouldBe(jwt2);
|
||||
}
|
||||
}
|
||||
48
tests/NATS.Server.Auth.Tests/AccountStatsTests.cs
Normal file
48
tests/NATS.Server.Auth.Tests/AccountStatsTests.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountStatsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Account_tracks_inbound_stats()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.IncrementInbound(1, 100);
|
||||
account.IncrementInbound(1, 200);
|
||||
account.InMsgs.ShouldBe(2);
|
||||
account.InBytes.ShouldBe(300);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_tracks_outbound_stats()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.IncrementOutbound(1, 50);
|
||||
account.IncrementOutbound(1, 75);
|
||||
account.OutMsgs.ShouldBe(2);
|
||||
account.OutBytes.ShouldBe(125);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_stats_start_at_zero()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.InMsgs.ShouldBe(0);
|
||||
account.OutMsgs.ShouldBe(0);
|
||||
account.InBytes.ShouldBe(0);
|
||||
account.OutBytes.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_stats_are_independent()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.IncrementInbound(5, 500);
|
||||
account.IncrementOutbound(3, 300);
|
||||
account.InMsgs.ShouldBe(5);
|
||||
account.OutMsgs.ShouldBe(3);
|
||||
account.InBytes.ShouldBe(500);
|
||||
account.OutBytes.ShouldBe(300);
|
||||
}
|
||||
}
|
||||
71
tests/NATS.Server.Auth.Tests/AccountTests.cs
Normal file
71
tests/NATS.Server.Auth.Tests/AccountTests.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AccountTests
|
||||
{
|
||||
[Fact]
|
||||
public void Account_has_name_and_own_sublist()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
|
||||
account.Name.ShouldBe("test-account");
|
||||
account.SubList.ShouldNotBeNull();
|
||||
account.SubList.Count.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_tracks_clients()
|
||||
{
|
||||
var account = new Account("test");
|
||||
|
||||
account.ClientCount.ShouldBe(0);
|
||||
account.AddClient(1);
|
||||
account.ClientCount.ShouldBe(1);
|
||||
account.RemoveClient(1);
|
||||
account.ClientCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalAccount_has_default_name()
|
||||
{
|
||||
Account.GlobalAccountName.ShouldBe("$G");
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_unlimited_connections_when_zero()
|
||||
{
|
||||
var acc = new Account("test") { MaxConnections = 0 };
|
||||
acc.AddClient(1).ShouldBeTrue();
|
||||
acc.AddClient(2).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_enforces_max_subscriptions()
|
||||
{
|
||||
var acc = new Account("test") { MaxSubscriptions = 2 };
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_decrement_subscriptions()
|
||||
{
|
||||
var acc = new Account("test") { MaxSubscriptions = 1 };
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.DecrementSubscriptions();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue(); // slot freed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for 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
|
||||
{
|
||||
// Go: TestAccountIsolationExportImport server/accounts_test.go:111
|
||||
[Fact]
|
||||
public void Stream_export_import_delivers_cross_account()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var exporter = server.GetOrCreateAccount("acct-a");
|
||||
var importer = server.GetOrCreateAccount("acct-b");
|
||||
|
||||
// Account A exports "events.>"
|
||||
exporter.AddStreamExport("events.>", null);
|
||||
exporter.Exports.Streams.ShouldContainKey("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.>");
|
||||
importer.Imports.Streams[0].To.ShouldBe("imported.events.>");
|
||||
importer.Imports.Streams[0].SourceAccount.ShouldBe(exporter);
|
||||
|
||||
// Also set up a service export/import to verify cross-account message delivery
|
||||
exporter.AddServiceExport("svc.>", ServiceResponseType.Singleton, null);
|
||||
importer.AddServiceImport(exporter, "requests.>", "svc.>");
|
||||
|
||||
var received = new List<(string Subject, string Sid)>();
|
||||
var mockClient = new TestNatsClient(1, exporter);
|
||||
mockClient.OnMessage = (subject, sid, _, _, _) =>
|
||||
received.Add((subject, sid));
|
||||
|
||||
var exportSub = new Subscription { Subject = "svc.order", Sid = "s1", Client = mockClient };
|
||||
exporter.SubList.Insert(exportSub);
|
||||
|
||||
var si = importer.Imports.Services["requests.>"][0];
|
||||
server.ProcessServiceImport(si, "requests.order", null,
|
||||
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
received.Count.ShouldBe(1);
|
||||
received[0].Subject.ShouldBe("svc.order");
|
||||
received[0].Sid.ShouldBe("s1");
|
||||
}
|
||||
|
||||
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
|
||||
[Fact]
|
||||
public void Account_isolation_prevents_cross_account_delivery()
|
||||
{
|
||||
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 });
|
||||
|
||||
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, default, default);
|
||||
|
||||
receivedA.Count.ShouldBe(1);
|
||||
receivedB.Count.ShouldBe(0);
|
||||
receivedC.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestAddStreamExport server/accounts_test.go:1560
|
||||
[Fact]
|
||||
public void Add_stream_export_public()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var foo = server.GetOrCreateAccount("foo");
|
||||
|
||||
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
|
||||
{
|
||||
RevokedAccounts = new Dictionary<string, long> { [bar.Name] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
|
||||
};
|
||||
|
||||
auth.IsAuthorized(bar).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
}
|
||||
/// <summary>
|
||||
/// Minimal test double for INatsClient used in import/export 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) { }
|
||||
}
|
||||
}
|
||||
519
tests/NATS.Server.Auth.Tests/Accounts/AccountIsolationTests.cs
Normal file
519
tests/NATS.Server.Auth.Tests/Accounts/AccountIsolationTests.cs
Normal file
@@ -0,0 +1,519 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for account creation, registration, isolation, and basic account lifecycle.
|
||||
/// Reference: Go accounts_test.go — TestRegisterDuplicateAccounts, TestAccountIsolation,
|
||||
/// TestAccountFromOptions, TestAccountSimpleConfig, TestAccountParseConfig,
|
||||
/// TestMultiAccountsIsolation, TestNewAccountAndRequireNewAlwaysError, etc.
|
||||
/// </summary>
|
||||
public class AccountIsolationTests
|
||||
{
|
||||
private static NatsServer CreateTestServer(NatsOptions? options = null)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options ??= new NatsOptions();
|
||||
options.Port = port;
|
||||
return new NatsServer(options, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static bool ExceptionChainContains(Exception ex, string substring)
|
||||
{
|
||||
Exception? current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Go: TestRegisterDuplicateAccounts server/accounts_test.go:50
|
||||
[Fact]
|
||||
public void Register_duplicate_account_returns_existing()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var foo = server.GetOrCreateAccount("$foo");
|
||||
var foo2 = server.GetOrCreateAccount("$foo");
|
||||
|
||||
// GetOrCreateAccount returns the same instance if already registered
|
||||
foo.ShouldBeSameAs(foo2);
|
||||
}
|
||||
|
||||
// Go: TestAccountIsolation server/accounts_test.go:57
|
||||
[Fact]
|
||||
public void Account_isolation_separate_sublists()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var fooAcc = server.GetOrCreateAccount("$foo");
|
||||
var barAcc = server.GetOrCreateAccount("$bar");
|
||||
|
||||
// Accounts must have different SubLists
|
||||
fooAcc.SubList.ShouldNotBeSameAs(barAcc.SubList);
|
||||
fooAcc.Name.ShouldBe("$foo");
|
||||
barAcc.Name.ShouldBe("$bar");
|
||||
}
|
||||
|
||||
// Go: TestAccountIsolation server/accounts_test.go:57
|
||||
[Fact]
|
||||
public void Account_isolation_messages_do_not_cross()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var fooAcc = server.GetOrCreateAccount("$foo");
|
||||
var barAcc = server.GetOrCreateAccount("$bar");
|
||||
|
||||
var receivedFoo = new List<string>();
|
||||
var receivedBar = new List<string>();
|
||||
|
||||
var clientFoo = new TestNatsClient(1, fooAcc);
|
||||
clientFoo.OnMessage = (subject, _, _, _, _) => receivedFoo.Add(subject);
|
||||
var clientBar = new TestNatsClient(2, barAcc);
|
||||
clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject);
|
||||
|
||||
// Subscribe to "foo" in both accounts
|
||||
barAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientBar });
|
||||
fooAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientFoo });
|
||||
|
||||
// Publish to foo account's SubList
|
||||
var result = fooAcc.SubList.Match("foo");
|
||||
foreach (var sub in result.PlainSubs)
|
||||
sub.Client?.SendMessage("foo", sub.Sid, null, default, default);
|
||||
|
||||
// Only foo account subscriber should receive
|
||||
receivedFoo.Count.ShouldBe(1);
|
||||
receivedBar.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestAccountFromOptions server/accounts_test.go:386
|
||||
[Fact]
|
||||
public void Account_from_options_creates_accounts()
|
||||
{
|
||||
using var server = CreateTestServer(new NatsOptions
|
||||
{
|
||||
Accounts = new Dictionary<string, AccountConfig>
|
||||
{
|
||||
["foo"] = new AccountConfig(),
|
||||
["bar"] = new AccountConfig(),
|
||||
},
|
||||
});
|
||||
|
||||
var fooAcc = server.GetOrCreateAccount("foo");
|
||||
var barAcc = server.GetOrCreateAccount("bar");
|
||||
|
||||
fooAcc.ShouldNotBeNull();
|
||||
barAcc.ShouldNotBeNull();
|
||||
fooAcc.SubList.ShouldNotBeNull();
|
||||
barAcc.SubList.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// Go: TestAccountFromOptions server/accounts_test.go:386
|
||||
[Fact]
|
||||
public void Account_from_options_applies_config()
|
||||
{
|
||||
using var server = CreateTestServer(new NatsOptions
|
||||
{
|
||||
Accounts = new Dictionary<string, AccountConfig>
|
||||
{
|
||||
["limited"] = new AccountConfig { MaxConnections = 5, MaxSubscriptions = 10 },
|
||||
},
|
||||
});
|
||||
|
||||
var acc = server.GetOrCreateAccount("limited");
|
||||
acc.MaxConnections.ShouldBe(5);
|
||||
acc.MaxSubscriptions.ShouldBe(10);
|
||||
}
|
||||
|
||||
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
|
||||
[Fact]
|
||||
public async Task Multi_accounts_isolation_only_correct_importer_receives()
|
||||
{
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User { Username = "public", Password = "public", Account = "PUBLIC" },
|
||||
new User { Username = "client", Password = "client", Account = "CLIENT" },
|
||||
new User { Username = "client2", Password = "client2", Account = "CLIENT2" },
|
||||
],
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await using var publicNc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://public:public@127.0.0.1:{port}",
|
||||
});
|
||||
await using var clientNc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://client:client@127.0.0.1:{port}",
|
||||
});
|
||||
await using var client2Nc = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://client2:client2@127.0.0.1:{port}",
|
||||
});
|
||||
|
||||
await publicNc.ConnectAsync();
|
||||
await clientNc.ConnectAsync();
|
||||
await client2Nc.ConnectAsync();
|
||||
|
||||
// Subscribe on both client accounts
|
||||
await using var clientSub = await clientNc.SubscribeCoreAsync<string>("orders.>");
|
||||
await using var client2Sub = await client2Nc.SubscribeCoreAsync<string>("orders.>");
|
||||
await clientNc.PingAsync();
|
||||
await client2Nc.PingAsync();
|
||||
|
||||
// Publish from the same account as client - CLIENT should get it
|
||||
await clientNc.PublishAsync("orders.entry", "test1");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
var msg = await clientSub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("test1");
|
||||
|
||||
// CLIENT2 should NOT receive messages from CLIENT account
|
||||
using var shortTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
try
|
||||
{
|
||||
await client2Sub.Msgs.ReadAsync(shortTimeout.Token);
|
||||
throw new Exception("CLIENT2 should not have received a message from CLIENT account");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — timeout confirms cross-account isolation prevented delivery
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestAccountIsolation server/accounts_test.go:57 (integration variant)
|
||||
[Fact]
|
||||
public async Task Same_account_receives_messages_integration()
|
||||
{
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
||||
new User { Username = "charlie", Password = "pass", Account = "acct-a" },
|
||||
],
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await using var alice = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://alice:pass@127.0.0.1:{port}",
|
||||
});
|
||||
await using var charlie = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://charlie:pass@127.0.0.1:{port}",
|
||||
});
|
||||
|
||||
await alice.ConnectAsync();
|
||||
await charlie.ConnectAsync();
|
||||
|
||||
await using var sub = await charlie.SubscribeCoreAsync<string>("test.subject");
|
||||
await charlie.PingAsync();
|
||||
|
||||
await alice.PublishAsync("test.subject", "from-alice");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("from-alice");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestAccountIsolation server/accounts_test.go:57 (integration variant)
|
||||
[Fact]
|
||||
public async Task Different_account_does_not_receive_messages_integration()
|
||||
{
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
||||
new User { Username = "bob", Password = "pass", Account = "acct-b" },
|
||||
],
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await using var alice = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://alice:pass@127.0.0.1:{port}",
|
||||
});
|
||||
await using var bob = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://bob:pass@127.0.0.1:{port}",
|
||||
});
|
||||
|
||||
await alice.ConnectAsync();
|
||||
await bob.ConnectAsync();
|
||||
|
||||
await using var sub = await bob.SubscribeCoreAsync<string>("test.subject");
|
||||
await bob.PingAsync();
|
||||
|
||||
await alice.PublishAsync("test.subject", "from-alice");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
try
|
||||
{
|
||||
await sub.Msgs.ReadAsync(timeout.Token);
|
||||
throw new Exception("Bob should not have received a message from a different account");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — timeout confirms different-account isolation blocks delivery
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
|
||||
[Fact]
|
||||
public void Three_account_isolation_with_wildcard_subs()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var accountA = server.GetOrCreateAccount("acct-a");
|
||||
var accountB = server.GetOrCreateAccount("acct-b");
|
||||
var accountC = server.GetOrCreateAccount("acct-c");
|
||||
|
||||
accountA.SubList.ShouldNotBeSameAs(accountB.SubList);
|
||||
accountB.SubList.ShouldNotBeSameAs(accountC.SubList);
|
||||
|
||||
var receivedA = new List<string>();
|
||||
var receivedB = new List<string>();
|
||||
var receivedC = new List<string>();
|
||||
|
||||
var clientA = new TestNatsClient(1, accountA);
|
||||
clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
|
||||
var clientB = new TestNatsClient(2, accountB);
|
||||
clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);
|
||||
var clientC = new TestNatsClient(3, accountC);
|
||||
clientC.OnMessage = (subject, _, _, _, _) => receivedC.Add(subject);
|
||||
|
||||
accountA.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "a1", Client = clientA });
|
||||
accountB.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "b1", Client = clientB });
|
||||
accountC.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "c1", Client = clientC });
|
||||
|
||||
// Publish in Account A's subject space
|
||||
var resultA = accountA.SubList.Match("orders.client.stream.entry");
|
||||
foreach (var sub in resultA.PlainSubs)
|
||||
sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null, default, default);
|
||||
|
||||
receivedA.Count.ShouldBe(1);
|
||||
receivedB.Count.ShouldBe(0);
|
||||
receivedC.Count.ShouldBe(0);
|
||||
|
||||
// Publish in Account B
|
||||
var resultB = accountB.SubList.Match("orders.other.stream.entry");
|
||||
foreach (var sub in resultB.PlainSubs)
|
||||
sub.Client?.SendMessage("orders.other.stream.entry", sub.Sid, null, default, default);
|
||||
|
||||
receivedA.Count.ShouldBe(1); // unchanged
|
||||
receivedB.Count.ShouldBe(1);
|
||||
receivedC.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestAccountGlobalDefault server/accounts_test.go:2254
|
||||
[Fact]
|
||||
public void Global_account_has_default_name()
|
||||
{
|
||||
Account.GlobalAccountName.ShouldBe("$G");
|
||||
}
|
||||
|
||||
// Go: TestRegisterDuplicateAccounts server/accounts_test.go:50
|
||||
[Fact]
|
||||
public void GetOrCreateAccount_returns_same_instance()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var acc1 = server.GetOrCreateAccount("test-acc");
|
||||
var acc2 = server.GetOrCreateAccount("test-acc");
|
||||
|
||||
acc1.ShouldBeSameAs(acc2);
|
||||
}
|
||||
|
||||
// Go: TestAccountIsolation server/accounts_test.go:57 — verifies accounts are different objects
|
||||
[Fact]
|
||||
public void Accounts_are_distinct_objects()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var foo = server.GetOrCreateAccount("foo");
|
||||
var bar = server.GetOrCreateAccount("bar");
|
||||
|
||||
foo.ShouldNotBeSameAs(bar);
|
||||
foo.Name.ShouldNotBe(bar.Name);
|
||||
}
|
||||
|
||||
// Go: TestAccountMapsUsers server/accounts_test.go:2138
|
||||
[Fact]
|
||||
public async Task Users_mapped_to_correct_accounts()
|
||||
{
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User { Username = "alice", Password = "pass", Account = "acct-a" },
|
||||
new User { Username = "bob", Password = "pass", Account = "acct-b" },
|
||||
],
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Both should connect successfully to their respective accounts
|
||||
await using var alice = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://alice:pass@127.0.0.1:{port}",
|
||||
});
|
||||
await using var bob = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://bob:pass@127.0.0.1:{port}",
|
||||
});
|
||||
|
||||
await alice.ConnectAsync();
|
||||
await alice.PingAsync();
|
||||
await bob.ConnectAsync();
|
||||
await bob.PingAsync();
|
||||
|
||||
// Verify isolation: publish in A, subscribe in B should not receive
|
||||
await using var bobSub = await bob.SubscribeCoreAsync<string>("mapped.test");
|
||||
await bob.PingAsync();
|
||||
|
||||
await alice.PublishAsync("mapped.test", "hello");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
||||
try
|
||||
{
|
||||
await bobSub.Msgs.ReadAsync(timeout.Token);
|
||||
throw new Exception("Bob should not receive messages from Alice's account");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected — timeout confirms subject-mapped accounts remain isolated
|
||||
return;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestAccountIsolation server/accounts_test.go:57 — wildcard subscriber in isolated account
|
||||
[Fact]
|
||||
public void Wildcard_subscriber_in_isolated_account_no_cross_delivery()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var fooAcc = server.GetOrCreateAccount("$foo");
|
||||
var barAcc = server.GetOrCreateAccount("$bar");
|
||||
|
||||
var receivedBar = new List<string>();
|
||||
var clientBar = new TestNatsClient(2, barAcc);
|
||||
clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject);
|
||||
|
||||
// Bar subscribes to wildcard
|
||||
barAcc.SubList.Insert(new Subscription { Subject = ">", Sid = "1", Client = clientBar });
|
||||
|
||||
// Publish in foo account
|
||||
var result = fooAcc.SubList.Match("anything.goes.here");
|
||||
result.PlainSubs.Length.ShouldBe(0); // No subscribers in foo
|
||||
|
||||
// Bar should still have no messages
|
||||
receivedBar.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go: TestAccountIsolation server/accounts_test.go:57 — multiple subs same account
|
||||
[Fact]
|
||||
public void Multiple_subscribers_same_account_all_receive()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var acc = server.GetOrCreateAccount("test");
|
||||
|
||||
var received1 = new List<string>();
|
||||
var received2 = new List<string>();
|
||||
|
||||
var client1 = new TestNatsClient(1, acc);
|
||||
client1.OnMessage = (subject, _, _, _, _) => received1.Add(subject);
|
||||
var client2 = new TestNatsClient(2, acc);
|
||||
client2.OnMessage = (subject, _, _, _, _) => received2.Add(subject);
|
||||
|
||||
acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s1", Client = client1 });
|
||||
acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s2", Client = client2 });
|
||||
|
||||
var result = acc.SubList.Match("events.order.created");
|
||||
result.PlainSubs.Length.ShouldBe(2);
|
||||
|
||||
foreach (var sub in result.PlainSubs)
|
||||
sub.Client?.SendMessage("events.order.created", sub.Sid, null, default, default);
|
||||
|
||||
received1.Count.ShouldBe(1);
|
||||
received2.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal test double for INatsClient used in isolation tests.
|
||||
/// </summary>
|
||||
private sealed class TestNatsClient(ulong id, Account account) : INatsClient
|
||||
{
|
||||
public ulong Id => id;
|
||||
public ClientKind Kind => ClientKind.Client;
|
||||
public Account? Account => account;
|
||||
public Protocol.ClientOptions? ClientOpts => null;
|
||||
public ClientPermissions? Permissions => null;
|
||||
public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? OnMessage { get; set; }
|
||||
|
||||
public void SendMessage(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
|
||||
}
|
||||
|
||||
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
|
||||
public void RemoveSubscription(string sid) { }
|
||||
}
|
||||
}
|
||||
820
tests/NATS.Server.Auth.Tests/Accounts/AuthCalloutTests.cs
Normal file
820
tests/NATS.Server.Auth.Tests/Accounts/AuthCalloutTests.cs
Normal file
@@ -0,0 +1,820 @@
|
||||
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.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Accounts;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for auth callout behavior, account limits (max connections / max subscriptions),
|
||||
/// user revocation, and cross-account communication scenarios.
|
||||
/// Reference: Go auth_callout_test.go — TestAuthCallout*, TestAuthCalloutTimeout, etc.
|
||||
/// Reference: Go accounts_test.go — TestAccountMaxConns, TestAccountMaxSubs,
|
||||
/// TestUserRevoked*, TestCrossAccountRequestReply.
|
||||
/// </summary>
|
||||
public class AuthCalloutTests
|
||||
{
|
||||
private static NatsServer CreateTestServer(NatsOptions? options = null)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options ??= new NatsOptions();
|
||||
options.Port = port;
|
||||
return new NatsServer(options, NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static bool ExceptionChainContains(Exception ex, string substring)
|
||||
{
|
||||
Exception? current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Auth callout handler registration ────────────────────────────────────
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout registered in options
|
||||
[Fact]
|
||||
public void AuthCallout_handler_registered_in_options()
|
||||
{
|
||||
var client = new StubExternalAuthClient(allow: true, identity: "callout-user");
|
||||
var options = new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Client = client,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
},
|
||||
};
|
||||
|
||||
var authService = AuthService.Build(options);
|
||||
authService.IsAuthRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout invoked with valid credentials
|
||||
[Fact]
|
||||
public void AuthCallout_valid_credentials_returns_auth_result()
|
||||
{
|
||||
var client = new StubExternalAuthClient(allow: true, identity: "callout-user", account: "acct-a");
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
|
||||
});
|
||||
|
||||
var result = authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "user", Password = "pass" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Identity.ShouldBe("callout-user");
|
||||
result.AccountName.ShouldBe("acct-a");
|
||||
}
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout with invalid credentials fails
|
||||
[Fact]
|
||||
public void AuthCallout_invalid_credentials_returns_null()
|
||||
{
|
||||
var client = new StubExternalAuthClient(allow: false);
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
|
||||
});
|
||||
|
||||
var result = authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "bad-user", Password = "bad-pass" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestAuthCalloutTimeout auth_callout_test.go — callout timeout returns null
|
||||
[Fact]
|
||||
public void AuthCallout_timeout_returns_null()
|
||||
{
|
||||
var client = new DelayedExternalAuthClient(delay: TimeSpan.FromSeconds(5));
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Client = client,
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
});
|
||||
|
||||
var result = authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "user", Password = "pass" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout response assigns account
|
||||
[Fact]
|
||||
public void AuthCallout_response_assigns_account_name()
|
||||
{
|
||||
var client = new StubExternalAuthClient(allow: true, identity: "alice", account: "tenant-1");
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
|
||||
});
|
||||
|
||||
var result = authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "x" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.AccountName.ShouldBe("tenant-1");
|
||||
}
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout with no account in response
|
||||
[Fact]
|
||||
public void AuthCallout_no_account_in_response_returns_null_account_name()
|
||||
{
|
||||
var client = new StubExternalAuthClient(allow: true, identity: "anonymous-user", account: null);
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
|
||||
});
|
||||
|
||||
var result = authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "anon", Password = "x" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.AccountName.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout invoked (receives request data)
|
||||
[Fact]
|
||||
public void AuthCallout_receives_username_and_password()
|
||||
{
|
||||
var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u");
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) },
|
||||
});
|
||||
|
||||
authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "myuser", Password = "mypass" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
captureClient.LastRequest.ShouldNotBeNull();
|
||||
captureClient.LastRequest!.Username.ShouldBe("myuser");
|
||||
captureClient.LastRequest.Password.ShouldBe("mypass");
|
||||
}
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout invoked with token
|
||||
[Fact]
|
||||
public void AuthCallout_receives_token()
|
||||
{
|
||||
var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u");
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) },
|
||||
});
|
||||
|
||||
authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Token = "my-bearer-token" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
captureClient.LastRequest.ShouldNotBeNull();
|
||||
captureClient.LastRequest!.Token.ShouldBe("my-bearer-token");
|
||||
}
|
||||
|
||||
// Go: TestAuthCallout auth_callout_test.go — callout invoked for each connection
|
||||
[Fact]
|
||||
public void AuthCallout_invoked_for_each_authentication_attempt()
|
||||
{
|
||||
var client = new CountingExternalAuthClient(allow: true, identity: "u");
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) },
|
||||
});
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = $"user{i}", Password = "p" },
|
||||
Nonce = [],
|
||||
});
|
||||
}
|
||||
|
||||
client.CallCount.ShouldBe(5);
|
||||
}
|
||||
|
||||
// ── Account limits: max connections ──────────────────────────────────────
|
||||
|
||||
// Go: TestAccountMaxConns accounts_test.go — max connections limit enforced
|
||||
[Fact]
|
||||
public void Account_max_connections_enforced()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("limited");
|
||||
acc.MaxConnections = 2;
|
||||
|
||||
acc.AddClient(1).ShouldBeTrue();
|
||||
acc.AddClient(2).ShouldBeTrue();
|
||||
acc.AddClient(3).ShouldBeFalse(); // limit reached
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxConns accounts_test.go — zero max connections means unlimited
|
||||
[Fact]
|
||||
public void Account_zero_max_connections_means_unlimited()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("unlimited");
|
||||
acc.MaxConnections = 0; // unlimited
|
||||
|
||||
for (ulong i = 1; i <= 100; i++)
|
||||
acc.AddClient(i).ShouldBeTrue();
|
||||
|
||||
acc.ClientCount.ShouldBe(100);
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxConns accounts_test.go — connection count tracked
|
||||
[Fact]
|
||||
public void Account_connection_count_tracking()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("tracked");
|
||||
|
||||
acc.AddClient(1);
|
||||
acc.AddClient(2);
|
||||
acc.AddClient(3);
|
||||
|
||||
acc.ClientCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxConns accounts_test.go — limits reset after disconnect
|
||||
[Fact]
|
||||
public void Account_connection_limit_resets_after_disconnect()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("resetable");
|
||||
acc.MaxConnections = 2;
|
||||
|
||||
acc.AddClient(1).ShouldBeTrue();
|
||||
acc.AddClient(2).ShouldBeTrue();
|
||||
acc.AddClient(3).ShouldBeFalse(); // full
|
||||
|
||||
acc.RemoveClient(1); // disconnect one
|
||||
|
||||
acc.AddClient(3).ShouldBeTrue(); // now room for another
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxConns accounts_test.go — different accounts have independent limits
|
||||
[Fact]
|
||||
public void Account_limits_are_per_account_independent()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var accA = server.GetOrCreateAccount("acct-a");
|
||||
var accB = server.GetOrCreateAccount("acct-b");
|
||||
|
||||
accA.MaxConnections = 2;
|
||||
accB.MaxConnections = 5;
|
||||
|
||||
accA.AddClient(1).ShouldBeTrue();
|
||||
accA.AddClient(2).ShouldBeTrue();
|
||||
accA.AddClient(3).ShouldBeFalse(); // A is full
|
||||
|
||||
// B is independent — should still allow
|
||||
accB.AddClient(10).ShouldBeTrue();
|
||||
accB.AddClient(11).ShouldBeTrue();
|
||||
accB.AddClient(12).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxConns accounts_test.go — config-driven max connections
|
||||
[Fact]
|
||||
public void Account_from_config_applies_max_connections()
|
||||
{
|
||||
using var server = CreateTestServer(new NatsOptions
|
||||
{
|
||||
Accounts = new Dictionary<string, AccountConfig>
|
||||
{
|
||||
["limited"] = new AccountConfig { MaxConnections = 3 },
|
||||
},
|
||||
});
|
||||
|
||||
var acc = server.GetOrCreateAccount("limited");
|
||||
acc.MaxConnections.ShouldBe(3);
|
||||
|
||||
acc.AddClient(1).ShouldBeTrue();
|
||||
acc.AddClient(2).ShouldBeTrue();
|
||||
acc.AddClient(3).ShouldBeTrue();
|
||||
acc.AddClient(4).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── Account limits: max subscriptions ────────────────────────────────────
|
||||
|
||||
// Go: TestAccountMaxSubs accounts_test.go — max subscriptions enforced
|
||||
[Fact]
|
||||
public void Account_max_subscriptions_enforced()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("sub-limited");
|
||||
acc.MaxSubscriptions = 2;
|
||||
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeFalse(); // limit reached
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxSubs accounts_test.go — zero max subscriptions means unlimited
|
||||
[Fact]
|
||||
public void Account_zero_max_subscriptions_means_unlimited()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("unlimited-subs");
|
||||
acc.MaxSubscriptions = 0;
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
|
||||
acc.SubscriptionCount.ShouldBe(100);
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxSubs accounts_test.go — subscription count tracked
|
||||
[Fact]
|
||||
public void Account_subscription_count_tracking()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("sub-tracked");
|
||||
|
||||
acc.IncrementSubscriptions();
|
||||
acc.IncrementSubscriptions();
|
||||
acc.IncrementSubscriptions();
|
||||
|
||||
acc.SubscriptionCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxSubs accounts_test.go — decrement frees capacity
|
||||
[Fact]
|
||||
public void Account_subscription_decrement_frees_capacity()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("sub-freeable");
|
||||
acc.MaxSubscriptions = 2;
|
||||
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeFalse(); // full
|
||||
|
||||
acc.DecrementSubscriptions(); // free one
|
||||
|
||||
acc.IncrementSubscriptions().ShouldBeTrue(); // now fits
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxSubs accounts_test.go — config-driven max subscriptions
|
||||
[Fact]
|
||||
public void Account_from_config_applies_max_subscriptions()
|
||||
{
|
||||
using var server = CreateTestServer(new NatsOptions
|
||||
{
|
||||
Accounts = new Dictionary<string, AccountConfig>
|
||||
{
|
||||
["sub-limited"] = new AccountConfig { MaxSubscriptions = 5 },
|
||||
},
|
||||
});
|
||||
|
||||
var acc = server.GetOrCreateAccount("sub-limited");
|
||||
acc.MaxSubscriptions.ShouldBe(5);
|
||||
}
|
||||
|
||||
// Go: TestAccountMaxSubs accounts_test.go — different accounts have independent subscription limits
|
||||
[Fact]
|
||||
public void Account_subscription_limits_are_independent()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var accA = server.GetOrCreateAccount("sub-a");
|
||||
var accB = server.GetOrCreateAccount("sub-b");
|
||||
|
||||
accA.MaxSubscriptions = 1;
|
||||
accB.MaxSubscriptions = 3;
|
||||
|
||||
accA.IncrementSubscriptions().ShouldBeTrue();
|
||||
accA.IncrementSubscriptions().ShouldBeFalse(); // A full
|
||||
|
||||
accB.IncrementSubscriptions().ShouldBeTrue();
|
||||
accB.IncrementSubscriptions().ShouldBeTrue();
|
||||
accB.IncrementSubscriptions().ShouldBeTrue(); // B has capacity
|
||||
}
|
||||
|
||||
// ── User revocation ───────────────────────────────────────────────────────
|
||||
|
||||
// Go: TestUserRevoked accounts_test.go — revoked user rejected
|
||||
[Fact]
|
||||
public void Revoked_user_is_rejected()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("revocation-test");
|
||||
|
||||
acc.RevokeUser("UNKEY123", issuedAt: 1000);
|
||||
|
||||
acc.IsUserRevoked("UNKEY123", issuedAt: 999).ShouldBeTrue();
|
||||
acc.IsUserRevoked("UNKEY123", issuedAt: 1000).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestUserRevoked accounts_test.go — not-yet-revoked user is allowed
|
||||
[Fact]
|
||||
public void User_issued_after_revocation_time_is_allowed()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("revocation-test");
|
||||
|
||||
acc.RevokeUser("UNKEY456", issuedAt: 1000);
|
||||
|
||||
// Issued after the revocation timestamp — should be allowed
|
||||
acc.IsUserRevoked("UNKEY456", issuedAt: 1001).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestUserRevoked accounts_test.go — non-existent user is not revoked
|
||||
[Fact]
|
||||
public void Non_revoked_user_is_allowed()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("revocation-test");
|
||||
|
||||
acc.IsUserRevoked("UNKEY999", issuedAt: 500).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestUserRevoked accounts_test.go — wildcard revocation affects all users
|
||||
[Fact]
|
||||
public void Wildcard_revocation_rejects_any_user()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("revocation-test");
|
||||
|
||||
// Revoke ALL users issued at or before timestamp 2000
|
||||
acc.RevokeUser("*", issuedAt: 2000);
|
||||
|
||||
acc.IsUserRevoked("UNKEY_A", issuedAt: 1000).ShouldBeTrue();
|
||||
acc.IsUserRevoked("UNKEY_B", issuedAt: 2000).ShouldBeTrue();
|
||||
acc.IsUserRevoked("UNKEY_C", issuedAt: 2001).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestUserRevoked accounts_test.go — revocation of non-existent user is no-op
|
||||
[Fact]
|
||||
public void Revoking_non_existent_user_is_no_op()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("revocation-test");
|
||||
|
||||
// Should not throw
|
||||
var ex = Record.Exception(() => acc.RevokeUser("NONEXISTENT_KEY", issuedAt: 500));
|
||||
ex.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestUserRevoked accounts_test.go — re-revoke at later time updates revocation
|
||||
[Fact]
|
||||
public void Re_revoking_user_with_later_timestamp_updates_revocation()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var acc = server.GetOrCreateAccount("revocation-test");
|
||||
|
||||
acc.RevokeUser("UNKEY_RE", issuedAt: 1000);
|
||||
// User issued at 1001 is currently allowed
|
||||
acc.IsUserRevoked("UNKEY_RE", issuedAt: 1001).ShouldBeFalse();
|
||||
|
||||
// Re-revoke at a later timestamp
|
||||
acc.RevokeUser("UNKEY_RE", issuedAt: 2000);
|
||||
// Now user issued at 1001 should be rejected
|
||||
acc.IsUserRevoked("UNKEY_RE", issuedAt: 1001).ShouldBeTrue();
|
||||
// User issued at 2001 still allowed
|
||||
acc.IsUserRevoked("UNKEY_RE", issuedAt: 2001).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── Cross-account communication ───────────────────────────────────────────
|
||||
|
||||
// Go: TestCrossAccountRequestReply accounts_test.go — service export visibility
|
||||
[Fact]
|
||||
public void Service_export_is_visible_in_exporter_account()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var exporter = server.GetOrCreateAccount("exporter");
|
||||
|
||||
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
|
||||
|
||||
exporter.Exports.Services.ShouldContainKey("api.>");
|
||||
exporter.Exports.Services["api.>"].Account.ShouldBeSameAs(exporter);
|
||||
}
|
||||
|
||||
// Go: TestCrossAccountRequestReply accounts_test.go — service import routing
|
||||
[Fact]
|
||||
public void Service_import_routes_to_exporter_sublist()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var exporter = server.GetOrCreateAccount("exporter");
|
||||
var importer = server.GetOrCreateAccount("importer");
|
||||
|
||||
exporter.AddServiceExport("svc.calc", ServiceResponseType.Singleton, null);
|
||||
importer.AddServiceImport(exporter, "requests.calc", "svc.calc");
|
||||
|
||||
var received = new List<string>();
|
||||
var mockClient = new TestNatsClient(1, exporter);
|
||||
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
|
||||
|
||||
exporter.SubList.Insert(new Subscription { Subject = "svc.calc", Sid = "s1", Client = mockClient });
|
||||
|
||||
var si = importer.Imports.Services["requests.calc"][0];
|
||||
server.ProcessServiceImport(si, "requests.calc", null, default, default);
|
||||
|
||||
received.Count.ShouldBe(1);
|
||||
received[0].ShouldBe("svc.calc");
|
||||
}
|
||||
|
||||
// Go: TestCrossAccountRequestReply accounts_test.go — response routed back to importer
|
||||
[Fact]
|
||||
public void Service_import_response_preserves_reply_to_inbox()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var exporter = server.GetOrCreateAccount("exporter");
|
||||
var importer = server.GetOrCreateAccount("importer");
|
||||
|
||||
exporter.AddServiceExport("api.query", ServiceResponseType.Singleton, null);
|
||||
importer.AddServiceImport(exporter, "q.query", "api.query");
|
||||
|
||||
string? capturedReply = null;
|
||||
var mockClient = new TestNatsClient(1, exporter);
|
||||
mockClient.OnMessage = (_, _, replyTo, _, _) => capturedReply = replyTo;
|
||||
|
||||
exporter.SubList.Insert(new Subscription { Subject = "api.query", Sid = "s1", Client = mockClient });
|
||||
|
||||
var si = importer.Imports.Services["q.query"][0];
|
||||
server.ProcessServiceImport(si, "q.query", "_INBOX.reply.001", default, default);
|
||||
|
||||
capturedReply.ShouldBe("_INBOX.reply.001");
|
||||
}
|
||||
|
||||
// Go: TestCrossAccountRequestReply accounts_test.go — wildcard import/export matching
|
||||
[Fact]
|
||||
public void Wildcard_service_import_maps_token_suffix()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var exporter = server.GetOrCreateAccount("exporter");
|
||||
var importer = server.GetOrCreateAccount("importer");
|
||||
|
||||
exporter.AddServiceExport("backend.>", ServiceResponseType.Singleton, null);
|
||||
importer.AddServiceImport(exporter, "public.>", "backend.>");
|
||||
|
||||
var received = new List<string>();
|
||||
var mockClient = new TestNatsClient(1, exporter);
|
||||
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
|
||||
|
||||
exporter.SubList.Insert(new Subscription { Subject = "backend.echo", Sid = "s1", Client = mockClient });
|
||||
|
||||
var si = importer.Imports.Services["public.>"][0];
|
||||
server.ProcessServiceImport(si, "public.echo", null, default, default);
|
||||
|
||||
received.Count.ShouldBe(1);
|
||||
received[0].ShouldBe("backend.echo");
|
||||
}
|
||||
|
||||
// Go: TestCrossAccountRequestReply accounts_test.go — account subject namespaces independent
|
||||
[Fact]
|
||||
public void Account_specific_subject_namespaces_are_independent()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
var accA = server.GetOrCreateAccount("ns-a");
|
||||
var accB = server.GetOrCreateAccount("ns-b");
|
||||
|
||||
var receivedA = new List<string>();
|
||||
var receivedB = new List<string>();
|
||||
|
||||
var clientA = new TestNatsClient(1, accA);
|
||||
clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
|
||||
var clientB = new TestNatsClient(2, accB);
|
||||
clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);
|
||||
|
||||
accA.SubList.Insert(new Subscription { Subject = "shared.topic", Sid = "a1", Client = clientA });
|
||||
accB.SubList.Insert(new Subscription { Subject = "shared.topic", Sid = "b1", Client = clientB });
|
||||
|
||||
// Publish only to A's namespace
|
||||
var resultA = accA.SubList.Match("shared.topic");
|
||||
foreach (var sub in resultA.PlainSubs)
|
||||
sub.Client?.SendMessage("shared.topic", sub.Sid, null, default, default);
|
||||
|
||||
receivedA.Count.ShouldBe(1);
|
||||
receivedB.Count.ShouldBe(0); // B's subscription not in A's sublist
|
||||
}
|
||||
|
||||
// Go: accounts_test.go — proxy authenticator routes to correct account
|
||||
[Fact]
|
||||
public void ProxyAuthenticator_routes_to_configured_account()
|
||||
{
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ProxyAuth = new ProxyAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
UsernamePrefix = "proxy:",
|
||||
Account = "proxy-account",
|
||||
},
|
||||
});
|
||||
|
||||
var result = authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "proxy:my-identity" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.Identity.ShouldBe("my-identity");
|
||||
result.AccountName.ShouldBe("proxy-account");
|
||||
}
|
||||
|
||||
// Go: accounts_test.go — proxy authenticator rejects non-matching prefix
|
||||
[Fact]
|
||||
public void ProxyAuthenticator_rejects_non_matching_prefix()
|
||||
{
|
||||
var authService = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ProxyAuth = new ProxyAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
UsernamePrefix = "proxy:",
|
||||
Account = "proxy-account",
|
||||
},
|
||||
});
|
||||
|
||||
var result = authService.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "direct-user", Password = "x" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: auth_callout_test.go — integration: callout allowed connection succeeds
|
||||
[Fact]
|
||||
public async Task AuthCallout_allowed_connection_connects_successfully()
|
||||
{
|
||||
var calloutClient = new StubExternalAuthClient(allow: true, identity: "user1");
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Client = calloutClient,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
},
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await using var nats = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://user1:anypass@127.0.0.1:{port}",
|
||||
});
|
||||
|
||||
await nats.ConnectAsync();
|
||||
await nats.PingAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: auth_callout_test.go — integration: callout denied connection fails
|
||||
[Fact]
|
||||
public async Task AuthCallout_denied_connection_is_rejected()
|
||||
{
|
||||
var calloutClient = new StubExternalAuthClient(allow: false);
|
||||
var (server, port, cts) = await StartServerAsync(new NatsOptions
|
||||
{
|
||||
ExternalAuth = new ExternalAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Client = calloutClient,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
},
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await using var nats = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://bad-user:badpass@127.0.0.1:{port}",
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
||||
{
|
||||
await nats.ConnectAsync();
|
||||
await nats.PingAsync();
|
||||
});
|
||||
|
||||
ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue(
|
||||
$"Expected 'Authorization Violation' in exception chain, but got: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test doubles ─────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class StubExternalAuthClient(bool allow, string? identity = null, string? account = null)
|
||||
: IExternalAuthClient
|
||||
{
|
||||
public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) =>
|
||||
Task.FromResult(new ExternalAuthDecision(allow, identity, account));
|
||||
}
|
||||
|
||||
private sealed class DelayedExternalAuthClient(TimeSpan delay) : IExternalAuthClient
|
||||
{
|
||||
public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||
using var timer = new Timer(_ => tcs.TrySetResult(true), null, delay, Timeout.InfiniteTimeSpan);
|
||||
await tcs.Task;
|
||||
return new ExternalAuthDecision(true, "delayed");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingExternalAuthClient(bool allow, string identity) : IExternalAuthClient
|
||||
{
|
||||
public ExternalAuthRequest? LastRequest { get; private set; }
|
||||
|
||||
public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new ExternalAuthDecision(allow, identity));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingExternalAuthClient(bool allow, string identity) : IExternalAuthClient
|
||||
{
|
||||
private int _callCount;
|
||||
public int CallCount => _callCount;
|
||||
|
||||
public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
return Task.FromResult(new ExternalAuthDecision(allow, identity));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestNatsClient(ulong id, Account account) : INatsClient
|
||||
{
|
||||
public ulong Id => id;
|
||||
public ClientKind Kind => ClientKind.Client;
|
||||
public Account? Account => account;
|
||||
public 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) { }
|
||||
}
|
||||
}
|
||||
594
tests/NATS.Server.Auth.Tests/Accounts/AuthMechanismTests.cs
Normal file
594
tests/NATS.Server.Auth.Tests/Accounts/AuthMechanismTests.cs
Normal file
@@ -0,0 +1,594 @@
|
||||
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;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.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 async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static bool ExceptionChainContains(Exception ex, string substring)
|
||||
{
|
||||
Exception? current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Go: 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("/");
|
||||
}
|
||||
}
|
||||
438
tests/NATS.Server.Auth.Tests/Accounts/PermissionTests.cs
Normal file
438
tests/NATS.Server.Auth.Tests/Accounts/PermissionTests.cs
Normal file
@@ -0,0 +1,438 @@
|
||||
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.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.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 async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static bool ExceptionChainContains(Exception ex, string substring)
|
||||
{
|
||||
Exception? current = ex;
|
||||
while (current != null)
|
||||
{
|
||||
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
current = current.InnerException;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Go: 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 — timeout confirms permission denial blocked the message
|
||||
return;
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
173
tests/NATS.Server.Auth.Tests/Auth/AccountClaimReloadTests.cs
Normal file
173
tests/NATS.Server.Auth.Tests/Auth/AccountClaimReloadTests.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
// Tests for account claim hot-reload with diff-based update detection.
|
||||
// Go reference: accounts_test.go TestUpdateAccountClaims, updateAccountClaimsWithRefresh (~line 3374).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountClaimReloadTests
|
||||
{
|
||||
// 1. First update: all provided fields are reported as changed.
|
||||
[Fact]
|
||||
public void UpdateAccountClaims_FirstUpdate_AllFieldsChanged()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var claims = new AccountClaimData
|
||||
{
|
||||
MaxConnections = 10,
|
||||
MaxSubscriptions = 100,
|
||||
Nkey = "NKEY123",
|
||||
Issuer = "ISSUER_OP",
|
||||
ExpiresAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
var result = account.UpdateAccountClaims(claims);
|
||||
|
||||
result.Changed.ShouldBeTrue();
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections));
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions));
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey));
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer));
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.ExpiresAt));
|
||||
result.ChangedFields.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
// 2. Applying the exact same claims a second time returns Changed=false.
|
||||
[Fact]
|
||||
public void UpdateAccountClaims_NoChange_ReturnsFalse()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var claims = new AccountClaimData
|
||||
{
|
||||
MaxConnections = 5,
|
||||
MaxSubscriptions = 50,
|
||||
Nkey = "NKEY_A",
|
||||
Issuer = "OP",
|
||||
};
|
||||
|
||||
account.UpdateAccountClaims(claims);
|
||||
var result = account.UpdateAccountClaims(claims);
|
||||
|
||||
result.Changed.ShouldBeFalse();
|
||||
result.ChangedFields.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// 3. Changing MaxConnections is detected.
|
||||
[Fact]
|
||||
public void UpdateAccountClaims_MaxConnectionsChanged_Detected()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var initial = new AccountClaimData { MaxConnections = 10 };
|
||||
account.UpdateAccountClaims(initial);
|
||||
|
||||
var updated = new AccountClaimData { MaxConnections = 20 };
|
||||
var result = account.UpdateAccountClaims(updated);
|
||||
|
||||
result.Changed.ShouldBeTrue();
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxConnections));
|
||||
account.MaxConnections.ShouldBe(20);
|
||||
}
|
||||
|
||||
// 4. Changing MaxSubscriptions is detected.
|
||||
[Fact]
|
||||
public void UpdateAccountClaims_MaxSubscriptionsChanged_Detected()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var initial = new AccountClaimData { MaxSubscriptions = 100 };
|
||||
account.UpdateAccountClaims(initial);
|
||||
|
||||
var updated = new AccountClaimData { MaxSubscriptions = 200 };
|
||||
var result = account.UpdateAccountClaims(updated);
|
||||
|
||||
result.Changed.ShouldBeTrue();
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.MaxSubscriptions));
|
||||
account.MaxSubscriptions.ShouldBe(200);
|
||||
}
|
||||
|
||||
// 5. Changing Nkey is detected.
|
||||
[Fact]
|
||||
public void UpdateAccountClaims_NkeyChanged_Detected()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var initial = new AccountClaimData { Nkey = "OLD_NKEY" };
|
||||
account.UpdateAccountClaims(initial);
|
||||
|
||||
var updated = new AccountClaimData { Nkey = "NEW_NKEY" };
|
||||
var result = account.UpdateAccountClaims(updated);
|
||||
|
||||
result.Changed.ShouldBeTrue();
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Nkey));
|
||||
account.Nkey.ShouldBe("NEW_NKEY");
|
||||
}
|
||||
|
||||
// 6. Changing Issuer is detected.
|
||||
[Fact]
|
||||
public void UpdateAccountClaims_IssuerChanged_Detected()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var initial = new AccountClaimData { Issuer = "ISSUER_A" };
|
||||
account.UpdateAccountClaims(initial);
|
||||
|
||||
var updated = new AccountClaimData { Issuer = "ISSUER_B" };
|
||||
var result = account.UpdateAccountClaims(updated);
|
||||
|
||||
result.Changed.ShouldBeTrue();
|
||||
result.ChangedFields.ShouldContain(nameof(AccountClaimData.Issuer));
|
||||
account.Issuer.ShouldBe("ISSUER_B");
|
||||
}
|
||||
|
||||
// 7. A successful claim update increments the generation counter.
|
||||
[Fact]
|
||||
public void UpdateAccountClaims_IncrementsGeneration()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var before = account.GenerationId;
|
||||
|
||||
var claims = new AccountClaimData { MaxConnections = 5 };
|
||||
account.UpdateAccountClaims(claims);
|
||||
|
||||
account.GenerationId.ShouldBe(before + 1);
|
||||
}
|
||||
|
||||
// 8. HasClaims is false on a fresh account.
|
||||
[Fact]
|
||||
public void HasClaims_BeforeUpdate_ReturnsFalse()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.HasClaims.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 9. HasClaims is true after the first update.
|
||||
[Fact]
|
||||
public void HasClaims_AfterUpdate_ReturnsTrue()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var claims = new AccountClaimData { MaxConnections = 1 };
|
||||
account.UpdateAccountClaims(claims);
|
||||
|
||||
account.HasClaims.ShouldBeTrue();
|
||||
account.CurrentClaims.ShouldNotBeNull();
|
||||
account.CurrentClaims!.MaxConnections.ShouldBe(1);
|
||||
}
|
||||
|
||||
// 10. ClaimUpdateCount increments only when claims actually change.
|
||||
[Fact]
|
||||
public void ClaimUpdateCount_IncrementsOnChange()
|
||||
{
|
||||
var account = new Account("test");
|
||||
account.ClaimUpdateCount.ShouldBe(0);
|
||||
|
||||
var claimsA = new AccountClaimData { MaxConnections = 5 };
|
||||
account.UpdateAccountClaims(claimsA);
|
||||
account.ClaimUpdateCount.ShouldBe(1);
|
||||
|
||||
// Reapplying same claims does NOT increment count.
|
||||
account.UpdateAccountClaims(claimsA);
|
||||
account.ClaimUpdateCount.ShouldBe(1);
|
||||
|
||||
// Applying different claims does increment.
|
||||
var claimsB = new AccountClaimData { MaxConnections = 10 };
|
||||
account.UpdateAccountClaims(claimsB);
|
||||
account.ClaimUpdateCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
135
tests/NATS.Server.Auth.Tests/Auth/AccountExpirationTests.cs
Normal file
135
tests/NATS.Server.Auth.Tests/Auth/AccountExpirationTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using NATS.Server.Auth;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
// Go reference: server/accounts.go — account expiry / SetExpirationTimer
|
||||
|
||||
public sealed class AccountExpirationTests
|
||||
{
|
||||
// 1. ExpiresAt_Default_IsNull
|
||||
// Go reference: accounts.go — account.expiry zero-value
|
||||
[Fact]
|
||||
public void ExpiresAt_Default_IsNull()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.ExpiresAt.ShouldBeNull();
|
||||
}
|
||||
|
||||
// 2. SetExpiration_SetsExpiresAt
|
||||
// Go reference: accounts.go — SetExpirationTimer stores expiry value
|
||||
[Fact]
|
||||
public void SetExpiration_SetsExpiresAt()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
var expiresAt = new DateTime(2030, 6, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
account.SetExpiration(expiresAt);
|
||||
|
||||
account.ExpiresAt.ShouldBe(expiresAt);
|
||||
}
|
||||
|
||||
// 3. IsExpired_FutureDate_ReturnsFalse
|
||||
// Go reference: accounts.go — isExpired() returns false when expiry is in future
|
||||
[Fact]
|
||||
public void IsExpired_FutureDate_ReturnsFalse()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.SetExpiration(DateTime.UtcNow.AddHours(1));
|
||||
|
||||
account.IsExpired.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 4. IsExpired_PastDate_ReturnsTrue
|
||||
// Go reference: accounts.go — isExpired() returns true when past expiry
|
||||
[Fact]
|
||||
public void IsExpired_PastDate_ReturnsTrue()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.SetExpiration(DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
account.IsExpired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 5. ClearExpiration_RemovesExpiry
|
||||
// Go reference: accounts.go — clearing expiry resets the field to zero
|
||||
[Fact]
|
||||
public void ClearExpiration_RemovesExpiry()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.SetExpiration(DateTime.UtcNow.AddHours(1));
|
||||
account.ExpiresAt.ShouldNotBeNull();
|
||||
|
||||
account.ClearExpiration();
|
||||
|
||||
account.ExpiresAt.ShouldBeNull();
|
||||
}
|
||||
|
||||
// 6. SetExpirationFromTtl_CalculatesCorrectly
|
||||
// Go reference: accounts.go — SetExpirationTimer(ttl) sets expiry = now + ttl
|
||||
[Fact]
|
||||
public void SetExpirationFromTtl_CalculatesCorrectly()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
var before = DateTime.UtcNow;
|
||||
account.SetExpirationFromTtl(TimeSpan.FromHours(1));
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
account.ExpiresAt.ShouldNotBeNull();
|
||||
account.ExpiresAt!.Value.ShouldBeGreaterThanOrEqualTo(before.AddHours(1));
|
||||
account.ExpiresAt.Value.ShouldBeLessThanOrEqualTo(after.AddHours(1));
|
||||
}
|
||||
|
||||
// 7. TimeToExpiry_NoExpiry_ReturnsNull
|
||||
// Go reference: accounts.go — no expiry set returns nil duration
|
||||
[Fact]
|
||||
public void TimeToExpiry_NoExpiry_ReturnsNull()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
|
||||
account.TimeToExpiry.ShouldBeNull();
|
||||
}
|
||||
|
||||
// 8. TimeToExpiry_Expired_ReturnsZero
|
||||
// Go reference: accounts.go — already-expired account has zero remaining time
|
||||
[Fact]
|
||||
public void TimeToExpiry_Expired_ReturnsZero()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.SetExpiration(DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
account.TimeToExpiry.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// 9. TimeToExpiry_Future_ReturnsPositive
|
||||
// Go reference: accounts.go — unexpired account returns positive remaining duration
|
||||
[Fact]
|
||||
public void TimeToExpiry_Future_ReturnsPositive()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.SetExpiration(DateTime.UtcNow.AddHours(1));
|
||||
|
||||
var tte = account.TimeToExpiry;
|
||||
tte.ShouldNotBeNull();
|
||||
tte!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// 10. GetExpirationInfo_ReturnsCompleteInfo
|
||||
// Go reference: accounts.go — expiry fields exposed for monitoring / JWT renewal
|
||||
[Fact]
|
||||
public void GetExpirationInfo_ReturnsCompleteInfo()
|
||||
{
|
||||
var account = new Account("info-account");
|
||||
var expiresAt = DateTime.UtcNow.AddHours(2);
|
||||
account.SetExpiration(expiresAt);
|
||||
|
||||
var info = account.GetExpirationInfo();
|
||||
|
||||
info.AccountName.ShouldBe("info-account");
|
||||
info.HasExpiration.ShouldBeTrue();
|
||||
info.ExpiresAt.ShouldBe(expiresAt);
|
||||
info.IsExpired.ShouldBeFalse();
|
||||
info.TimeToExpiry.ShouldNotBeNull();
|
||||
info.TimeToExpiry!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
481
tests/NATS.Server.Auth.Tests/Auth/AccountGoParityTests.cs
Normal file
481
tests/NATS.Server.Auth.Tests/Auth/AccountGoParityTests.cs
Normal file
@@ -0,0 +1,481 @@
|
||||
// Port of Go server/accounts_test.go — account routing, limits, and import/export parity tests.
|
||||
// Reference: golang/nats-server/server/accounts_test.go
|
||||
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using ServerSubscriptions = NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Parity tests ported from Go server/accounts_test.go exercising account
|
||||
/// route mappings, connection limits, import/export cycle detection,
|
||||
/// system account, and JetStream resource limits.
|
||||
/// </summary>
|
||||
public class AccountGoParityTests
|
||||
{
|
||||
// ========================================================================
|
||||
// TestAccountBasicRouteMapping
|
||||
// Go reference: accounts_test.go:TestAccountBasicRouteMapping
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BasicRouteMapping_SubjectIsolation()
|
||||
{
|
||||
// Go: TestAccountBasicRouteMapping — messages are isolated to accounts.
|
||||
// Different accounts have independent subscription namespaces.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
|
||||
// Add subscriptions to account A's SubList
|
||||
var subA = new ServerSubscriptions.Subscription { Subject = "foo", Sid = "1" };
|
||||
accA.SubList.Insert(subA);
|
||||
|
||||
// Account B should not see account A's subscriptions
|
||||
var resultB = accB.SubList.Match("foo");
|
||||
resultB.PlainSubs.Length.ShouldBe(0);
|
||||
|
||||
// Account A should see its own subscription
|
||||
var resultA = accA.SubList.Match("foo");
|
||||
resultA.PlainSubs.Length.ShouldBe(1);
|
||||
resultA.PlainSubs[0].ShouldBe(subA);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TestAccountWildcardRouteMapping
|
||||
// Go reference: accounts_test.go:TestAccountWildcardRouteMapping
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void WildcardRouteMapping_PerAccountMatching()
|
||||
{
|
||||
// Go: TestAccountWildcardRouteMapping — wildcards work per-account.
|
||||
using var acc = new Account("TEST");
|
||||
|
||||
var sub1 = new ServerSubscriptions.Subscription { Subject = "orders.*", Sid = "1" };
|
||||
var sub2 = new ServerSubscriptions.Subscription { Subject = "orders.>", Sid = "2" };
|
||||
acc.SubList.Insert(sub1);
|
||||
acc.SubList.Insert(sub2);
|
||||
|
||||
var result = acc.SubList.Match("orders.new");
|
||||
result.PlainSubs.Length.ShouldBe(2);
|
||||
|
||||
var result2 = acc.SubList.Match("orders.new.item");
|
||||
result2.PlainSubs.Length.ShouldBe(1); // only "orders.>" matches
|
||||
result2.PlainSubs[0].ShouldBe(sub2);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Connection limits
|
||||
// Go reference: accounts_test.go:TestAccountConnsLimitExceededAfterUpdate
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ConnectionLimit_ExceededAfterUpdate()
|
||||
{
|
||||
// Go: TestAccountConnsLimitExceededAfterUpdate — reducing max connections
|
||||
// below current count prevents new connections.
|
||||
using var acc = new Account("TEST") { MaxConnections = 5 };
|
||||
|
||||
// Add 5 clients
|
||||
for (ulong i = 1; i <= 5; i++)
|
||||
acc.AddClient(i).ShouldBeTrue();
|
||||
|
||||
acc.ClientCount.ShouldBe(5);
|
||||
|
||||
// 6th client should fail
|
||||
acc.AddClient(6).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionLimit_RemoveAllowsNew()
|
||||
{
|
||||
// Go: removing a client frees a slot.
|
||||
using var acc = new Account("TEST") { MaxConnections = 2 };
|
||||
|
||||
acc.AddClient(1).ShouldBeTrue();
|
||||
acc.AddClient(2).ShouldBeTrue();
|
||||
acc.AddClient(3).ShouldBeFalse();
|
||||
|
||||
acc.RemoveClient(1);
|
||||
acc.AddClient(3).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConnectionLimit_ZeroMeansUnlimited()
|
||||
{
|
||||
// Go: MaxConnections=0 means unlimited.
|
||||
using var acc = new Account("TEST") { MaxConnections = 0 };
|
||||
|
||||
for (ulong i = 1; i <= 100; i++)
|
||||
acc.AddClient(i).ShouldBeTrue();
|
||||
|
||||
acc.ClientCount.ShouldBe(100);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Subscription limits
|
||||
// Go reference: accounts_test.go TestAccountUserSubPermsWithQueueGroups
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SubscriptionLimit_Enforced()
|
||||
{
|
||||
// Go: TestAccountUserSubPermsWithQueueGroups — subscription count limits.
|
||||
using var acc = new Account("TEST") { MaxSubscriptions = 3 };
|
||||
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeFalse();
|
||||
|
||||
acc.SubscriptionCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscriptionLimit_DecrementAllowsNew()
|
||||
{
|
||||
using var acc = new Account("TEST") { MaxSubscriptions = 2 };
|
||||
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
acc.IncrementSubscriptions().ShouldBeFalse();
|
||||
|
||||
acc.DecrementSubscriptions();
|
||||
acc.IncrementSubscriptions().ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// System account
|
||||
// Go reference: events_test.go:TestSystemAccountNewConnection
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void SystemAccount_IsSystemAccountFlag()
|
||||
{
|
||||
// Go: TestSystemAccountNewConnection — system account identification.
|
||||
using var sysAcc = new Account(Account.SystemAccountName) { IsSystemAccount = true };
|
||||
using var globalAcc = new Account(Account.GlobalAccountName);
|
||||
|
||||
sysAcc.IsSystemAccount.ShouldBeTrue();
|
||||
sysAcc.Name.ShouldBe("$SYS");
|
||||
|
||||
globalAcc.IsSystemAccount.ShouldBeFalse();
|
||||
globalAcc.Name.ShouldBe("$G");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Import/Export cycle detection
|
||||
// Go reference: accounts_test.go — addServiceImport with checkForImportCycle
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ImportExport_DirectCycleDetected()
|
||||
{
|
||||
// Go: cycle detection prevents A importing from B when B imports from A.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
|
||||
accA.AddServiceExport("svc.a", ServiceResponseType.Singleton, [accB]);
|
||||
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
||||
|
||||
// A imports from B
|
||||
accA.AddServiceImport(accB, "from.b", "svc.b");
|
||||
|
||||
// B importing from A would create a cycle: B -> A -> B
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
accB.AddServiceImport(accA, "from.a", "svc.a"));
|
||||
ex.Message.ShouldContain("cycle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportExport_IndirectCycleDetected()
|
||||
{
|
||||
// Go: indirect cycles through A -> B -> C -> A are detected.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
using var accC = new Account("C");
|
||||
|
||||
accA.AddServiceExport("svc.a", ServiceResponseType.Singleton, [accC]);
|
||||
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
||||
accC.AddServiceExport("svc.c", ServiceResponseType.Singleton, [accB]);
|
||||
|
||||
// A -> B
|
||||
accA.AddServiceImport(accB, "from.b", "svc.b");
|
||||
// B -> C
|
||||
accB.AddServiceImport(accC, "from.c", "svc.c");
|
||||
|
||||
// C -> A would close the cycle: C -> A -> B -> C
|
||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
||||
accC.AddServiceImport(accA, "from.a", "svc.a"));
|
||||
ex.Message.ShouldContain("cycle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportExport_NoCycle_Succeeds()
|
||||
{
|
||||
// Go: linear import chain A -> B -> C is allowed.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
using var accC = new Account("C");
|
||||
|
||||
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
||||
accC.AddServiceExport("svc.c", ServiceResponseType.Singleton, [accB]);
|
||||
|
||||
accA.AddServiceImport(accB, "from.b", "svc.b");
|
||||
accB.AddServiceImport(accC, "from.c", "svc.c");
|
||||
// No exception — linear chain is allowed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportExport_UnauthorizedAccount_Throws()
|
||||
{
|
||||
// Go: unauthorized import throws.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
using var accC = new Account("C");
|
||||
|
||||
// B exports only to C, not A
|
||||
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accC]);
|
||||
|
||||
Should.Throw<UnauthorizedAccessException>(() =>
|
||||
accA.AddServiceImport(accB, "from.b", "svc.b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportExport_NoExport_Throws()
|
||||
{
|
||||
// Go: importing a non-existent export throws.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
accA.AddServiceImport(accB, "from.b", "svc.nonexistent"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Stream import/export
|
||||
// Go reference: accounts_test.go TestAccountBasicRouteMapping (stream exports)
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void StreamImportExport_BasicFlow()
|
||||
{
|
||||
// Go: basic stream export from A, imported by B.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
|
||||
accA.AddStreamExport("events.>", [accB]);
|
||||
accB.AddStreamImport(accA, "events.>", "imported.events.>");
|
||||
|
||||
accB.Imports.Streams.Count.ShouldBe(1);
|
||||
accB.Imports.Streams[0].From.ShouldBe("events.>");
|
||||
accB.Imports.Streams[0].To.ShouldBe("imported.events.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamImport_Unauthorized_Throws()
|
||||
{
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
using var accC = new Account("C");
|
||||
|
||||
accA.AddStreamExport("events.>", [accC]); // only C authorized
|
||||
|
||||
Should.Throw<UnauthorizedAccessException>(() =>
|
||||
accB.AddStreamImport(accA, "events.>", "imported.>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamImport_NoExport_Throws()
|
||||
{
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
accB.AddStreamImport(accA, "nonexistent.>", "imported.>"));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// JetStream account limits
|
||||
// Go reference: accounts_test.go (JS limits section)
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void JetStreamLimits_MaxStreams_Enforced()
|
||||
{
|
||||
// Go: per-account JetStream stream limit.
|
||||
using var acc = new Account("TEST")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxStreams = 2 },
|
||||
};
|
||||
|
||||
acc.TryReserveStream().ShouldBeTrue();
|
||||
acc.TryReserveStream().ShouldBeTrue();
|
||||
acc.TryReserveStream().ShouldBeFalse();
|
||||
|
||||
acc.ReleaseStream();
|
||||
acc.TryReserveStream().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JetStreamLimits_MaxConsumers_Enforced()
|
||||
{
|
||||
using var acc = new Account("TEST")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxConsumers = 3 },
|
||||
};
|
||||
|
||||
acc.TryReserveConsumer().ShouldBeTrue();
|
||||
acc.TryReserveConsumer().ShouldBeTrue();
|
||||
acc.TryReserveConsumer().ShouldBeTrue();
|
||||
acc.TryReserveConsumer().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JetStreamLimits_MaxStorage_Enforced()
|
||||
{
|
||||
using var acc = new Account("TEST")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxStorage = 1024 },
|
||||
};
|
||||
|
||||
acc.TrackStorageDelta(512).ShouldBeTrue();
|
||||
acc.TrackStorageDelta(512).ShouldBeTrue();
|
||||
acc.TrackStorageDelta(1).ShouldBeFalse(); // would exceed
|
||||
|
||||
acc.TrackStorageDelta(-256).ShouldBeTrue(); // free some
|
||||
acc.TrackStorageDelta(256).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JetStreamLimits_Unlimited_AllowsAny()
|
||||
{
|
||||
using var acc = new Account("TEST")
|
||||
{
|
||||
JetStreamLimits = AccountLimits.Unlimited,
|
||||
};
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
acc.TryReserveStream().ShouldBeTrue();
|
||||
acc.TryReserveConsumer().ShouldBeTrue();
|
||||
}
|
||||
|
||||
acc.TrackStorageDelta(long.MaxValue / 2).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Account stats tracking
|
||||
// Go reference: accounts_test.go TestAccountReqMonitoring
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void AccountStats_InboundOutbound()
|
||||
{
|
||||
// Go: TestAccountReqMonitoring — per-account message/byte stats.
|
||||
using var acc = new Account("TEST");
|
||||
|
||||
acc.IncrementInbound(10, 1024);
|
||||
acc.IncrementOutbound(5, 512);
|
||||
|
||||
acc.InMsgs.ShouldBe(10);
|
||||
acc.InBytes.ShouldBe(1024);
|
||||
acc.OutMsgs.ShouldBe(5);
|
||||
acc.OutBytes.ShouldBe(512);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountStats_CumulativeAcrossIncrements()
|
||||
{
|
||||
using var acc = new Account("TEST");
|
||||
|
||||
acc.IncrementInbound(10, 1024);
|
||||
acc.IncrementInbound(5, 512);
|
||||
|
||||
acc.InMsgs.ShouldBe(15);
|
||||
acc.InBytes.ShouldBe(1536);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// User revocation
|
||||
// Go reference: accounts_test.go TestAccountClaimsUpdatesWithServiceImports
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void UserRevocation_RevokedBeforeIssuedAt()
|
||||
{
|
||||
// Go: TestAccountClaimsUpdatesWithServiceImports — user revocation by NKey.
|
||||
using var acc = new Account("TEST");
|
||||
|
||||
acc.RevokeUser("UABC123", 1000);
|
||||
|
||||
// JWT issued at 999 (before revocation) is revoked
|
||||
acc.IsUserRevoked("UABC123", 999).ShouldBeTrue();
|
||||
// JWT issued at 1000 (exactly at revocation) is revoked
|
||||
acc.IsUserRevoked("UABC123", 1000).ShouldBeTrue();
|
||||
// JWT issued at 1001 (after revocation) is NOT revoked
|
||||
acc.IsUserRevoked("UABC123", 1001).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserRevocation_WildcardRevokesAll()
|
||||
{
|
||||
using var acc = new Account("TEST");
|
||||
|
||||
acc.RevokeUser("*", 500);
|
||||
|
||||
acc.IsUserRevoked("ANY_USER_1", 499).ShouldBeTrue();
|
||||
acc.IsUserRevoked("ANY_USER_2", 500).ShouldBeTrue();
|
||||
acc.IsUserRevoked("ANY_USER_3", 501).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserRevocation_UnrevokedUser_NotRevoked()
|
||||
{
|
||||
using var acc = new Account("TEST");
|
||||
acc.IsUserRevoked("UNKNOWN_USER", 1000).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Remove service/stream imports
|
||||
// Go reference: accounts_test.go TestAccountRouteMappingChangesAfterClientStart
|
||||
// ========================================================================
|
||||
|
||||
[Fact]
|
||||
public void RemoveServiceImport_RemovesCorrectly()
|
||||
{
|
||||
// Go: TestAccountRouteMappingChangesAfterClientStart — dynamic import removal.
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
|
||||
accB.AddServiceExport("svc.b", ServiceResponseType.Singleton, [accA]);
|
||||
accA.AddServiceImport(accB, "from.b", "svc.b");
|
||||
accA.Imports.Services.ContainsKey("from.b").ShouldBeTrue();
|
||||
|
||||
accA.RemoveServiceImport("from.b").ShouldBeTrue();
|
||||
accA.Imports.Services.ContainsKey("from.b").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveStreamImport_RemovesCorrectly()
|
||||
{
|
||||
using var accA = new Account("A");
|
||||
using var accB = new Account("B");
|
||||
|
||||
accA.AddStreamExport("events.>", [accB]);
|
||||
accB.AddStreamImport(accA, "events.>", "imported.>");
|
||||
accB.Imports.Streams.Count.ShouldBe(1);
|
||||
|
||||
accB.RemoveStreamImport("events.>").ShouldBeTrue();
|
||||
accB.Imports.Streams.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveNonexistent_ReturnsFalse()
|
||||
{
|
||||
using var acc = new Account("TEST");
|
||||
acc.RemoveServiceImport("nonexistent").ShouldBeFalse();
|
||||
acc.RemoveStreamImport("nonexistent").ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
211
tests/NATS.Server.Auth.Tests/Auth/AccountImportExportTests.cs
Normal file
211
tests/NATS.Server.Auth.Tests/Auth/AccountImportExportTests.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
// Tests for account import/export cycle detection.
|
||||
// Go reference: accounts_test.go TestAccountImportCycleDetection.
|
||||
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountImportExportTests
|
||||
{
|
||||
private static Account CreateAccount(string name) => new(name);
|
||||
|
||||
private static void SetupServiceExport(Account exporter, string subject, IEnumerable<Account>? approved = null)
|
||||
{
|
||||
exporter.AddServiceExport(subject, ServiceResponseType.Singleton, approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddServiceImport_NoCycle_Succeeds()
|
||||
{
|
||||
// A exports "svc.foo", B imports from A — no cycle
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
SetupServiceExport(a, "svc.foo"); // public export (no approved list)
|
||||
|
||||
var import = b.AddServiceImport(a, "svc.foo", "svc.foo");
|
||||
|
||||
import.ShouldNotBeNull();
|
||||
import.DestinationAccount.Name.ShouldBe("A");
|
||||
import.From.ShouldBe("svc.foo");
|
||||
b.Imports.Services.ShouldContainKey("svc.foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddServiceImport_DirectCycle_Throws()
|
||||
{
|
||||
// A exports "svc.foo", B exports "svc.bar"
|
||||
// B imports "svc.foo" from A (ok)
|
||||
// A imports "svc.bar" from B — creates cycle A->B->A
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
SetupServiceExport(a, "svc.foo");
|
||||
SetupServiceExport(b, "svc.bar");
|
||||
|
||||
b.AddServiceImport(a, "svc.foo", "svc.foo");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => a.AddServiceImport(b, "svc.bar", "svc.bar"))
|
||||
.Message.ShouldContain("cycle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddServiceImport_IndirectCycle_A_B_C_A_Throws()
|
||||
{
|
||||
// A->B->C, then C->A creates indirect cycle
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
|
||||
SetupServiceExport(a, "svc.a");
|
||||
SetupServiceExport(b, "svc.b");
|
||||
SetupServiceExport(c, "svc.c");
|
||||
|
||||
// B imports from A
|
||||
b.AddServiceImport(a, "svc.a", "svc.a");
|
||||
// C imports from B
|
||||
c.AddServiceImport(b, "svc.b", "svc.b");
|
||||
// A imports from C — would create C->B->A->C cycle
|
||||
Should.Throw<InvalidOperationException>(() => a.AddServiceImport(c, "svc.c", "svc.c"))
|
||||
.Message.ShouldContain("cycle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCycle_NoCycle_ReturnsFalse()
|
||||
{
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
|
||||
SetupServiceExport(a, "svc.a");
|
||||
SetupServiceExport(b, "svc.b");
|
||||
|
||||
// A imports from B, B imports from C — linear chain, no cycle back to A
|
||||
// For this test we manually add imports without cycle check via ImportMap
|
||||
b.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = a,
|
||||
From = "svc.a",
|
||||
To = "svc.a",
|
||||
});
|
||||
|
||||
// Check: does following imports from A lead back to C? No.
|
||||
AccountImportExport.DetectCycle(a, c).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCycle_DirectCycle_ReturnsTrue()
|
||||
{
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
// A has import pointing to B
|
||||
a.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = b,
|
||||
From = "svc.x",
|
||||
To = "svc.x",
|
||||
});
|
||||
|
||||
// Does following from A lead to B? Yes.
|
||||
AccountImportExport.DetectCycle(a, b).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectCycle_IndirectCycle_ReturnsTrue()
|
||||
{
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
|
||||
// A -> B -> C (imports)
|
||||
a.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = b,
|
||||
From = "svc.1",
|
||||
To = "svc.1",
|
||||
});
|
||||
b.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = c,
|
||||
From = "svc.2",
|
||||
To = "svc.2",
|
||||
});
|
||||
|
||||
// Does following from A lead to C? Yes, via B.
|
||||
AccountImportExport.DetectCycle(a, c).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveServiceImport_ExistingImport_Succeeds()
|
||||
{
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
SetupServiceExport(a, "svc.foo");
|
||||
b.AddServiceImport(a, "svc.foo", "svc.foo");
|
||||
|
||||
b.Imports.Services.ShouldContainKey("svc.foo");
|
||||
|
||||
b.RemoveServiceImport("svc.foo").ShouldBeTrue();
|
||||
b.Imports.Services.ShouldNotContainKey("svc.foo");
|
||||
|
||||
// Removing again returns false
|
||||
b.RemoveServiceImport("svc.foo").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveStreamImport_ExistingImport_Succeeds()
|
||||
{
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
a.AddStreamExport("stream.data", null); // public
|
||||
b.AddStreamImport(a, "stream.data", "imported.data");
|
||||
|
||||
b.Imports.Streams.Count.ShouldBe(1);
|
||||
|
||||
b.RemoveStreamImport("stream.data").ShouldBeTrue();
|
||||
b.Imports.Streams.Count.ShouldBe(0);
|
||||
|
||||
// Removing again returns false
|
||||
b.RemoveStreamImport("stream.data").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateImport_UnauthorizedAccount_Throws()
|
||||
{
|
||||
var exporter = CreateAccount("Exporter");
|
||||
var importer = CreateAccount("Importer");
|
||||
var approved = CreateAccount("Approved");
|
||||
|
||||
// Export only approves "Approved" account, not "Importer"
|
||||
SetupServiceExport(exporter, "svc.restricted", [approved]);
|
||||
|
||||
Should.Throw<UnauthorizedAccessException>(
|
||||
() => AccountImportExport.ValidateImport(importer, exporter, "svc.restricted"))
|
||||
.Message.ShouldContain("not authorized");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddStreamImport_NoCycleCheck_Succeeds()
|
||||
{
|
||||
// Stream imports do not require cycle detection (unlike service imports).
|
||||
// Even with a "circular" stream import topology, it should succeed.
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
a.AddStreamExport("stream.a", null);
|
||||
b.AddStreamExport("stream.b", null);
|
||||
|
||||
// B imports stream from A
|
||||
b.AddStreamImport(a, "stream.a", "imported.a");
|
||||
|
||||
// A imports stream from B — no cycle check for streams
|
||||
a.AddStreamImport(b, "stream.b", "imported.b");
|
||||
|
||||
a.Imports.Streams.Count.ShouldBe(1);
|
||||
b.Imports.Streams.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
169
tests/NATS.Server.Auth.Tests/Auth/AccountLimitsTests.cs
Normal file
169
tests/NATS.Server.Auth.Tests/Auth/AccountLimitsTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
// Tests for per-account JetStream resource limits.
|
||||
// Go reference: accounts_test.go TestAccountLimits, TestJetStreamLimits.
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountLimitsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryReserveConsumer_UnderLimit_ReturnsTrue()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxConsumers = 3 },
|
||||
};
|
||||
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.ConsumerCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserveConsumer_AtLimit_ReturnsFalse()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxConsumers = 2 },
|
||||
};
|
||||
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.TryReserveConsumer().ShouldBeFalse();
|
||||
account.ConsumerCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleaseConsumer_DecrementsCount()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxConsumers = 2 },
|
||||
};
|
||||
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.ConsumerCount.ShouldBe(2);
|
||||
|
||||
account.ReleaseConsumer();
|
||||
account.ConsumerCount.ShouldBe(1);
|
||||
|
||||
// Now we can reserve again
|
||||
account.TryReserveConsumer().ShouldBeTrue();
|
||||
account.ConsumerCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackStorageDelta_UnderLimit_ReturnsTrue()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxStorage = 1000 },
|
||||
};
|
||||
|
||||
account.TrackStorageDelta(500).ShouldBeTrue();
|
||||
account.StorageUsed.ShouldBe(500);
|
||||
|
||||
account.TrackStorageDelta(400).ShouldBeTrue();
|
||||
account.StorageUsed.ShouldBe(900);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackStorageDelta_ExceedsLimit_ReturnsFalse()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxStorage = 1000 },
|
||||
};
|
||||
|
||||
account.TrackStorageDelta(800).ShouldBeTrue();
|
||||
account.TrackStorageDelta(300).ShouldBeFalse(); // 800 + 300 = 1100 > 1000
|
||||
account.StorageUsed.ShouldBe(800); // unchanged
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrackStorageDelta_NegativeDelta_ReducesUsage()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxStorage = 1000 },
|
||||
};
|
||||
|
||||
account.TrackStorageDelta(800).ShouldBeTrue();
|
||||
account.TrackStorageDelta(-300).ShouldBeTrue(); // negative always succeeds
|
||||
account.StorageUsed.ShouldBe(500);
|
||||
|
||||
// Now we have room again
|
||||
account.TrackStorageDelta(400).ShouldBeTrue();
|
||||
account.StorageUsed.ShouldBe(900);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxStorage_Zero_Unlimited()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
JetStreamLimits = new AccountLimits { MaxStorage = 0 }, // unlimited
|
||||
};
|
||||
|
||||
// Should accept any amount
|
||||
account.TrackStorageDelta(long.MaxValue / 2).ShouldBeTrue();
|
||||
account.StorageUsed.ShouldBe(long.MaxValue / 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Limits_DefaultValues_AllUnlimited()
|
||||
{
|
||||
var limits = AccountLimits.Unlimited;
|
||||
|
||||
limits.MaxStorage.ShouldBe(0);
|
||||
limits.MaxStreams.ShouldBe(0);
|
||||
limits.MaxConsumers.ShouldBe(0);
|
||||
limits.MaxAckPending.ShouldBe(0);
|
||||
limits.MaxMemoryStorage.ShouldBe(0);
|
||||
limits.MaxDiskStorage.ShouldBe(0);
|
||||
|
||||
// Account defaults to unlimited
|
||||
var account = new Account("test");
|
||||
account.JetStreamLimits.ShouldBe(AccountLimits.Unlimited);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReserveStream_WithLimits_RespectsNewLimits()
|
||||
{
|
||||
// JetStreamLimits.MaxStreams should take precedence over MaxJetStreamStreams
|
||||
var account = new Account("test")
|
||||
{
|
||||
MaxJetStreamStreams = 10, // legacy field
|
||||
JetStreamLimits = new AccountLimits { MaxStreams = 2 }, // new limit overrides
|
||||
};
|
||||
|
||||
account.TryReserveStream().ShouldBeTrue();
|
||||
account.TryReserveStream().ShouldBeTrue();
|
||||
account.TryReserveStream().ShouldBeFalse(); // limited to 2 by JetStreamLimits
|
||||
account.JetStreamStreamCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EvictOldestClient_WhenMaxConnectionsExceeded()
|
||||
{
|
||||
var account = new Account("test")
|
||||
{
|
||||
MaxConnections = 2,
|
||||
};
|
||||
|
||||
account.AddClient(1).ShouldBeTrue();
|
||||
account.AddClient(2).ShouldBeTrue();
|
||||
account.AddClient(3).ShouldBeFalse(); // at limit
|
||||
account.ClientCount.ShouldBe(2);
|
||||
|
||||
// Remove oldest, then new one can connect
|
||||
account.RemoveClient(1);
|
||||
account.ClientCount.ShouldBe(1);
|
||||
|
||||
account.AddClient(3).ShouldBeTrue();
|
||||
account.ClientCount.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AccountResponseAndInterestParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientInfoHdr_constant_matches_go_value()
|
||||
{
|
||||
Account.ClientInfoHdr.ShouldBe("Nats-Request-Info");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interest_and_subscription_interest_count_plain_and_queue_matches()
|
||||
{
|
||||
using var account = new Account("A");
|
||||
account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "1" });
|
||||
account.SubList.Insert(new Subscription { Subject = "orders.*", Sid = "2", Queue = "workers" });
|
||||
|
||||
account.Interest("orders.created").ShouldBe(2);
|
||||
account.SubscriptionInterest("orders.created").ShouldBeTrue();
|
||||
account.SubscriptionInterest("payments.created").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumServiceImports_counts_distinct_from_subject_keys()
|
||||
{
|
||||
using var importer = new Account("importer");
|
||||
using var exporter = new Account("exporter");
|
||||
|
||||
importer.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "svc.a",
|
||||
To = "svc.remote.a",
|
||||
});
|
||||
importer.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "svc.a",
|
||||
To = "svc.remote.b",
|
||||
});
|
||||
importer.Imports.AddServiceImport(new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "svc.b",
|
||||
To = "svc.remote.c",
|
||||
});
|
||||
|
||||
importer.NumServiceImports().ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumPendingResponses_filters_by_service_export()
|
||||
{
|
||||
using var account = new Account("A");
|
||||
account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null);
|
||||
account.AddServiceExport("svc.two", ServiceResponseType.Singleton, null);
|
||||
|
||||
var seOne = account.Exports.Services["svc.one"];
|
||||
var seTwo = account.Exports.Services["svc.two"];
|
||||
|
||||
account.Exports.Responses["r1"] = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.AAA.>",
|
||||
To = "reply.one",
|
||||
Export = seOne,
|
||||
IsResponse = true,
|
||||
};
|
||||
account.Exports.Responses["r2"] = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.BBB.>",
|
||||
To = "reply.two",
|
||||
Export = seOne,
|
||||
IsResponse = true,
|
||||
};
|
||||
account.Exports.Responses["r3"] = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.CCC.>",
|
||||
To = "reply.three",
|
||||
Export = seTwo,
|
||||
IsResponse = true,
|
||||
};
|
||||
|
||||
account.NumPendingAllResponses().ShouldBe(3);
|
||||
account.NumPendingResponses("svc.one").ShouldBe(2);
|
||||
account.NumPendingResponses("svc.two").ShouldBe(1);
|
||||
account.NumPendingResponses("svc.unknown").ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveRespServiceImport_removes_mapping_for_specified_reason()
|
||||
{
|
||||
using var account = new Account("A");
|
||||
account.AddServiceExport("svc.one", ServiceResponseType.Singleton, null);
|
||||
var seOne = account.Exports.Services["svc.one"];
|
||||
|
||||
var responseSi = new ServiceImport
|
||||
{
|
||||
DestinationAccount = account,
|
||||
From = "_R_.ZZZ.>",
|
||||
To = "reply",
|
||||
Export = seOne,
|
||||
IsResponse = true,
|
||||
};
|
||||
account.Exports.Responses["r1"] = responseSi;
|
||||
|
||||
account.RemoveRespServiceImport(responseSi, ResponseServiceImportRemovalReason.Timeout);
|
||||
|
||||
account.Exports.Responses.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
1006
tests/NATS.Server.Auth.Tests/Auth/AccountRoutingTests.cs
Normal file
1006
tests/NATS.Server.Auth.Tests/Auth/AccountRoutingTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
200
tests/NATS.Server.Auth.Tests/Auth/ActivationExpirationTests.cs
Normal file
200
tests/NATS.Server.Auth.Tests/Auth/ActivationExpirationTests.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
// Tests for Account JWT activation claim expiration: RegisterActivation,
|
||||
// CheckActivationExpiry, IsActivationExpired, GetExpiredActivations,
|
||||
// RemoveExpiredActivations, and ActiveActivationCount.
|
||||
// Go reference: server/accounts.go — checkActivation (~line 2943),
|
||||
// activationExpired (~line 2920).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ActivationExpirationTests
|
||||
{
|
||||
// Helpers for well-past / well-future dates to avoid timing flakiness.
|
||||
private static DateTime WellFuture => DateTime.UtcNow.AddDays(30);
|
||||
private static DateTime WellPast => DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
private static ActivationClaim ValidClaim(string subject) => new()
|
||||
{
|
||||
Subject = subject,
|
||||
IssuedAt = DateTime.UtcNow.AddDays(-1),
|
||||
ExpiresAt = WellFuture,
|
||||
Issuer = "AABC123",
|
||||
};
|
||||
|
||||
private static ActivationClaim ExpiredClaim(string subject) => new()
|
||||
{
|
||||
Subject = subject,
|
||||
IssuedAt = DateTime.UtcNow.AddDays(-60),
|
||||
ExpiresAt = WellPast,
|
||||
Issuer = "AABC123",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RegisterActivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RegisterActivation_StoresActivation()
|
||||
{
|
||||
// Go ref: accounts.go — checkActivation stores the decoded activation claim.
|
||||
var account = new Account("test");
|
||||
var claim = ValidClaim("svc.foo");
|
||||
|
||||
account.RegisterActivation("svc.foo", claim);
|
||||
|
||||
var result = account.CheckActivationExpiry("svc.foo");
|
||||
result.Found.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckActivationExpiry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CheckActivationExpiry_Valid_NotExpired()
|
||||
{
|
||||
// Go ref: accounts.go — act.Expires > tn ⇒ checkActivation returns true (not expired).
|
||||
var account = new Account("test");
|
||||
account.RegisterActivation("svc.valid", ValidClaim("svc.valid"));
|
||||
|
||||
var result = account.CheckActivationExpiry("svc.valid");
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsExpired.ShouldBeFalse();
|
||||
result.ExpiresAt.ShouldNotBeNull();
|
||||
result.TimeToExpiry.ShouldNotBeNull();
|
||||
result.TimeToExpiry!.Value.ShouldBeGreaterThan(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckActivationExpiry_Expired_ReturnsExpired()
|
||||
{
|
||||
// Go ref: accounts.go — act.Expires <= tn ⇒ checkActivation returns false (expired).
|
||||
var account = new Account("test");
|
||||
account.RegisterActivation("svc.expired", ExpiredClaim("svc.expired"));
|
||||
|
||||
var result = account.CheckActivationExpiry("svc.expired");
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsExpired.ShouldBeTrue();
|
||||
result.ExpiresAt.ShouldNotBeNull();
|
||||
result.TimeToExpiry.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckActivationExpiry_NotFound()
|
||||
{
|
||||
// Go ref: accounts.go — checkActivation returns false when claim is nil/empty token.
|
||||
var account = new Account("test");
|
||||
|
||||
var result = account.CheckActivationExpiry("svc.unknown");
|
||||
|
||||
result.Found.ShouldBeFalse();
|
||||
result.IsExpired.ShouldBeFalse();
|
||||
result.ExpiresAt.ShouldBeNull();
|
||||
result.TimeToExpiry.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IsActivationExpired
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void IsActivationExpired_Valid_ReturnsFalse()
|
||||
{
|
||||
// Go ref: accounts.go — act.Expires > tn ⇒ not expired.
|
||||
var account = new Account("test");
|
||||
account.RegisterActivation("svc.ok", ValidClaim("svc.ok"));
|
||||
|
||||
account.IsActivationExpired("svc.ok").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActivationExpired_Expired_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go — act.Expires <= tn ⇒ expired, activationExpired fires.
|
||||
var account = new Account("test");
|
||||
account.RegisterActivation("svc.past", ExpiredClaim("svc.past"));
|
||||
|
||||
account.IsActivationExpired("svc.past").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetExpiredActivations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetExpiredActivations_ReturnsOnlyExpired()
|
||||
{
|
||||
// Go ref: accounts.go — activationExpired is called only for expired claims.
|
||||
var account = new Account("test");
|
||||
account.RegisterActivation("svc.a", ValidClaim("svc.a"));
|
||||
account.RegisterActivation("svc.b", ExpiredClaim("svc.b"));
|
||||
account.RegisterActivation("svc.c", ValidClaim("svc.c"));
|
||||
account.RegisterActivation("svc.d", ExpiredClaim("svc.d"));
|
||||
|
||||
var expired = account.GetExpiredActivations();
|
||||
|
||||
expired.Count.ShouldBe(2);
|
||||
expired.ShouldContain("svc.b");
|
||||
expired.ShouldContain("svc.d");
|
||||
expired.ShouldNotContain("svc.a");
|
||||
expired.ShouldNotContain("svc.c");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RemoveExpiredActivations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RemoveExpiredActivations_RemovesAndReturnsCount()
|
||||
{
|
||||
// Go ref: accounts.go — activationExpired removes the import when activation expires.
|
||||
var account = new Account("test");
|
||||
account.RegisterActivation("svc.live", ValidClaim("svc.live"));
|
||||
account.RegisterActivation("svc.gone1", ExpiredClaim("svc.gone1"));
|
||||
account.RegisterActivation("svc.gone2", ExpiredClaim("svc.gone2"));
|
||||
|
||||
var removed = account.RemoveExpiredActivations();
|
||||
|
||||
removed.ShouldBe(2);
|
||||
|
||||
// The expired ones should no longer be found.
|
||||
account.CheckActivationExpiry("svc.gone1").Found.ShouldBeFalse();
|
||||
account.CheckActivationExpiry("svc.gone2").Found.ShouldBeFalse();
|
||||
|
||||
// The live one should still be registered.
|
||||
account.CheckActivationExpiry("svc.live").Found.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActiveActivationCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ActiveActivationCount_ExcludesExpired()
|
||||
{
|
||||
// Go ref: accounts.go — only non-expired activations are considered active.
|
||||
var account = new Account("test");
|
||||
account.RegisterActivation("svc.1", ValidClaim("svc.1"));
|
||||
account.RegisterActivation("svc.2", ValidClaim("svc.2"));
|
||||
account.RegisterActivation("svc.3", ExpiredClaim("svc.3"));
|
||||
|
||||
account.ActiveActivationCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActivationClaim.TimeToExpiry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ActivationClaim_TimeToExpiry_Zero_WhenExpired()
|
||||
{
|
||||
// Go ref: accounts.go — expired activation has no remaining time.
|
||||
var claim = ExpiredClaim("svc.expired");
|
||||
|
||||
claim.IsExpired.ShouldBeTrue();
|
||||
claim.TimeToExpiry.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
}
|
||||
1533
tests/NATS.Server.Auth.Tests/Auth/AuthCalloutGoParityTests.cs
Normal file
1533
tests/NATS.Server.Auth.Tests/Auth/AuthCalloutGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthExtensionParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Auth_service_uses_proxy_auth_extension_when_enabled()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions
|
||||
{
|
||||
ProxyAuth = new ProxyAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
UsernamePrefix = "proxy:",
|
||||
},
|
||||
});
|
||||
|
||||
service.IsAuthRequired.ShouldBeTrue();
|
||||
var result = service.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "proxy:alice" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("alice");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AuthModelAndCalloutConstantsParityTests
|
||||
{
|
||||
[Fact]
|
||||
public void NkeyUser_exposes_parity_fields()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var nkeyUser = new NKeyUser
|
||||
{
|
||||
Nkey = "UABC",
|
||||
Issued = now,
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD", "WEBSOCKET" },
|
||||
ProxyRequired = true,
|
||||
};
|
||||
|
||||
nkeyUser.Issued.ShouldBe(now);
|
||||
nkeyUser.ProxyRequired.ShouldBeTrue();
|
||||
nkeyUser.AllowedConnectionTypes.ShouldContain("STANDARD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_exposes_parity_fields()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Username = "alice",
|
||||
Password = "secret",
|
||||
AllowedConnectionTypes = new HashSet<string> { "STANDARD" },
|
||||
ProxyRequired = false,
|
||||
};
|
||||
|
||||
user.ProxyRequired.ShouldBeFalse();
|
||||
user.AllowedConnectionTypes.ShouldContain("STANDARD");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void External_auth_callout_constants_match_go_subjects_and_header()
|
||||
{
|
||||
ExternalAuthCalloutAuthenticator.AuthCalloutSubject.ShouldBe("$SYS.REQ.USER.AUTH");
|
||||
ExternalAuthCalloutAuthenticator.AuthRequestSubject.ShouldBe("nats-authorization-request");
|
||||
ExternalAuthCalloutAuthenticator.AuthRequestXKeyHeader.ShouldBe("Nats-Server-Xkey");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class AuthServiceParityBatch4Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_assigns_global_account_to_orphan_users()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions
|
||||
{
|
||||
Users = [new User { Username = "alice", Password = "secret" }],
|
||||
});
|
||||
|
||||
var result = service.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.AccountName.ShouldBe(Account.GlobalAccountName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_assigns_global_account_to_orphan_nkeys()
|
||||
{
|
||||
using var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var pub = kp.GetPublicKey();
|
||||
var nonce = "test-nonce"u8.ToArray();
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
|
||||
var service = AuthService.Build(new NatsOptions
|
||||
{
|
||||
NKeys = [new NKeyUser { Nkey = pub }],
|
||||
});
|
||||
|
||||
var result = service.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions
|
||||
{
|
||||
Nkey = pub,
|
||||
Sig = Convert.ToBase64String(sig),
|
||||
},
|
||||
Nonce = nonce,
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.AccountName.ShouldBe(Account.GlobalAccountName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_validates_response_permissions_defaults_and_publish_allow()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions
|
||||
{
|
||||
Users =
|
||||
[
|
||||
new User
|
||||
{
|
||||
Username = "alice",
|
||||
Password = "secret",
|
||||
Permissions = new Permissions
|
||||
{
|
||||
Response = new ResponsePermission { MaxMsgs = 0, Expires = TimeSpan.Zero },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
var result = service.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Permissions.ShouldNotBeNull();
|
||||
result.Permissions.Response.ShouldNotBeNull();
|
||||
result.Permissions.Response.MaxMsgs.ShouldBe(NatsProtocol.DefaultAllowResponseMaxMsgs);
|
||||
result.Permissions.Response.Expires.ShouldBe(NatsProtocol.DefaultAllowResponseExpiration);
|
||||
result.Permissions.Publish.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ExternalAuthCalloutTests
|
||||
{
|
||||
[Fact]
|
||||
public void External_callout_authenticator_can_allow_and_deny_with_timeout_and_reason_mapping()
|
||||
{
|
||||
var authenticator = new ExternalAuthCalloutAuthenticator(
|
||||
new FakeExternalAuthClient(),
|
||||
TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var allowed = authenticator.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "u", Password = "p" },
|
||||
Nonce = [],
|
||||
});
|
||||
allowed.ShouldNotBeNull();
|
||||
allowed.Identity.ShouldBe("u");
|
||||
|
||||
var denied = authenticator.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "u", Password = "bad" },
|
||||
Nonce = [],
|
||||
});
|
||||
denied.ShouldBeNull();
|
||||
|
||||
var timeout = new ExternalAuthCalloutAuthenticator(
|
||||
new SlowExternalAuthClient(TimeSpan.FromMilliseconds(200)),
|
||||
TimeSpan.FromMilliseconds(30));
|
||||
timeout.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "u", Password = "p" },
|
||||
Nonce = [],
|
||||
}).ShouldBeNull();
|
||||
}
|
||||
|
||||
private sealed class FakeExternalAuthClient : IExternalAuthClient
|
||||
{
|
||||
public Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request is { Username: "u", Password: "p" })
|
||||
return Task.FromResult(new ExternalAuthDecision(true, "u", "A"));
|
||||
return Task.FromResult(new ExternalAuthDecision(false, Reason: "denied"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SlowExternalAuthClient(TimeSpan delay) : IExternalAuthClient
|
||||
{
|
||||
public async Task<ExternalAuthDecision> AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await using var reg = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||
using var timer = new Timer(_ => tcs.TrySetResult(true), null, delay, Timeout.InfiniteTimeSpan);
|
||||
await tcs.Task;
|
||||
return new ExternalAuthDecision(true, "slow");
|
||||
}
|
||||
}
|
||||
}
|
||||
170
tests/NATS.Server.Auth.Tests/Auth/ImportShadowingTests.cs
Normal file
170
tests/NATS.Server.Auth.Tests/Auth/ImportShadowingTests.cs
Normal file
@@ -0,0 +1,170 @@
|
||||
// Tests for service import shadowing detection.
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ImportShadowingTests
|
||||
{
|
||||
private static Account CreateAccount(string name) => new(name);
|
||||
|
||||
private static Subscription MakeSub(string subject) =>
|
||||
new() { Subject = subject, Sid = subject };
|
||||
|
||||
/// <summary>
|
||||
/// Adds a service import entry directly to the account's import map (bypassing
|
||||
/// export/cycle checks) so that shadowing tests can exercise the import map iteration.
|
||||
/// </summary>
|
||||
private static void RegisterServiceImport(Account account, string fromSubject)
|
||||
{
|
||||
var dest = CreateAccount("Dest");
|
||||
var si = new ServiceImport
|
||||
{
|
||||
DestinationAccount = dest,
|
||||
From = fromSubject,
|
||||
To = fromSubject,
|
||||
};
|
||||
account.Imports.AddServiceImport(si);
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void ServiceImportShadowed_NoLocalSubs_ReturnsFalse()
|
||||
{
|
||||
var account = CreateAccount("A");
|
||||
|
||||
var result = account.ServiceImportShadowed("orders.create");
|
||||
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void ServiceImportShadowed_ExactMatch_ReturnsTrue()
|
||||
{
|
||||
var account = CreateAccount("A");
|
||||
account.SubList.Insert(MakeSub("orders.create"));
|
||||
|
||||
var result = account.ServiceImportShadowed("orders.create");
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void ServiceImportShadowed_WildcardMatch_ReturnsTrue()
|
||||
{
|
||||
// Local subscription "orders.*" shadows import on "orders.create"
|
||||
var account = CreateAccount("A");
|
||||
account.SubList.Insert(MakeSub("orders.*"));
|
||||
|
||||
var result = account.ServiceImportShadowed("orders.create");
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void ServiceImportShadowed_GtWildcard_ReturnsTrue()
|
||||
{
|
||||
// Local subscription "orders.>" shadows import on "orders.create.new"
|
||||
var account = CreateAccount("A");
|
||||
account.SubList.Insert(MakeSub("orders.>"));
|
||||
|
||||
var result = account.ServiceImportShadowed("orders.create.new");
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void ServiceImportShadowed_NoMatch_ReturnsFalse()
|
||||
{
|
||||
// Local subscription "users.*" does NOT shadow import on "orders.create"
|
||||
var account = CreateAccount("A");
|
||||
account.SubList.Insert(MakeSub("users.*"));
|
||||
|
||||
var result = account.ServiceImportShadowed("orders.create");
|
||||
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void GetShadowedServiceImports_ReturnsOnlyShadowed()
|
||||
{
|
||||
var account = CreateAccount("A");
|
||||
|
||||
// Register two service imports
|
||||
RegisterServiceImport(account, "orders.create");
|
||||
RegisterServiceImport(account, "users.profile");
|
||||
|
||||
// Only add a local sub that shadows "orders.create"
|
||||
account.SubList.Insert(MakeSub("orders.create"));
|
||||
|
||||
var shadowed = account.GetShadowedServiceImports();
|
||||
|
||||
shadowed.Count.ShouldBe(1);
|
||||
shadowed.ShouldContain("orders.create");
|
||||
shadowed.ShouldNotContain("users.profile");
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void HasShadowedImports_True_WhenShadowed()
|
||||
{
|
||||
var account = CreateAccount("A");
|
||||
|
||||
RegisterServiceImport(account, "orders.create");
|
||||
account.SubList.Insert(MakeSub("orders.create"));
|
||||
|
||||
account.HasShadowedImports.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void HasShadowedImports_False_WhenNone()
|
||||
{
|
||||
var account = CreateAccount("A");
|
||||
|
||||
RegisterServiceImport(account, "orders.create");
|
||||
// No local subs — nothing shadows the import
|
||||
|
||||
account.HasShadowedImports.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void CheckServiceImportShadowing_ReturnsShadowingSubscriptions()
|
||||
{
|
||||
var account = CreateAccount("A");
|
||||
account.SubList.Insert(MakeSub("orders.*"));
|
||||
account.SubList.Insert(MakeSub("orders.>"));
|
||||
|
||||
var result = account.CheckServiceImportShadowing("orders.create");
|
||||
|
||||
result.IsShadowed.ShouldBeTrue();
|
||||
result.ImportSubject.ShouldBe("orders.create");
|
||||
result.ShadowingSubscriptions.Count.ShouldBeGreaterThan(0);
|
||||
// Both wildcard subs match "orders.create"
|
||||
result.ShadowingSubscriptions.ShouldContain("orders.*");
|
||||
result.ShadowingSubscriptions.ShouldContain("orders.>");
|
||||
}
|
||||
|
||||
// Go reference: accounts.go serviceImportShadowed (~line 2015).
|
||||
[Fact]
|
||||
public void CheckServiceImportShadowing_NotShadowed()
|
||||
{
|
||||
var account = CreateAccount("A");
|
||||
account.SubList.Insert(MakeSub("users.*"));
|
||||
|
||||
var result = account.CheckServiceImportShadowing("orders.create");
|
||||
|
||||
result.IsShadowed.ShouldBeFalse();
|
||||
result.ImportSubject.ShouldBe("orders.create");
|
||||
result.ShadowingSubscriptions.Count.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
1770
tests/NATS.Server.Auth.Tests/Auth/JwtGoParityTests.cs
Normal file
1770
tests/NATS.Server.Auth.Tests/Auth/JwtGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
165
tests/NATS.Server.Auth.Tests/Auth/NKeyRevocationTests.cs
Normal file
165
tests/NATS.Server.Auth.Tests/Auth/NKeyRevocationTests.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
// Tests for user NKey revocation on Account.
|
||||
// Go reference: accounts_test.go TestJWTUserRevocation, checkUserRevoked (~line 3202),
|
||||
// isRevoked with jwt.All global key (~line 2929).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class NKeyRevocationTests
|
||||
{
|
||||
// ── 1 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void RevokeUser_AddsToRevokedList()
|
||||
{
|
||||
var account = new Account("A");
|
||||
|
||||
account.RevokeUser("UNKEY1", 100L);
|
||||
|
||||
account.RevokedUserCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── 2 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void IsUserRevoked_Revoked_ReturnsTrue()
|
||||
{
|
||||
// A JWT issued at t=50 revoked when the revocation timestamp is 100
|
||||
// means issuedAt (50) <= revokedAt (100) → revoked.
|
||||
// Go reference: accounts.go isRevoked — t < issuedAt ⇒ NOT revoked (inverted).
|
||||
var account = new Account("A");
|
||||
account.RevokeUser("UNKEY1", 100L);
|
||||
|
||||
account.IsUserRevoked("UNKEY1", 50L).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ── 3 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void IsUserRevoked_NotRevoked_ReturnsFalse()
|
||||
{
|
||||
// A JWT issued at t=200 with revocation timestamp 100 means
|
||||
// issuedAt (200) > revokedAt (100) → NOT revoked.
|
||||
var account = new Account("A");
|
||||
account.RevokeUser("UNKEY1", 100L);
|
||||
|
||||
account.IsUserRevoked("UNKEY1", 200L).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── 4 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void RevokedUserCount_MatchesRevocations()
|
||||
{
|
||||
var account = new Account("A");
|
||||
|
||||
account.RevokedUserCount.ShouldBe(0);
|
||||
|
||||
account.RevokeUser("UNKEY1", 1L);
|
||||
account.RevokedUserCount.ShouldBe(1);
|
||||
|
||||
account.RevokeUser("UNKEY2", 2L);
|
||||
account.RevokedUserCount.ShouldBe(2);
|
||||
|
||||
// Revoking the same key again does not increase count.
|
||||
account.RevokeUser("UNKEY1", 99L);
|
||||
account.RevokedUserCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ── 5 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void GetRevokedUsers_ReturnsAllKeys()
|
||||
{
|
||||
var account = new Account("A");
|
||||
account.RevokeUser("UNKEY1", 1L);
|
||||
account.RevokeUser("UNKEY2", 2L);
|
||||
account.RevokeUser("UNKEY3", 3L);
|
||||
|
||||
var keys = account.GetRevokedUsers();
|
||||
|
||||
keys.Count.ShouldBe(3);
|
||||
keys.ShouldContain("UNKEY1");
|
||||
keys.ShouldContain("UNKEY2");
|
||||
keys.ShouldContain("UNKEY3");
|
||||
}
|
||||
|
||||
// ── 6 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void UnrevokeUser_RemovesRevocation()
|
||||
{
|
||||
var account = new Account("A");
|
||||
account.RevokeUser("UNKEY1", 100L);
|
||||
account.RevokedUserCount.ShouldBe(1);
|
||||
|
||||
var removed = account.UnrevokeUser("UNKEY1");
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
account.RevokedUserCount.ShouldBe(0);
|
||||
account.IsUserRevoked("UNKEY1", 50L).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── 7 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void UnrevokeUser_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var account = new Account("A");
|
||||
|
||||
var removed = account.UnrevokeUser("DOES_NOT_EXIST");
|
||||
|
||||
removed.ShouldBeFalse();
|
||||
account.RevokedUserCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ── 8 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void ClearAllRevocations_EmptiesList()
|
||||
{
|
||||
var account = new Account("A");
|
||||
account.RevokeUser("UNKEY1", 1L);
|
||||
account.RevokeUser("UNKEY2", 2L);
|
||||
account.RevokeAllUsers(999L);
|
||||
account.RevokedUserCount.ShouldBe(3);
|
||||
|
||||
account.ClearAllRevocations();
|
||||
|
||||
account.RevokedUserCount.ShouldBe(0);
|
||||
account.GetRevokedUsers().ShouldBeEmpty();
|
||||
account.IsGlobalRevocation().ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── 9 ──────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void RevokeAllUsers_SetsGlobalRevocation()
|
||||
{
|
||||
// Go reference: accounts.go — Revocations[jwt.All] used in isRevoked (~line 2934).
|
||||
// The "*" key causes any user whose issuedAt <= timestamp to be revoked.
|
||||
var account = new Account("A");
|
||||
|
||||
account.RevokeAllUsers(500L);
|
||||
|
||||
account.IsGlobalRevocation().ShouldBeTrue();
|
||||
// User issued at 500 is revoked (≤ 500).
|
||||
account.IsUserRevoked("ANY_USER", 500L).ShouldBeTrue();
|
||||
// User issued at 499 is also revoked.
|
||||
account.IsUserRevoked("ANY_USER", 499L).ShouldBeTrue();
|
||||
// User issued at 501 is NOT revoked (> 500).
|
||||
account.IsUserRevoked("ANY_USER", 501L).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── 10 ─────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public void GetRevocationInfo_ReturnsComplete()
|
||||
{
|
||||
var account = new Account("A");
|
||||
account.RevokeUser("UNKEY1", 10L);
|
||||
account.RevokeUser("UNKEY2", 20L);
|
||||
account.RevokeAllUsers(999L);
|
||||
|
||||
var info = account.GetRevocationInfo();
|
||||
|
||||
// Two per-user keys + one global "*" key = 3 total.
|
||||
info.RevokedCount.ShouldBe(3);
|
||||
info.HasGlobalRevocation.ShouldBeTrue();
|
||||
info.RevokedNKeys.Count.ShouldBe(3);
|
||||
info.RevokedNKeys.ShouldContain("UNKEY1");
|
||||
info.RevokedNKeys.ShouldContain("UNKEY2");
|
||||
info.RevokedNKeys.ShouldContain("*");
|
||||
}
|
||||
}
|
||||
28
tests/NATS.Server.Auth.Tests/Auth/ProxyAuthTests.cs
Normal file
28
tests/NATS.Server.Auth.Tests/Auth/ProxyAuthTests.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ProxyAuthTests
|
||||
{
|
||||
[Fact]
|
||||
public void Proxy_authenticator_maps_prefixed_username_to_identity()
|
||||
{
|
||||
var authenticator = new ProxyAuthenticator(new ProxyAuthOptions
|
||||
{
|
||||
Enabled = true,
|
||||
UsernamePrefix = "proxy:",
|
||||
Account = "A",
|
||||
});
|
||||
|
||||
var result = authenticator.Authenticate(new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "proxy:bob" },
|
||||
Nonce = [],
|
||||
});
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("bob");
|
||||
result.AccountName.ShouldBe("A");
|
||||
}
|
||||
}
|
||||
137
tests/NATS.Server.Auth.Tests/Auth/ResponseThresholdTests.cs
Normal file
137
tests/NATS.Server.Auth.Tests/Auth/ResponseThresholdTests.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
// Tests for Account.SetServiceResponseThreshold / GetServiceResponseThreshold /
|
||||
// IsServiceResponseOverdue / CheckServiceResponse.
|
||||
// Go reference: server/accounts.go — SetServiceExportResponseThreshold (~line 2522),
|
||||
// ServiceExportResponseThreshold (~line 2510).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ResponseThresholdTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetServiceResponseThreshold / GetServiceResponseThreshold
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void SetServiceResponseThreshold_StoresThreshold()
|
||||
{
|
||||
// Go ref: accounts.go SetServiceExportResponseThreshold (~line 2522)
|
||||
var account = new Account("test");
|
||||
account.SetServiceResponseThreshold("svc.foo", TimeSpan.FromSeconds(5));
|
||||
|
||||
account.ServiceResponseThresholds.ContainsKey("svc.foo").ShouldBeTrue();
|
||||
account.ServiceResponseThresholds["svc.foo"].ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceResponseThreshold_ReturnsStored()
|
||||
{
|
||||
// Go ref: accounts.go ServiceExportResponseThreshold (~line 2510)
|
||||
var account = new Account("test");
|
||||
account.SetServiceResponseThreshold("svc.bar", TimeSpan.FromMilliseconds(200));
|
||||
|
||||
account.GetServiceResponseThreshold("svc.bar").ShouldBe(TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetServiceResponseThreshold_NotSet_ReturnsNull()
|
||||
{
|
||||
// Go ref: accounts.go ServiceExportResponseThreshold — returns error when export not found
|
||||
var account = new Account("test");
|
||||
|
||||
account.GetServiceResponseThreshold("svc.unknown").ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IsServiceResponseOverdue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void IsServiceResponseOverdue_WithinThreshold_ReturnsFalse()
|
||||
{
|
||||
// Go ref: accounts.go respThresh check — elapsed < threshold ⇒ not overdue
|
||||
var account = new Account("test");
|
||||
account.SetServiceResponseThreshold("svc.a", TimeSpan.FromSeconds(10));
|
||||
|
||||
account.IsServiceResponseOverdue("svc.a", TimeSpan.FromSeconds(9)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsServiceResponseOverdue_ExceedsThreshold_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go respThresh check — elapsed > threshold ⇒ overdue
|
||||
var account = new Account("test");
|
||||
account.SetServiceResponseThreshold("svc.b", TimeSpan.FromSeconds(1));
|
||||
|
||||
account.IsServiceResponseOverdue("svc.b", TimeSpan.FromSeconds(2)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsServiceResponseOverdue_NoThreshold_ReturnsFalse()
|
||||
{
|
||||
// Go ref: accounts.go — when no respThresh is set the timer never fires (never overdue)
|
||||
var account = new Account("test");
|
||||
|
||||
account.IsServiceResponseOverdue("svc.unregistered", TimeSpan.FromHours(1)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetServiceResponseThreshold_OverwritesPrevious()
|
||||
{
|
||||
// Go ref: accounts.go SetServiceExportResponseThreshold — se.respThresh = maxTime overwrites
|
||||
var account = new Account("test");
|
||||
account.SetServiceResponseThreshold("svc.c", TimeSpan.FromSeconds(5));
|
||||
account.SetServiceResponseThreshold("svc.c", TimeSpan.FromSeconds(30));
|
||||
|
||||
account.GetServiceResponseThreshold("svc.c").ShouldBe(TimeSpan.FromSeconds(30));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckServiceResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CheckServiceResponse_Found_NotOverdue()
|
||||
{
|
||||
// Go ref: accounts.go ServiceExportResponseThreshold + respThresh timer — within window
|
||||
var account = new Account("test");
|
||||
account.SetServiceResponseThreshold("svc.d", TimeSpan.FromSeconds(10));
|
||||
|
||||
var result = account.CheckServiceResponse("svc.d", TimeSpan.FromSeconds(5));
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsOverdue.ShouldBeFalse();
|
||||
result.Threshold.ShouldBe(TimeSpan.FromSeconds(10));
|
||||
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckServiceResponse_Found_Overdue()
|
||||
{
|
||||
// Go ref: accounts.go respThresh timer fires — elapsed exceeded threshold
|
||||
var account = new Account("test");
|
||||
account.SetServiceResponseThreshold("svc.e", TimeSpan.FromSeconds(2));
|
||||
|
||||
var result = account.CheckServiceResponse("svc.e", TimeSpan.FromSeconds(5));
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.IsOverdue.ShouldBeTrue();
|
||||
result.Threshold.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckServiceResponse_NotFound()
|
||||
{
|
||||
// Go ref: accounts.go — no export defined, returns error; here Found=false
|
||||
var account = new Account("test");
|
||||
|
||||
var result = account.CheckServiceResponse("svc.none", TimeSpan.FromSeconds(1));
|
||||
|
||||
result.Found.ShouldBeFalse();
|
||||
result.IsOverdue.ShouldBeFalse();
|
||||
result.Threshold.ShouldBeNull();
|
||||
result.Elapsed.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
}
|
||||
174
tests/NATS.Server.Auth.Tests/Auth/ReverseResponseMapTests.cs
Normal file
174
tests/NATS.Server.Auth.Tests/Auth/ReverseResponseMapTests.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
// Tests for Account.AddReverseRespMapEntry / CheckForReverseEntries and related helpers.
|
||||
// Go reference: server/accounts.go — addRespMapEntry (~line 2800), checkForReverseEntries (~line 2810).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ReverseResponseMapTests
|
||||
{
|
||||
// ---------------------------------------------------------------------------
|
||||
// AddReverseRespMapEntry / CheckForReverseEntries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddReverseRespMapEntry_StoresEntry()
|
||||
{
|
||||
// Go ref: accounts.go addRespMapEntry — stores respMapEntry keyed by rewritten reply
|
||||
var account = new Account("A");
|
||||
account.AddReverseRespMapEntry("_R_.abc", "B", "reply.1");
|
||||
|
||||
account.ReverseResponseMapCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckForReverseEntries_Found_ReturnsEntry()
|
||||
{
|
||||
// Go ref: accounts.go checkForReverseEntries — returns entry when key exists
|
||||
var account = new Account("A");
|
||||
account.AddReverseRespMapEntry("_R_.xyz", "origin-account", "original.reply.subject");
|
||||
|
||||
var entry = account.CheckForReverseEntries("_R_.xyz");
|
||||
|
||||
entry.ShouldNotBeNull();
|
||||
entry.ReplySubject.ShouldBe("_R_.xyz");
|
||||
entry.OriginAccount.ShouldBe("origin-account");
|
||||
entry.OriginalReply.ShouldBe("original.reply.subject");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckForReverseEntries_NotFound_ReturnsNull()
|
||||
{
|
||||
// Go ref: accounts.go checkForReverseEntries — returns nil when key absent
|
||||
var account = new Account("A");
|
||||
|
||||
var entry = account.CheckForReverseEntries("_R_.nonexistent");
|
||||
|
||||
entry.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RemoveReverseRespMapEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void RemoveReverseRespMapEntry_Found_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go — reverse map cleanup after response is routed
|
||||
var account = new Account("A");
|
||||
account.AddReverseRespMapEntry("_R_.del", "B", "orig.reply");
|
||||
|
||||
var removed = account.RemoveReverseRespMapEntry("_R_.del");
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
account.ReverseResponseMapCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveReverseRespMapEntry_NotFound_ReturnsFalse()
|
||||
{
|
||||
// Go ref: accounts.go — removing an absent entry is a no-op
|
||||
var account = new Account("A");
|
||||
|
||||
var removed = account.RemoveReverseRespMapEntry("_R_.missing");
|
||||
|
||||
removed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReverseResponseMapCount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ReverseResponseMapCount_MatchesEntries()
|
||||
{
|
||||
// Go ref: accounts.go — map length reflects outstanding response mappings
|
||||
var account = new Account("A");
|
||||
|
||||
account.ReverseResponseMapCount.ShouldBe(0);
|
||||
|
||||
account.AddReverseRespMapEntry("_R_.1", "B", "r1");
|
||||
account.AddReverseRespMapEntry("_R_.2", "C", "r2");
|
||||
account.AddReverseRespMapEntry("_R_.3", "D", "r3");
|
||||
|
||||
account.ReverseResponseMapCount.ShouldBe(3);
|
||||
|
||||
account.RemoveReverseRespMapEntry("_R_.2");
|
||||
|
||||
account.ReverseResponseMapCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClearReverseResponseMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ClearReverseResponseMap_EmptiesAll()
|
||||
{
|
||||
// Go ref: accounts.go — clearing map after bulk expiry / account teardown
|
||||
var account = new Account("A");
|
||||
account.AddReverseRespMapEntry("_R_.a", "B", "ra");
|
||||
account.AddReverseRespMapEntry("_R_.b", "C", "rb");
|
||||
|
||||
account.ClearReverseResponseMap();
|
||||
|
||||
account.ReverseResponseMapCount.ShouldBe(0);
|
||||
account.CheckForReverseEntries("_R_.a").ShouldBeNull();
|
||||
account.CheckForReverseEntries("_R_.b").ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetReverseResponseMapKeys
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetReverseResponseMapKeys_ReturnsAllKeys()
|
||||
{
|
||||
// Go ref: accounts.go — iterating active respMapEntry keys for diagnostics
|
||||
var account = new Account("A");
|
||||
account.AddReverseRespMapEntry("_R_.k1", "B", "r1");
|
||||
account.AddReverseRespMapEntry("_R_.k2", "C", "r2");
|
||||
|
||||
var keys = account.GetReverseResponseMapKeys();
|
||||
|
||||
keys.Count.ShouldBe(2);
|
||||
keys.ShouldContain("_R_.k1");
|
||||
keys.ShouldContain("_R_.k2");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overwrite and CreatedAt preservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void AddReverseRespMapEntry_OverwritesPrevious()
|
||||
{
|
||||
// Go ref: accounts.go addRespMapEntry — map assignment overwrites existing key
|
||||
var account = new Account("A");
|
||||
account.AddReverseRespMapEntry("_R_.ov", "B", "first.reply");
|
||||
account.AddReverseRespMapEntry("_R_.ov", "C", "second.reply");
|
||||
|
||||
var entry = account.CheckForReverseEntries("_R_.ov");
|
||||
|
||||
entry.ShouldNotBeNull();
|
||||
entry.OriginAccount.ShouldBe("C");
|
||||
entry.OriginalReply.ShouldBe("second.reply");
|
||||
account.ReverseResponseMapCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReverseRespMapEntry_PreservesCreatedAt()
|
||||
{
|
||||
// Go ref: accounts.go respMapEntry — timestamp recorded at map insertion time
|
||||
var before = DateTime.UtcNow;
|
||||
var account = new Account("A");
|
||||
account.AddReverseRespMapEntry("_R_.ts", "B", "ts.reply");
|
||||
var after = DateTime.UtcNow;
|
||||
|
||||
var entry = account.CheckForReverseEntries("_R_.ts");
|
||||
|
||||
entry.ShouldNotBeNull();
|
||||
entry.CreatedAt.ShouldBeGreaterThanOrEqualTo(before);
|
||||
entry.CreatedAt.ShouldBeLessThanOrEqualTo(after);
|
||||
}
|
||||
}
|
||||
168
tests/NATS.Server.Auth.Tests/Auth/ServiceLatencyTrackerTests.cs
Normal file
168
tests/NATS.Server.Auth.Tests/Auth/ServiceLatencyTrackerTests.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
// Tests for service export latency tracker with p50/p90/p99 percentile histogram.
|
||||
// Go reference: accounts_test.go TestServiceLatency, serviceExportLatencyStats.
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class ServiceLatencyTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordLatency_IncrementsTotalRequests()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
tracker.RecordLatency(10.0);
|
||||
tracker.RecordLatency(20.0);
|
||||
tracker.RecordLatency(30.0);
|
||||
|
||||
tracker.TotalRequests.ShouldBe(3L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetP50_ReturnsMedian()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
foreach (var v in new double[] { 1, 2, 3, 4, 5 })
|
||||
tracker.RecordLatency(v);
|
||||
|
||||
// Sorted: [1, 2, 3, 4, 5], index = (int)(0.50 * 4) = 2 → value 3
|
||||
tracker.GetP50().ShouldBe(3.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetP90_ReturnsHighPercentile()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
for (var i = 1; i <= 100; i++)
|
||||
tracker.RecordLatency(i);
|
||||
|
||||
// Sorted [1..100], index = (int)(0.90 * 99) = (int)89.1 = 89 → value 90
|
||||
tracker.GetP90().ShouldBe(90.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetP99_ReturnsTopPercentile()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
for (var i = 1; i <= 100; i++)
|
||||
tracker.RecordLatency(i);
|
||||
|
||||
// Sorted [1..100], index = (int)(0.99 * 99) = (int)98.01 = 98 → value 99
|
||||
tracker.GetP99().ShouldBe(99.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AverageLatencyMs_CalculatesCorrectly()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
tracker.RecordLatency(10.0);
|
||||
tracker.RecordLatency(20.0);
|
||||
tracker.RecordLatency(30.0);
|
||||
|
||||
tracker.AverageLatencyMs.ShouldBe(20.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinLatencyMs_ReturnsMinimum()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
tracker.RecordLatency(15.0);
|
||||
tracker.RecordLatency(5.0);
|
||||
tracker.RecordLatency(10.0);
|
||||
|
||||
tracker.MinLatencyMs.ShouldBe(5.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxLatencyMs_ReturnsMaximum()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
tracker.RecordLatency(5.0);
|
||||
tracker.RecordLatency(15.0);
|
||||
tracker.RecordLatency(10.0);
|
||||
|
||||
tracker.MaxLatencyMs.ShouldBe(15.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsSamples()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
tracker.RecordLatency(10.0);
|
||||
tracker.RecordLatency(20.0);
|
||||
tracker.SampleCount.ShouldBe(2);
|
||||
tracker.TotalRequests.ShouldBe(2L);
|
||||
|
||||
tracker.Reset();
|
||||
|
||||
tracker.SampleCount.ShouldBe(0);
|
||||
tracker.TotalRequests.ShouldBe(0L);
|
||||
tracker.AverageLatencyMs.ShouldBe(0.0);
|
||||
tracker.MinLatencyMs.ShouldBe(0.0);
|
||||
tracker.MaxLatencyMs.ShouldBe(0.0);
|
||||
tracker.GetP50().ShouldBe(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSnapshot_ReturnsImmutableSnapshot()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker();
|
||||
|
||||
tracker.RecordLatency(10.0);
|
||||
tracker.RecordLatency(20.0);
|
||||
tracker.RecordLatency(30.0);
|
||||
|
||||
var snapshot = tracker.GetSnapshot();
|
||||
|
||||
snapshot.TotalRequests.ShouldBe(3L);
|
||||
snapshot.SampleCount.ShouldBe(3);
|
||||
snapshot.AverageMs.ShouldBe(20.0);
|
||||
snapshot.MinMs.ShouldBe(10.0);
|
||||
snapshot.MaxMs.ShouldBe(30.0);
|
||||
// P50 of [10, 20, 30]: index = (int)(0.50 * 2) = 1 → 20
|
||||
snapshot.P50Ms.ShouldBe(20.0);
|
||||
|
||||
// Mutating tracker after snapshot does not change the snapshot
|
||||
tracker.RecordLatency(1000.0);
|
||||
snapshot.MaxMs.ShouldBe(30.0);
|
||||
snapshot.SampleCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxSamples_EvictsOldest()
|
||||
{
|
||||
var tracker = new ServiceLatencyTracker(maxSamples: 5);
|
||||
|
||||
for (var i = 1; i <= 10; i++)
|
||||
tracker.RecordLatency(i);
|
||||
|
||||
// Only the last 5 samples should remain (6, 7, 8, 9, 10)
|
||||
tracker.SampleCount.ShouldBe(5);
|
||||
// TotalRequests counts all recorded calls, not just retained ones
|
||||
tracker.TotalRequests.ShouldBe(10L);
|
||||
// Minimum of retained samples is 6
|
||||
tracker.MinLatencyMs.ShouldBe(6.0);
|
||||
// Maximum of retained samples is 10
|
||||
tracker.MaxLatencyMs.ShouldBe(10.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_RecordServiceLatency_DelegatesToTracker()
|
||||
{
|
||||
var account = new Account("test");
|
||||
|
||||
account.RecordServiceLatency(50.0);
|
||||
account.RecordServiceLatency(100.0);
|
||||
|
||||
account.LatencyTracker.TotalRequests.ShouldBe(2L);
|
||||
account.LatencyTracker.AverageLatencyMs.ShouldBe(75.0);
|
||||
}
|
||||
}
|
||||
175
tests/NATS.Server.Auth.Tests/Auth/StreamImportCycleTests.cs
Normal file
175
tests/NATS.Server.Auth.Tests/Auth/StreamImportCycleTests.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
// Tests for stream import cycle detection via DFS on Account.
|
||||
// Go reference: accounts_test.go — TestAccountStreamImportCycles (accounts.go:1627 streamImportFormsCycle).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class StreamImportCycleTests
|
||||
{
|
||||
private static Account CreateAccount(string name) => new(name);
|
||||
|
||||
/// <summary>
|
||||
/// Sets up a public stream export on <paramref name="exporter"/> for <paramref name="subject"/>
|
||||
/// and then adds a stream import on <paramref name="importer"/> from <paramref name="exporter"/>.
|
||||
/// </summary>
|
||||
private static void SetupStreamImport(Account importer, Account exporter, string subject)
|
||||
{
|
||||
exporter.AddStreamExport(subject, approved: null); // public export
|
||||
importer.AddStreamImport(exporter, subject, subject);
|
||||
}
|
||||
|
||||
// 1. No cycle when the proposed source has no imports leading back to this account.
|
||||
// A imports from B; checking whether B can import from C — no path C→A exists.
|
||||
[Fact]
|
||||
public void StreamImportFormsCycle_NoCycle_ReturnsFalse()
|
||||
{
|
||||
// Go ref: accounts.go streamImportFormsCycle
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
|
||||
SetupStreamImport(a, b, "events.>"); // A imports from B
|
||||
c.AddStreamExport("other.>", approved: null);
|
||||
|
||||
// B importing from C: does C→...→A exist? No.
|
||||
a.StreamImportFormsCycle(c).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 2. Direct cycle: A already imports from B; proposing B imports from A = cycle.
|
||||
[Fact]
|
||||
public void StreamImportFormsCycle_DirectCycle_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go streamImportFormsCycle
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
SetupStreamImport(a, b, "stream.>"); // A imports from B
|
||||
|
||||
// Now check: would A importing from B (again, or B's perspective) form a cycle?
|
||||
// We ask account B: does proposing A as source form a cycle?
|
||||
// i.e. b.StreamImportFormsCycle(a) — does a chain from A lead back to B?
|
||||
// A imports from B, so A→B, meaning following A's imports we reach B. Cycle confirmed.
|
||||
b.StreamImportFormsCycle(a).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 3. Indirect cycle: A→B→C; proposing C import from A would create C→A→B→C.
|
||||
[Fact]
|
||||
public void StreamImportFormsCycle_IndirectCycle_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go checkStreamImportsForCycles
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
|
||||
SetupStreamImport(a, b, "s.>"); // A imports from B
|
||||
SetupStreamImport(b, c, "t.>"); // B imports from C
|
||||
|
||||
// Would C importing from A form a cycle? Path: A imports from B, B imports from C → cycle.
|
||||
c.StreamImportFormsCycle(a).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 4. Self-import: A importing from A is always a cycle.
|
||||
[Fact]
|
||||
public void StreamImportFormsCycle_SelfImport_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go streamImportFormsCycle — proposedSource == this
|
||||
var a = CreateAccount("A");
|
||||
|
||||
a.StreamImportFormsCycle(a).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 5. Account with no imports at all — no cycle possible.
|
||||
[Fact]
|
||||
public void StreamImportFormsCycle_NoImports_ReturnsFalse()
|
||||
{
|
||||
// Go ref: accounts.go streamImportFormsCycle — empty imports.streams
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
// Neither account has any stream imports; proposing B as source for A is safe.
|
||||
a.StreamImportFormsCycle(b).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 6. Diamond topology: A→B, A→C, B→D, C→D — no cycle, just shared descendant.
|
||||
[Fact]
|
||||
public void StreamImportFormsCycle_DiamondNoCycle_ReturnsFalse()
|
||||
{
|
||||
// Go ref: accounts.go checkStreamImportsForCycles — visited set prevents false positives
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
var d = CreateAccount("D");
|
||||
|
||||
SetupStreamImport(a, b, "b.>"); // A imports from B
|
||||
SetupStreamImport(a, c, "c.>"); // A imports from C
|
||||
SetupStreamImport(b, d, "d1.>"); // B imports from D
|
||||
SetupStreamImport(c, d, "d2.>"); // C imports from D
|
||||
|
||||
// Proposing D import from A: does A→...→D path exist? Yes (via B and C).
|
||||
d.StreamImportFormsCycle(a).ShouldBeTrue();
|
||||
|
||||
// Proposing E (new account) import from D: D has no imports, so no cycle.
|
||||
var e = CreateAccount("E");
|
||||
e.StreamImportFormsCycle(d).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 7. GetStreamImportSources returns names of source accounts.
|
||||
[Fact]
|
||||
public void GetStreamImportSources_ReturnsSourceNames()
|
||||
{
|
||||
// Go ref: accounts.go imports.streams acc field
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
|
||||
SetupStreamImport(a, b, "x.>");
|
||||
SetupStreamImport(a, c, "y.>");
|
||||
|
||||
var sources = a.GetStreamImportSources();
|
||||
|
||||
sources.Count.ShouldBe(2);
|
||||
sources.ShouldContain("B");
|
||||
sources.ShouldContain("C");
|
||||
}
|
||||
|
||||
// 8. GetStreamImportSources returns empty list when no imports exist.
|
||||
[Fact]
|
||||
public void GetStreamImportSources_Empty_ReturnsEmpty()
|
||||
{
|
||||
// Go ref: accounts.go imports.streams — empty slice
|
||||
var a = CreateAccount("A");
|
||||
|
||||
var sources = a.GetStreamImportSources();
|
||||
|
||||
sources.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// 9. HasStreamImportFrom returns true when a matching import exists.
|
||||
[Fact]
|
||||
public void HasStreamImportFrom_True()
|
||||
{
|
||||
// Go ref: accounts.go imports.streams — acc.Name lookup
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
|
||||
SetupStreamImport(a, b, "events.>");
|
||||
|
||||
a.HasStreamImportFrom("B").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 10. HasStreamImportFrom returns false when no import from that account exists.
|
||||
[Fact]
|
||||
public void HasStreamImportFrom_False()
|
||||
{
|
||||
// Go ref: accounts.go imports.streams — acc.Name lookup miss
|
||||
var a = CreateAccount("A");
|
||||
var b = CreateAccount("B");
|
||||
var c = CreateAccount("C");
|
||||
|
||||
SetupStreamImport(a, b, "events.>");
|
||||
|
||||
a.HasStreamImportFrom("C").ShouldBeFalse();
|
||||
a.HasStreamImportFrom(c.Name).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
161
tests/NATS.Server.Auth.Tests/Auth/SubPermissionCacheTests.cs
Normal file
161
tests/NATS.Server.Auth.Tests/Auth/SubPermissionCacheTests.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
using NATS.Server.Auth;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for SUB permission caching and generation-based invalidation.
|
||||
/// Reference: Go server/client.go — subPermCache, pubPermCache, perm cache invalidation on account update.
|
||||
/// </summary>
|
||||
public sealed class SubPermissionCacheTests
|
||||
{
|
||||
// ── SUB API ───────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SetSub_and_TryGetSub_round_trips()
|
||||
{
|
||||
var cache = new PermissionLruCache();
|
||||
|
||||
cache.SetSub("foo.bar", true);
|
||||
|
||||
cache.TryGetSub("foo.bar", out var result).ShouldBeTrue();
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetSub_returns_false_for_unknown()
|
||||
{
|
||||
var cache = new PermissionLruCache();
|
||||
|
||||
cache.TryGetSub("unknown.subject", out var result).ShouldBeFalse();
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PUB_and_SUB_stored_independently()
|
||||
{
|
||||
var cache = new PermissionLruCache();
|
||||
|
||||
// Same logical subject, different PUB/SUB outcomes
|
||||
cache.Set("orders.>", false); // PUB denied
|
||||
cache.SetSub("orders.>", true); // SUB allowed
|
||||
|
||||
cache.TryGet("orders.>", out var pubAllowed).ShouldBeTrue();
|
||||
pubAllowed.ShouldBeFalse();
|
||||
|
||||
cache.TryGetSub("orders.>", out var subAllowed).ShouldBeTrue();
|
||||
subAllowed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ── Invalidation ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_clears_on_next_access()
|
||||
{
|
||||
var cache = new PermissionLruCache();
|
||||
|
||||
cache.Set("pub.subject", true);
|
||||
cache.SetSub("sub.subject", true);
|
||||
|
||||
cache.Invalidate();
|
||||
|
||||
// Both PUB and SUB lookups should miss after invalidation
|
||||
cache.TryGet("pub.subject", out _).ShouldBeFalse();
|
||||
cache.TryGetSub("sub.subject", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generation_increments_on_invalidate()
|
||||
{
|
||||
var cache = new PermissionLruCache();
|
||||
|
||||
var before = cache.Generation;
|
||||
cache.Invalidate();
|
||||
var afterOne = cache.Generation;
|
||||
cache.Invalidate();
|
||||
var afterTwo = cache.Generation;
|
||||
|
||||
afterOne.ShouldBe(before + 1);
|
||||
afterTwo.ShouldBe(before + 2);
|
||||
}
|
||||
|
||||
// ── LRU eviction ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void LRU_eviction_applies_to_SUB_entries()
|
||||
{
|
||||
// capacity = 4: fill with 4 SUB entries then add a 5th; the oldest should be evicted
|
||||
var cache = new PermissionLruCache(capacity: 4);
|
||||
|
||||
cache.SetSub("a", true);
|
||||
cache.SetSub("b", true);
|
||||
cache.SetSub("c", true);
|
||||
cache.SetSub("d", true);
|
||||
|
||||
// Touch "a" so it becomes MRU; "b" becomes LRU
|
||||
cache.TryGetSub("a", out _);
|
||||
|
||||
// Adding "e" should evict "b" (LRU)
|
||||
cache.SetSub("e", true);
|
||||
|
||||
cache.Count.ShouldBe(4);
|
||||
cache.TryGetSub("b", out _).ShouldBeFalse("b should have been evicted");
|
||||
cache.TryGetSub("e", out _).ShouldBeTrue("e was just added");
|
||||
}
|
||||
|
||||
// ── Backward compatibility ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Existing_PUB_API_still_works()
|
||||
{
|
||||
var cache = new PermissionLruCache();
|
||||
|
||||
cache.Set("pub.only", true);
|
||||
|
||||
cache.TryGet("pub.only", out var value).ShouldBeTrue();
|
||||
value.ShouldBeTrue();
|
||||
|
||||
// Overwrite with false
|
||||
cache.Set("pub.only", false);
|
||||
cache.TryGet("pub.only", out value).ShouldBeTrue();
|
||||
value.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ── Account.GenerationId ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Account_GenerationId_starts_at_zero()
|
||||
{
|
||||
var account = new Account("test");
|
||||
|
||||
account.GenerationId.ShouldBe(0L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_IncrementGeneration_increments()
|
||||
{
|
||||
var account = new Account("test");
|
||||
|
||||
account.IncrementGeneration();
|
||||
account.GenerationId.ShouldBe(1L);
|
||||
|
||||
account.IncrementGeneration();
|
||||
account.GenerationId.ShouldBe(2L);
|
||||
}
|
||||
|
||||
// ── Mixed PUB + SUB count ─────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Mixed_PUB_SUB_count_includes_both()
|
||||
{
|
||||
var cache = new PermissionLruCache();
|
||||
|
||||
cache.Set("pub.a", true);
|
||||
cache.Set("pub.b", false);
|
||||
cache.SetSub("sub.a", true);
|
||||
cache.SetSub("sub.b", false);
|
||||
|
||||
// All four entries (stored under different internal keys) contribute to Count
|
||||
cache.Count.ShouldBe(4);
|
||||
}
|
||||
}
|
||||
226
tests/NATS.Server.Auth.Tests/Auth/SystemAccountTests.cs
Normal file
226
tests/NATS.Server.Auth.Tests/Auth/SystemAccountTests.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
// Port of Go server/accounts_test.go — TestSystemAccountDefaultCreation,
|
||||
// TestSystemAccountSysSubjectRouting, TestNonSystemAccountCannotSubscribeToSys.
|
||||
// Reference: golang/nats-server/server/accounts_test.go, server.go — initSystemAccount.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the $SYS system account functionality including:
|
||||
/// - Default system account creation with IsSystemAccount flag
|
||||
/// - $SYS.> subject routing to the system account's SubList
|
||||
/// - Non-system accounts blocked from subscribing to $SYS.> subjects
|
||||
/// - System account event publishing
|
||||
/// Reference: Go server/accounts.go — isSystemAccount, isReservedSubject.
|
||||
/// </summary>
|
||||
public class SystemAccountTests
|
||||
{
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the server creates a $SYS system account by default with
|
||||
/// IsSystemAccount set to true.
|
||||
/// Reference: Go server/server.go — initSystemAccount.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Default_system_account_is_created()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
server.SystemAccount.ShouldNotBeNull();
|
||||
server.SystemAccount.Name.ShouldBe(Account.SystemAccountName);
|
||||
server.SystemAccount.IsSystemAccount.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account constant matches "$SYS".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_name_constant_is_correct()
|
||||
{
|
||||
Account.SystemAccountName.ShouldBe("$SYS");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a non-system account does not have IsSystemAccount set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Regular_account_is_not_system_account()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.IsSystemAccount.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSystemAccount can be explicitly set on an account.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsSystemAccount_can_be_set()
|
||||
{
|
||||
var account = new Account("custom-sys") { IsSystemAccount = true };
|
||||
account.IsSystemAccount.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSystemSubject correctly identifies $SYS subjects.
|
||||
/// Reference: Go server/server.go — isReservedSubject.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("$SYS", true)]
|
||||
[InlineData("$SYS.ACCOUNT.test.CONNECT", true)]
|
||||
[InlineData("$SYS.SERVER.abc.STATSZ", true)]
|
||||
[InlineData("$SYS.REQ.SERVER.PING.VARZ", true)]
|
||||
[InlineData("foo.bar", false)]
|
||||
[InlineData("$G", false)]
|
||||
[InlineData("SYS.test", false)]
|
||||
[InlineData("$JS.API.STREAM.LIST", false)]
|
||||
[InlineData("$SYS.", true)]
|
||||
public void IsSystemSubject_identifies_sys_subjects(string subject, bool expected)
|
||||
{
|
||||
NatsServer.IsSystemSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account is listed among server accounts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_is_in_server_accounts()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var accounts = server.GetAccounts().ToList();
|
||||
accounts.ShouldContain(a => a.Name == Account.SystemAccountName && a.IsSystemAccount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSubscriptionAllowed blocks non-system accounts from $SYS.> subjects.
|
||||
/// Reference: Go server/accounts.go — isReservedForSys.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Non_system_account_cannot_subscribe_to_sys_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var regularAccount = new Account("regular");
|
||||
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeFalse();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeFalse();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.REQ.SERVER.PING.VARZ").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account IS allowed to subscribe to $SYS.> subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_can_subscribe_to_sys_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that any account can subscribe to non-$SYS subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Any_account_can_subscribe_to_regular_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var regularAccount = new Account("regular");
|
||||
|
||||
server.IsSubscriptionAllowed(regularAccount, "foo.bar").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$JS.API.STREAM.LIST").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "foo.bar").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetSubListForSubject routes $SYS subjects to the system account's SubList.
|
||||
/// Reference: Go server/server.go — sublist routing for internal subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSubListForSubject_routes_sys_to_system_account()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
|
||||
|
||||
// $SYS subjects should route to the system account's SubList
|
||||
var sysList = server.GetSubListForSubject(globalAccount, "$SYS.SERVER.abc.STATSZ");
|
||||
sysList.ShouldBeSameAs(server.SystemAccount.SubList);
|
||||
|
||||
// Regular subjects should route to the specified account's SubList
|
||||
var regularList = server.GetSubListForSubject(globalAccount, "foo.bar");
|
||||
regularList.ShouldBeSameAs(globalAccount.SubList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the EventSystem publishes to the system account's SubList
|
||||
/// and that internal subscriptions for monitoring are registered there.
|
||||
/// The subscriptions are wired up during StartAsync via InitEventTracking.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Event_system_subscribes_in_system_account()
|
||||
{
|
||||
var (server, _, cts) = await StartServerAsync(new NatsOptions());
|
||||
try
|
||||
{
|
||||
// The system account's SubList should have subscriptions registered
|
||||
// by the internal event system (VARZ, HEALTHZ, etc.)
|
||||
server.EventSystem.ShouldNotBeNull();
|
||||
server.SystemAccount.SubList.Count.ShouldBeGreaterThan(0u);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the global account is separate from the system account.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Global_and_system_accounts_are_separate()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
|
||||
var systemAccount = server.SystemAccount;
|
||||
|
||||
globalAccount.ShouldNotBeSameAs(systemAccount);
|
||||
globalAccount.Name.ShouldBe(Account.GlobalAccountName);
|
||||
systemAccount.Name.ShouldBe(Account.SystemAccountName);
|
||||
globalAccount.IsSystemAccount.ShouldBeFalse();
|
||||
systemAccount.IsSystemAccount.ShouldBeTrue();
|
||||
globalAccount.SubList.ShouldNotBeSameAs(systemAccount.SubList);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class TlsMapAuthParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void GetTlsAuthDcs_extracts_domain_components_from_subject()
|
||||
{
|
||||
using var cert = CreateSelfSignedCert("CN=alice,DC=example,DC=com");
|
||||
|
||||
TlsMapAuthenticator.GetTlsAuthDcs(cert.SubjectName).ShouldBe("DC=example,DC=com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DnsAltNameLabels_and_matches_follow_rfc6125_shape()
|
||||
{
|
||||
var labels = TlsMapAuthenticator.DnsAltNameLabels("*.Example.COM");
|
||||
labels.ShouldBe(["*", "example", "com"]);
|
||||
|
||||
TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://node.example.com:6222")]).ShouldBeTrue();
|
||||
TlsMapAuthenticator.DnsAltNameMatches(labels, [new Uri("nats://a.b.example.com:6222")]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authenticate_can_match_user_from_email_or_dns_san()
|
||||
{
|
||||
using var cert = CreateSelfSignedCertWithSan("CN=ignored", "ops@example.com", "router.example.com");
|
||||
var auth = new TlsMapAuthenticator([
|
||||
new User { Username = "ops@example.com", Password = "" },
|
||||
new User { Username = "router.example.com", Password = "" },
|
||||
]);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new Protocol.ClientOptions(),
|
||||
Nonce = [],
|
||||
ClientCertificate = cert,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
(result.Identity == "ops@example.com" || result.Identity == "router.example.com").ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCert(string subjectName)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateSelfSignedCertWithSan(string subjectName, string email, string dns)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest(subjectName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var sans = new SubjectAlternativeNameBuilder();
|
||||
sans.AddEmailAddress(email);
|
||||
sans.AddDnsName(dns);
|
||||
req.CertificateExtensions.Add(sans.Build());
|
||||
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
||||
}
|
||||
}
|
||||
169
tests/NATS.Server.Auth.Tests/Auth/WildcardExportTests.cs
Normal file
169
tests/NATS.Server.Auth.Tests/Auth/WildcardExportTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
// Tests for wildcard service export matching on Account.
|
||||
// Go reference: accounts_test.go — getWildcardServiceExport, getServiceExport (accounts.go line 2849).
|
||||
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
|
||||
namespace NATS.Server.Auth.Tests.Auth;
|
||||
|
||||
public class WildcardExportTests
|
||||
{
|
||||
private static Account CreateAccount(string name = "TestAccount") => new(name);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// GetWildcardServiceExport
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetWildcardServiceExport_ExactMatch_ReturnsExport()
|
||||
{
|
||||
// Go ref: accounts.go getWildcardServiceExport — exact key in exports.services map
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
|
||||
|
||||
var result = acct.GetWildcardServiceExport("orders.create");
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Subject.ShouldBe("orders.create");
|
||||
result.ResponseType.ShouldBe(ServiceResponseType.Singleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWildcardServiceExport_StarWildcard_ReturnsExport()
|
||||
{
|
||||
// Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '*' wildcard
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("orders.*", ServiceResponseType.Streamed, null);
|
||||
|
||||
var result = acct.GetWildcardServiceExport("orders.create");
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Subject.ShouldBe("orders.*");
|
||||
result.ResponseType.ShouldBe(ServiceResponseType.Streamed);
|
||||
result.IsWildcard.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWildcardServiceExport_GtWildcard_ReturnsExport()
|
||||
{
|
||||
// Go ref: accounts.go getWildcardServiceExport — isSubsetMatch with '>' wildcard
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("orders.>", ServiceResponseType.Chunked, null);
|
||||
|
||||
var result = acct.GetWildcardServiceExport("orders.create.new");
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Subject.ShouldBe("orders.>");
|
||||
result.ResponseType.ShouldBe(ServiceResponseType.Chunked);
|
||||
result.IsWildcard.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWildcardServiceExport_NoMatch_ReturnsNull()
|
||||
{
|
||||
// Go ref: accounts.go getWildcardServiceExport — returns nil when no pattern matches
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("payments.*", ServiceResponseType.Singleton, null);
|
||||
|
||||
var result = acct.GetWildcardServiceExport("orders.create");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// GetAllServiceExports
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetAllServiceExports_ReturnsAll()
|
||||
{
|
||||
// Go ref: accounts.go — exports.services map contains all registered exports
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
|
||||
acct.AddServiceExport("svc.b.*", ServiceResponseType.Streamed, null);
|
||||
acct.AddServiceExport("svc.>", ServiceResponseType.Chunked, null);
|
||||
|
||||
var all = acct.GetAllServiceExports();
|
||||
|
||||
all.Count.ShouldBe(3);
|
||||
all.Select(e => e.Subject).ShouldContain("svc.a");
|
||||
all.Select(e => e.Subject).ShouldContain("svc.b.*");
|
||||
all.Select(e => e.Subject).ShouldContain("svc.>");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// GetExactServiceExport
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GetExactServiceExport_Found()
|
||||
{
|
||||
// Go ref: accounts.go getServiceExport — direct map lookup, no wildcard scan
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
|
||||
|
||||
var result = acct.GetExactServiceExport("orders.create");
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Subject.ShouldBe("orders.create");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetExactServiceExport_NotFound_ReturnsNull()
|
||||
{
|
||||
// Go ref: accounts.go getServiceExport — map lookup misses wildcard patterns
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("orders.*", ServiceResponseType.Singleton, null);
|
||||
|
||||
// "orders.create" is not an exact key in the map — only "orders.*" is
|
||||
var result = acct.GetExactServiceExport("orders.create");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// HasServiceExport
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HasServiceExport_ExactMatch_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go — exact subject registered as an export
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("orders.create", ServiceResponseType.Singleton, null);
|
||||
|
||||
acct.HasServiceExport("orders.create").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasServiceExport_WildcardMatch_ReturnsTrue()
|
||||
{
|
||||
// Go ref: accounts.go — wildcard pattern covers the queried literal subject
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport("orders.>", ServiceResponseType.Singleton, null);
|
||||
|
||||
acct.HasServiceExport("orders.create.urgent").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// IsWildcard flag
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("orders.*", true)]
|
||||
[InlineData("orders.>", true)]
|
||||
[InlineData("orders.*.create", true)]
|
||||
[InlineData("orders.create", false)]
|
||||
[InlineData("svc", false)]
|
||||
public void IsWildcard_DetectsWildcardSubjects(string subject, bool expectedWildcard)
|
||||
{
|
||||
// Go ref: accounts.go — wildcard subjects contain '*' or '>'
|
||||
var acct = CreateAccount();
|
||||
acct.AddServiceExport(subject, ServiceResponseType.Singleton, null);
|
||||
|
||||
var result = acct.GetExactServiceExport(subject);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.IsWildcard.ShouldBe(expectedWildcard);
|
||||
}
|
||||
}
|
||||
21
tests/NATS.Server.Auth.Tests/AuthConfigTests.cs
Normal file
21
tests/NATS.Server.Auth.Tests/AuthConfigTests.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void NatsOptions_has_auth_fields_with_defaults()
|
||||
{
|
||||
var opts = new NatsOptions();
|
||||
|
||||
opts.Username.ShouldBeNull();
|
||||
opts.Password.ShouldBeNull();
|
||||
opts.Authorization.ShouldBeNull();
|
||||
opts.Users.ShouldBeNull();
|
||||
opts.NKeys.ShouldBeNull();
|
||||
opts.NoAuthUser.ShouldBeNull();
|
||||
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
251
tests/NATS.Server.Auth.Tests/AuthIntegrationTests.cs
Normal file
251
tests/NATS.Server.Auth.Tests/AuthIntegrationTests.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
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.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthIntegrationTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks whether any exception in the chain contains the given substring.
|
||||
/// The NATS client wraps server errors in outer NatsException messages,
|
||||
/// so the actual "Authorization Violation" may be in an inner exception.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
private static (NatsServer server, int port, CancellationTokenSource cts) StartServer(NatsOptions options)
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var (server, port, cts) = StartServer(options);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiUser_auth_success()
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
[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' in exception chain, but got: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
56
tests/NATS.Server.Auth.Tests/AuthProtocolTests.cs
Normal file
56
tests/NATS.Server.Auth.Tests/AuthProtocolTests.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthProtocolTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientOptions_deserializes_auth_fields()
|
||||
{
|
||||
var json = """{"user":"alice","pass":"secret","auth_token":"mytoken","nkey":"UABC","sig":"base64sig"}""";
|
||||
var opts = JsonSerializer.Deserialize<ClientOptions>(json);
|
||||
|
||||
opts.ShouldNotBeNull();
|
||||
opts.Username.ShouldBe("alice");
|
||||
opts.Password.ShouldBe("secret");
|
||||
opts.Token.ShouldBe("mytoken");
|
||||
opts.Nkey.ShouldBe("UABC");
|
||||
opts.Sig.ShouldBe("base64sig");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerInfo_serializes_auth_required_and_nonce()
|
||||
{
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "test",
|
||||
ServerName = "test",
|
||||
Version = "0.1.0",
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
AuthRequired = true,
|
||||
Nonce = "abc123",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(info);
|
||||
json.ShouldContain("\"auth_required\":true");
|
||||
json.ShouldContain("\"nonce\":\"abc123\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerInfo_omits_nonce_when_null()
|
||||
{
|
||||
var info = new ServerInfo
|
||||
{
|
||||
ServerId = "test",
|
||||
ServerName = "test",
|
||||
Version = "0.1.0",
|
||||
Host = "127.0.0.1",
|
||||
Port = 4222,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(info);
|
||||
json.ShouldNotContain("nonce");
|
||||
}
|
||||
}
|
||||
172
tests/NATS.Server.Auth.Tests/AuthServiceTests.cs
Normal file
172
tests/NATS.Server.Auth.Tests/AuthServiceTests.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class AuthServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsAuthRequired_false_when_no_auth_configured()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions());
|
||||
service.IsAuthRequired.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAuthRequired_true_when_token_configured()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
||||
service.IsAuthRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAuthRequired_true_when_username_configured()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
|
||||
service.IsAuthRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAuthRequired_true_when_users_configured()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Users = [new User { Username = "alice", Password = "secret" }],
|
||||
};
|
||||
var service = AuthService.Build(opts);
|
||||
service.IsAuthRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAuthRequired_true_when_nkeys_configured()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
NKeys = [new NKeyUser { Nkey = "UABC" }],
|
||||
};
|
||||
var service = AuthService.Build(opts);
|
||||
service.IsAuthRequired.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authenticate_succeeds_when_no_auth_required()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions());
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Token = "anything" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = service.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authenticate_token_success()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Token = "mytoken" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = service.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authenticate_token_failure()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions { Authorization = "mytoken" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Token = "wrong" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
service.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authenticate_simple_user_password_success()
|
||||
{
|
||||
var service = AuthService.Build(new NatsOptions { Username = "admin", Password = "pass" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "admin", Password = "pass" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = service.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Authenticate_multi_user_success()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Users = [
|
||||
new User { Username = "alice", Password = "secret1" },
|
||||
new User { Username = "bob", Password = "secret2" },
|
||||
],
|
||||
};
|
||||
var service = AuthService.Build(opts);
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "bob", Password = "secret2" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = service.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoAuthUser_fallback_when_no_creds()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
Users = [
|
||||
new User { Username = "default", Password = "unused" },
|
||||
],
|
||||
NoAuthUser = "default",
|
||||
};
|
||||
var service = AuthService.Build(opts);
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions(),
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = service.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("default");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NKeys_tried_before_users()
|
||||
{
|
||||
var opts = new NatsOptions
|
||||
{
|
||||
NKeys = [new NKeyUser { Nkey = "UABC" }],
|
||||
Users = [new User { Username = "alice", Password = "secret" }],
|
||||
};
|
||||
var service = AuthService.Build(opts);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = service.Authenticate(ctx);
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("alice");
|
||||
}
|
||||
}
|
||||
107
tests/NATS.Server.Auth.Tests/ClientPermissionsTests.cs
Normal file
107
tests/NATS.Server.Auth.Tests/ClientPermissionsTests.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ClientPermissionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void No_permissions_allows_everything()
|
||||
{
|
||||
var perms = ClientPermissions.Build(null);
|
||||
perms.ShouldBeNull();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_allow_and_deny()
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_cache_returns_same_result()
|
||||
{
|
||||
var perms = ClientPermissions.Build(new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
||||
});
|
||||
|
||||
perms.ShouldNotBeNull();
|
||||
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
|
||||
perms.IsPublishAllowed("foo.bar").ShouldBeTrue();
|
||||
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
|
||||
perms.IsPublishAllowed("baz.bar").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_permissions_object_allows_everything()
|
||||
{
|
||||
var perms = ClientPermissions.Build(new Permissions());
|
||||
perms.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
329
tests/NATS.Server.Auth.Tests/ImportExportTests.cs
Normal file
329
tests/NATS.Server.Auth.Tests/ImportExportTests.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class ImportExportTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExportAuth_public_export_authorizes_any_account()
|
||||
{
|
||||
var auth = new ExportAuth();
|
||||
var account = new Account("test");
|
||||
auth.IsAuthorized(account).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportAuth_approved_accounts_restricts_access()
|
||||
{
|
||||
var auth = new ExportAuth { ApprovedAccounts = ["allowed"] };
|
||||
var allowed = new Account("allowed");
|
||||
var denied = new Account("denied");
|
||||
auth.IsAuthorized(allowed).ShouldBeTrue();
|
||||
auth.IsAuthorized(denied).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportAuth_revoked_account_denied()
|
||||
{
|
||||
var auth = new ExportAuth
|
||||
{
|
||||
ApprovedAccounts = ["test"],
|
||||
RevokedAccounts = new() { ["test"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
|
||||
};
|
||||
var account = new Account("test");
|
||||
auth.IsAuthorized(account).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServiceResponseType_defaults_to_singleton()
|
||||
{
|
||||
var import = new ServiceImport
|
||||
{
|
||||
DestinationAccount = new Account("dest"),
|
||||
From = "requests.>",
|
||||
To = "api.>",
|
||||
};
|
||||
import.ResponseType.ShouldBe(ServiceResponseType.Singleton);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportMap_stores_and_retrieves_exports()
|
||||
{
|
||||
var map = new ExportMap();
|
||||
map.Services["api.>"] = new ServiceExport { Account = new Account("svc") };
|
||||
map.Streams["events.>"] = new StreamExport();
|
||||
|
||||
map.Services.ShouldContainKey("api.>");
|
||||
map.Streams.ShouldContainKey("events.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImportMap_stores_service_imports()
|
||||
{
|
||||
var map = new ImportMap();
|
||||
var si = new ServiceImport
|
||||
{
|
||||
DestinationAccount = new Account("dest"),
|
||||
From = "requests.>",
|
||||
To = "api.>",
|
||||
};
|
||||
map.AddServiceImport(si);
|
||||
map.Services.ShouldContainKey("requests.>");
|
||||
map.Services["requests.>"].Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_add_service_export_and_import()
|
||||
{
|
||||
var exporter = new Account("exporter");
|
||||
var importer = new Account("importer");
|
||||
|
||||
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
|
||||
exporter.Exports.Services.ShouldContainKey("api.>");
|
||||
|
||||
var si = importer.AddServiceImport(exporter, "requests.>", "api.>");
|
||||
si.ShouldNotBeNull();
|
||||
si.From.ShouldBe("requests.>");
|
||||
si.To.ShouldBe("api.>");
|
||||
si.DestinationAccount.ShouldBe(exporter);
|
||||
importer.Imports.Services.ShouldContainKey("requests.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_add_stream_export_and_import()
|
||||
{
|
||||
var exporter = new Account("exporter");
|
||||
var importer = new Account("importer");
|
||||
|
||||
exporter.AddStreamExport("events.>", null);
|
||||
exporter.Exports.Streams.ShouldContainKey("events.>");
|
||||
|
||||
importer.AddStreamImport(exporter, "events.>", "imported.events.>");
|
||||
importer.Imports.Streams.Count.ShouldBe(1);
|
||||
importer.Imports.Streams[0].From.ShouldBe("events.>");
|
||||
importer.Imports.Streams[0].To.ShouldBe("imported.events.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_service_import_auth_rejected()
|
||||
{
|
||||
var exporter = new Account("exporter");
|
||||
var importer = new Account("importer");
|
||||
|
||||
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, [new Account("other")]);
|
||||
|
||||
Should.Throw<UnauthorizedAccessException>(() =>
|
||||
importer.AddServiceImport(exporter, "requests.>", "api.>"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_lazy_creates_internal_client()
|
||||
{
|
||||
var account = new Account("test");
|
||||
var client = account.GetOrCreateInternalClient(99);
|
||||
client.ShouldNotBeNull();
|
||||
client.Kind.ShouldBe(ClientKind.Account);
|
||||
client.Account.ShouldBe(account);
|
||||
|
||||
// Second call returns same instance
|
||||
var client2 = account.GetOrCreateInternalClient(100);
|
||||
client2.ShouldBeSameAs(client);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Service_import_forwards_message_to_export_account()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
// Set up exporter and importer accounts
|
||||
var exporter = server.GetOrCreateAccount("exporter");
|
||||
var importer = server.GetOrCreateAccount("importer");
|
||||
|
||||
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
|
||||
importer.AddServiceImport(exporter, "requests.>", "api.>");
|
||||
|
||||
// Wire the import subscriptions into the importer account
|
||||
server.WireServiceImports(importer);
|
||||
|
||||
// Subscribe in exporter account to receive forwarded message
|
||||
var exportSub = new Subscription { Subject = "api.test", Sid = "export-1", Client = null };
|
||||
exporter.SubList.Insert(exportSub);
|
||||
|
||||
// Verify import infrastructure is wired: the importer should have service import entries
|
||||
importer.Imports.Services.ShouldContainKey("requests.>");
|
||||
importer.Imports.Services["requests.>"].Count.ShouldBe(1);
|
||||
importer.Imports.Services["requests.>"][0].DestinationAccount.ShouldBe(exporter);
|
||||
|
||||
await server.ShutdownAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessServiceImport_delivers_to_destination_account_subscribers()
|
||||
{
|
||||
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.>");
|
||||
|
||||
// Add a subscriber in the exporter account's SubList
|
||||
var received = new List<(string Subject, string Sid)>();
|
||||
var mockClient = new TestNatsClient(1, exporter);
|
||||
mockClient.OnMessage = (subject, sid, _, _, _) =>
|
||||
received.Add((subject, sid));
|
||||
|
||||
var exportSub = new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient };
|
||||
exporter.SubList.Insert(exportSub);
|
||||
|
||||
// Process a service import directly
|
||||
var si = importer.Imports.Services["requests.>"][0];
|
||||
server.ProcessServiceImport(si, "requests.test", null,
|
||||
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
received.Count.ShouldBe(1);
|
||||
received[0].Subject.ShouldBe("api.test");
|
||||
received[0].Sid.ShouldBe("s1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessServiceImport_with_transform_applies_subject_mapping()
|
||||
{
|
||||
using var server = CreateTestServer();
|
||||
|
||||
var exporter = server.GetOrCreateAccount("exporter");
|
||||
var importer = server.GetOrCreateAccount("importer");
|
||||
|
||||
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
|
||||
var si = importer.AddServiceImport(exporter, "requests.>", "api.>");
|
||||
|
||||
// Create a transform from requests.> to api.>
|
||||
var transform = SubjectTransform.Create("requests.>", "api.>");
|
||||
transform.ShouldNotBeNull();
|
||||
|
||||
// Create a new import with the transform set
|
||||
var siWithTransform = new ServiceImport
|
||||
{
|
||||
DestinationAccount = exporter,
|
||||
From = "requests.>",
|
||||
To = "api.>",
|
||||
Transform = transform,
|
||||
};
|
||||
|
||||
var received = new List<string>();
|
||||
var mockClient = new TestNatsClient(1, exporter);
|
||||
mockClient.OnMessage = (subject, _, _, _, _) =>
|
||||
received.Add(subject);
|
||||
|
||||
var exportSub = new Subscription { Subject = "api.hello", Sid = "s1", Client = mockClient };
|
||||
exporter.SubList.Insert(exportSub);
|
||||
|
||||
server.ProcessServiceImport(siWithTransform, "requests.hello", null,
|
||||
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
received.Count.ShouldBe(1);
|
||||
received[0].ShouldBe("api.hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessServiceImport_skips_invalid_imports()
|
||||
{
|
||||
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.>");
|
||||
|
||||
// Mark the import as invalid
|
||||
var si = importer.Imports.Services["requests.>"][0];
|
||||
si.Invalid = true;
|
||||
|
||||
// Add a subscriber in the exporter account
|
||||
var received = new List<string>();
|
||||
var mockClient = new TestNatsClient(1, exporter);
|
||||
mockClient.OnMessage = (subject, _, _, _, _) =>
|
||||
received.Add(subject);
|
||||
|
||||
var exportSub = new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient };
|
||||
exporter.SubList.Insert(exportSub);
|
||||
|
||||
// ProcessServiceImport should be a no-op for invalid imports
|
||||
server.ProcessServiceImport(si, "requests.test", null,
|
||||
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
received.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessServiceImport_delivers_to_queue_groups()
|
||||
{
|
||||
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.>");
|
||||
|
||||
// Add queue group subscribers in the exporter account
|
||||
var received = new List<(string Subject, string Sid)>();
|
||||
var mockClient1 = new TestNatsClient(1, exporter);
|
||||
mockClient1.OnMessage = (subject, sid, _, _, _) =>
|
||||
received.Add((subject, sid));
|
||||
var mockClient2 = new TestNatsClient(2, exporter);
|
||||
mockClient2.OnMessage = (subject, sid, _, _, _) =>
|
||||
received.Add((subject, sid));
|
||||
|
||||
var qSub1 = new Subscription { Subject = "api.test", Sid = "q1", Queue = "workers", Client = mockClient1 };
|
||||
var qSub2 = new Subscription { Subject = "api.test", Sid = "q2", Queue = "workers", Client = mockClient2 };
|
||||
exporter.SubList.Insert(qSub1);
|
||||
exporter.SubList.Insert(qSub2);
|
||||
|
||||
var si = importer.Imports.Services["requests.>"][0];
|
||||
server.ProcessServiceImport(si, "requests.test", null,
|
||||
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
// One member of the queue group should receive the message
|
||||
received.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static NatsServer CreateTestServer()
|
||||
{
|
||||
var port = TestPortAllocator.GetFreePort();
|
||||
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
||||
}
|
||||
/// <summary>
|
||||
/// Minimal test double for INatsClient used in import/export 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) { }
|
||||
}
|
||||
}
|
||||
866
tests/NATS.Server.Auth.Tests/JwtAuthenticatorTests.cs
Normal file
866
tests/NATS.Server.Auth.Tests/JwtAuthenticatorTests.cs
Normal file
@@ -0,0 +1,866 @@
|
||||
using System.Text;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class JwtAuthenticatorTests
|
||||
{
|
||||
private static string Base64UrlEncode(string input) =>
|
||||
Base64UrlEncode(Encoding.UTF8.GetBytes(input));
|
||||
|
||||
private static string Base64UrlEncode(byte[] input) =>
|
||||
Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
|
||||
{
|
||||
var header = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}""");
|
||||
var payload = Base64UrlEncode(payloadJson);
|
||||
var signingInput = Encoding.UTF8.GetBytes($"{header}.{payload}");
|
||||
var sig = new byte[64];
|
||||
signingKey.Sign(signingInput, sig);
|
||||
return $"{header}.{payload}.{Base64UrlEncode(sig)}";
|
||||
}
|
||||
|
||||
private static string SignNonce(KeyPair kp, byte[] nonce)
|
||||
{
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
return Convert.ToBase64String(sig).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_bearer_jwt_returns_auth_result()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "test-nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
result.AccountName.ShouldBe(accountPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Valid_jwt_with_nonce_signature_returns_auth_result()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var nonce = "test-nonce-data"u8.ToArray();
|
||||
var sig = SignNonce(userKp, nonce);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt, Nkey = userPub, Sig = sig },
|
||||
Nonce = nonce,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
result.AccountName.ShouldBe(accountPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_jwt_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var resolver = new MemAccountResolver();
|
||||
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions(),
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_jwt_string_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var resolver = new MemAccountResolver();
|
||||
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = "not-a-jwt" },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expired_jwt_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// Expired in 2020
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1500000000,
|
||||
"exp":1600000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoked_user_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
// Account JWT with revocation for user
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"account","version":2,
|
||||
"revocations":{
|
||||
"{{userPub}}":1700000001
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT issued at 1700000000 (before revocation time 1700000001)
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Untrusted_operator_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
// Use a different trusted key that doesn't match the operator
|
||||
var otherOperator = KeyPair.CreatePair(PrefixByte.Operator).GetPublicKey();
|
||||
var auth = new JwtAuthenticator([otherOperator], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_account_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
// Don't store the account JWT in the resolver
|
||||
var resolver = new MemAccountResolver();
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_bearer_without_sig_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// Non-bearer user JWT
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt }, // No Sig provided
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jwt_with_permissions_returns_permissions()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"pub":{"allow":["foo.>","bar.*"]}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Permissions.ShouldNotBeNull();
|
||||
result.Permissions.Publish.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.ShouldNotBeNull();
|
||||
result.Permissions.Publish.Allow.ShouldContain("foo.>");
|
||||
result.Permissions.Publish.Allow.ShouldContain("bar.*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signing_key_based_user_jwt_succeeds()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var signingPub = signingKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
// Account JWT with signing key
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"account","version":2,
|
||||
"signing_keys":["{{signingPub}}"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT issued by the signing key
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{signingPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, signingKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
result.AccountName.ShouldBe(accountPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wildcard_revocation_returns_null()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
// Account JWT with wildcard revocation
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"account","version":2,
|
||||
"revocations":{
|
||||
"*":1700000001
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT issued at 1700000000 (before wildcard revocation)
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// allowed_connection_types tests
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_allows_standard_context()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["STANDARD"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_rejects_mqtt_only_for_standard_context()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT only allows MQTT connections
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["MQTT"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
// Should reject: STANDARD is not in allowed_connection_types
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_allows_known_even_with_unknown_values()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT allows STANDARD and an unknown type
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["STANDARD","SOME_NEW_TYPE"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_rejects_when_only_unknown_values_present()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT only allows an unknown connection type
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["SOME_NEW_TYPE"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
// Should reject: STANDARD is not in allowed_connection_types
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Allowed_connection_types_is_case_insensitive_for_input_values()
|
||||
{
|
||||
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
|
||||
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
|
||||
var userKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
var operatorPub = operatorKp.GetPublicKey();
|
||||
var accountPub = accountKp.GetPublicKey();
|
||||
var userPub = userKp.GetPublicKey();
|
||||
|
||||
var accountPayload = $$"""
|
||||
{
|
||||
"sub":"{{accountPub}}",
|
||||
"iss":"{{operatorPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{"type":"account","version":2}
|
||||
}
|
||||
""";
|
||||
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
|
||||
|
||||
// User JWT allows "standard" (lowercase)
|
||||
var userPayload = $$"""
|
||||
{
|
||||
"sub":"{{userPub}}",
|
||||
"iss":"{{accountPub}}",
|
||||
"iat":1700000000,
|
||||
"nats":{
|
||||
"type":"user","version":2,
|
||||
"bearer_token":true,
|
||||
"issuer_account":"{{accountPub}}",
|
||||
"allowed_connection_types":["standard"]
|
||||
}
|
||||
}
|
||||
""";
|
||||
var userJwt = BuildSignedToken(userPayload, accountKp);
|
||||
|
||||
var resolver = new MemAccountResolver();
|
||||
await resolver.StoreAsync(accountPub, accountJwt);
|
||||
|
||||
var auth = new JwtAuthenticator([operatorPub], resolver);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { JWT = userJwt },
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
ConnectionType = "STANDARD",
|
||||
};
|
||||
|
||||
// Should allow: case-insensitive match of "standard" == "STANDARD"
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(userPub);
|
||||
}
|
||||
}
|
||||
1625
tests/NATS.Server.Auth.Tests/JwtTests.cs
Normal file
1625
tests/NATS.Server.Auth.Tests/JwtTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
28
tests/NATS.Server.Auth.Tests/NATS.Server.Auth.Tests.csproj
Normal file
28
tests/NATS.Server.Auth.Tests/NATS.Server.Auth.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NATS.Client.Core" />
|
||||
<PackageReference Include="NATS.NKeys" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<Using Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
|
||||
<ProjectReference Include="..\NATS.Server.TestUtilities\NATS.Server.TestUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
130
tests/NATS.Server.Auth.Tests/NKeyAuthenticatorTests.cs
Normal file
130
tests/NATS.Server.Auth.Tests/NKeyAuthenticatorTests.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class NKeyAuthenticatorTests
|
||||
{
|
||||
private static (string PublicKey, string SignatureBase64) CreateSignedNonce(byte[] nonce)
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
var sigBase64 = Convert.ToBase64String(sig);
|
||||
return (publicKey, sigBase64);
|
||||
}
|
||||
|
||||
private static string SignNonce(KeyPair kp, byte[] nonce)
|
||||
{
|
||||
var sig = new byte[64];
|
||||
kp.Sign(nonce, sig);
|
||||
return Convert.ToBase64String(sig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_result_for_valid_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce-123"u8.ToArray();
|
||||
var sigBase64 = SignNonce(kp, nonce);
|
||||
|
||||
var nkeyUser = new NKeyUser { Nkey = publicKey };
|
||||
var auth = new NKeyAuthenticator([nkeyUser]);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
|
||||
Nonce = nonce,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe(publicKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_invalid_signature()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce-123"u8.ToArray();
|
||||
|
||||
var nkeyUser = new NKeyUser { Nkey = publicKey };
|
||||
var auth = new NKeyAuthenticator([nkeyUser]);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Nkey = publicKey, Sig = Convert.ToBase64String(new byte[64]) },
|
||||
Nonce = nonce,
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_unknown_nkey()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce-123"u8.ToArray();
|
||||
var sigBase64 = SignNonce(kp, nonce);
|
||||
|
||||
var auth = new NKeyAuthenticator([]);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
|
||||
Nonce = nonce,
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_no_nkey_provided()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nkeyUser = new NKeyUser { Nkey = publicKey };
|
||||
var auth = new NKeyAuthenticator([nkeyUser]);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions(),
|
||||
Nonce = "nonce"u8.ToArray(),
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_permissions_from_nkey_user()
|
||||
{
|
||||
var kp = KeyPair.CreatePair(PrefixByte.User);
|
||||
var publicKey = kp.GetPublicKey();
|
||||
var nonce = "test-nonce"u8.ToArray();
|
||||
var sigBase64 = SignNonce(kp, nonce);
|
||||
|
||||
var perms = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
||||
};
|
||||
var nkeyUser = new NKeyUser { Nkey = publicKey, Permissions = perms };
|
||||
var auth = new NKeyAuthenticator([nkeyUser]);
|
||||
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Nkey = publicKey, Sig = sigBase64 },
|
||||
Nonce = nonce,
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Permissions.ShouldBe(perms);
|
||||
}
|
||||
}
|
||||
76
tests/NATS.Server.Auth.Tests/NKeyIntegrationTests.cs
Normal file
76
tests/NATS.Server.Auth.Tests/NKeyIntegrationTests.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class NKeyIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private NatsServer _server = null!;
|
||||
private int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task _serverTask = null!;
|
||||
private KeyPair _userKeyPair = null!;
|
||||
private string _userSeed = null!;
|
||||
private string _userPublicKey = null!;
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_userKeyPair = KeyPair.CreatePair(PrefixByte.User);
|
||||
_userPublicKey = _userKeyPair.GetPublicKey();
|
||||
_userSeed = _userKeyPair.GetSeed();
|
||||
|
||||
_server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Port = _port,
|
||||
NKeys = [new NKeyUser { Nkey = _userPublicKey }],
|
||||
}, NullLoggerFactory.Instance);
|
||||
|
||||
_serverTask = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NKey_auth_success()
|
||||
{
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{_port}",
|
||||
AuthOpts = new NatsAuthOpts { NKey = _userPublicKey, Seed = _userSeed },
|
||||
});
|
||||
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NKey_auth_wrong_key_fails()
|
||||
{
|
||||
// Generate a different key pair not known to the server
|
||||
var otherKp = KeyPair.CreatePair(PrefixByte.User);
|
||||
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{_port}",
|
||||
AuthOpts = new NatsAuthOpts { NKey = otherKp.GetPublicKey(), Seed = otherKp.GetSeed() },
|
||||
MaxReconnectRetry = 0,
|
||||
});
|
||||
|
||||
await Should.ThrowAsync<NatsException>(async () =>
|
||||
{
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
113
tests/NATS.Server.Auth.Tests/PermissionIntegrationTests.cs
Normal file
113
tests/NATS.Server.Auth.Tests/PermissionIntegrationTests.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class PermissionIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private NatsServer _server = null!;
|
||||
private int _port;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task _serverTask = null!;
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_port = TestPortAllocator.GetFreePort();
|
||||
_server = new NatsServer(new NatsOptions
|
||||
{
|
||||
Port = _port,
|
||||
Users =
|
||||
[
|
||||
new User
|
||||
{
|
||||
Username = "publisher",
|
||||
Password = "pass",
|
||||
Permissions = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["events.>"] },
|
||||
Subscribe = new SubjectPermission { Deny = [">"] },
|
||||
},
|
||||
},
|
||||
new User
|
||||
{
|
||||
Username = "subscriber",
|
||||
Password = "pass",
|
||||
Permissions = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Deny = [">"] },
|
||||
Subscribe = new SubjectPermission { Allow = ["events.>"] },
|
||||
},
|
||||
},
|
||||
new User
|
||||
{
|
||||
Username = "admin",
|
||||
Password = "pass",
|
||||
// No permissions — full access
|
||||
},
|
||||
],
|
||||
}, NullLoggerFactory.Instance);
|
||||
|
||||
_serverTask = _server.StartAsync(_cts.Token);
|
||||
await _server.WaitForReadyAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
_server.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publisher_can_publish_to_allowed_subject()
|
||||
{
|
||||
await using var pub = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://publisher:pass@127.0.0.1:{_port}",
|
||||
});
|
||||
await using var admin = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://admin:pass@127.0.0.1:{_port}",
|
||||
});
|
||||
|
||||
await pub.ConnectAsync();
|
||||
await admin.ConnectAsync();
|
||||
|
||||
await using var sub = await admin.SubscribeCoreAsync<string>("events.test");
|
||||
await admin.PingAsync();
|
||||
|
||||
await pub.PublishAsync("events.test", "hello");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("hello");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_has_full_access()
|
||||
{
|
||||
await using var admin1 = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://admin:pass@127.0.0.1:{_port}",
|
||||
});
|
||||
await using var admin2 = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://admin:pass@127.0.0.1:{_port}",
|
||||
});
|
||||
|
||||
await admin1.ConnectAsync();
|
||||
await admin2.ConnectAsync();
|
||||
|
||||
await using var sub = await admin2.SubscribeCoreAsync<string>("anything.at.all");
|
||||
await admin2.PingAsync();
|
||||
|
||||
await admin1.PublishAsync("anything.at.all", "data");
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
var msg = await sub.Msgs.ReadAsync(timeout.Token);
|
||||
msg.Data.ShouldBe("data");
|
||||
}
|
||||
}
|
||||
52
tests/NATS.Server.Auth.Tests/PermissionLruCacheTests.cs
Normal file
52
tests/NATS.Server.Auth.Tests/PermissionLruCacheTests.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class PermissionLruCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void Get_returns_none_for_unknown_key()
|
||||
{
|
||||
var cache = new PermissionLruCache(128);
|
||||
cache.TryGet("foo", out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_and_get_returns_value()
|
||||
{
|
||||
var cache = new PermissionLruCache(128);
|
||||
cache.Set("foo", true);
|
||||
cache.TryGet("foo", out var v).ShouldBeTrue();
|
||||
v.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evicts_oldest_when_full()
|
||||
{
|
||||
var cache = new PermissionLruCache(3);
|
||||
cache.Set("a", true);
|
||||
cache.Set("b", true);
|
||||
cache.Set("c", true);
|
||||
cache.Set("d", true); // evicts "a"
|
||||
|
||||
cache.TryGet("a", out _).ShouldBeFalse();
|
||||
cache.TryGet("b", out _).ShouldBeTrue();
|
||||
cache.TryGet("d", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_promotes_to_front()
|
||||
{
|
||||
var cache = new PermissionLruCache(3);
|
||||
cache.Set("a", true);
|
||||
cache.Set("b", true);
|
||||
cache.Set("c", true);
|
||||
|
||||
// Access "a" to promote it
|
||||
cache.TryGet("a", out _);
|
||||
|
||||
cache.Set("d", true); // should evict "b" (oldest untouched)
|
||||
cache.TryGet("a", out _).ShouldBeTrue();
|
||||
cache.TryGet("b", out _).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
99
tests/NATS.Server.Auth.Tests/PermissionTemplateTests.cs
Normal file
99
tests/NATS.Server.Auth.Tests/PermissionTemplateTests.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
public class PermissionTemplateTests
|
||||
{
|
||||
[Fact]
|
||||
public void Expand_name_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("user.{{name()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["user.alice.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_subject_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("inbox.{{subject()}}.>",
|
||||
name: "alice", subject: "UABC123", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["inbox.UABC123.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_name_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("acct.{{account-name()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "myaccount", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["acct.myaccount.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_subject_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("acct.{{account-subject()}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC456",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["acct.AABC456.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_tag_template_single_value()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:engineering"], accountTags: []);
|
||||
result.ShouldBe(["dept.engineering.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_tag_template_multi_value_cartesian()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(dept)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:eng", "dept:sales"], accountTags: []);
|
||||
result.Count.ShouldBe(2);
|
||||
result.ShouldContain("dept.eng.>");
|
||||
result.ShouldContain("dept.sales.>");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_account_tag_template()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("region.{{account-tag(region)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: ["region:us-east"]);
|
||||
result.ShouldBe(["region.us-east.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_no_templates_returns_original()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("foo.bar.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["foo.bar.>"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expand_unknown_tag_returns_empty()
|
||||
{
|
||||
var result = PermissionTemplates.Expand("dept.{{tag(missing)}}.>",
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: ["dept:eng"], accountTags: []);
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExpandAll_expands_array_of_subjects()
|
||||
{
|
||||
var subjects = new[] { "user.{{name()}}.>", "inbox.{{subject()}}.>" };
|
||||
var result = PermissionTemplates.ExpandAll(subjects,
|
||||
name: "alice", subject: "UABC", accountName: "acct", accountSubject: "AABC",
|
||||
userTags: [], accountTags: []);
|
||||
result.ShouldBe(["user.alice.>", "inbox.UABC.>"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class SimpleUserPasswordAuthenticatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Returns_result_for_correct_credentials()
|
||||
{
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "admin", Password = "password123" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("admin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_wrong_username()
|
||||
{
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "wrong", Password = "password123" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_wrong_password()
|
||||
{
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "admin", Password = "wrong" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_null_username()
|
||||
{
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = null, Password = "password123" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_empty_username()
|
||||
{
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "", Password = "password123" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_null_password()
|
||||
{
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", "password123");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "admin", Password = null },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Supports_bcrypt_password()
|
||||
{
|
||||
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", hash);
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "admin", Password = "secret" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_wrong_password_with_bcrypt()
|
||||
{
|
||||
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
|
||||
var auth = new SimpleUserPasswordAuthenticator("admin", hash);
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "admin", Password = "wrongpassword" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
62
tests/NATS.Server.Auth.Tests/TokenAuthenticatorTests.cs
Normal file
62
tests/NATS.Server.Auth.Tests/TokenAuthenticatorTests.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class TokenAuthenticatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Returns_result_for_correct_token()
|
||||
{
|
||||
var auth = new TokenAuthenticator("secret-token");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Token = "secret-token" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("token");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_wrong_token()
|
||||
{
|
||||
var auth = new TokenAuthenticator("secret-token");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Token = "wrong-token" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_no_token_provided()
|
||||
{
|
||||
var auth = new TokenAuthenticator("secret-token");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions(),
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_different_length_token()
|
||||
{
|
||||
var auth = new TokenAuthenticator("secret-token");
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Token = "short" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
120
tests/NATS.Server.Auth.Tests/UserPasswordAuthenticatorTests.cs
Normal file
120
tests/NATS.Server.Auth.Tests/UserPasswordAuthenticatorTests.cs
Normal file
@@ -0,0 +1,120 @@
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth.Tests;
|
||||
|
||||
public class UserPasswordAuthenticatorTests
|
||||
{
|
||||
private static UserPasswordAuthenticator CreateAuth(params User[] users)
|
||||
{
|
||||
return new UserPasswordAuthenticator(users);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_result_for_correct_plain_password()
|
||||
{
|
||||
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_result_for_correct_bcrypt_password()
|
||||
{
|
||||
var hash = BCrypt.Net.BCrypt.HashPassword("secret");
|
||||
var auth = CreateAuth(new User { Username = "bob", Password = hash });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "bob", Password = "secret" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Identity.ShouldBe("bob");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_wrong_password()
|
||||
{
|
||||
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "wrong" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_for_unknown_user()
|
||||
{
|
||||
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "unknown", Password = "secret" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_no_username_provided()
|
||||
{
|
||||
var auth = CreateAuth(new User { Username = "alice", Password = "secret" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions(),
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
auth.Authenticate(ctx).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_permissions_from_user()
|
||||
{
|
||||
var perms = new Permissions
|
||||
{
|
||||
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
||||
};
|
||||
var auth = CreateAuth(new User { Username = "alice", Password = "secret", Permissions = perms });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.Permissions.ShouldBe(perms);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_account_name_from_user()
|
||||
{
|
||||
var auth = CreateAuth(new User { Username = "alice", Password = "secret", Account = "myaccount" });
|
||||
var ctx = new ClientAuthContext
|
||||
{
|
||||
Opts = new ClientOptions { Username = "alice", Password = "secret" },
|
||||
Nonce = [],
|
||||
};
|
||||
|
||||
var result = auth.Authenticate(ctx);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result.AccountName.ShouldBe("myaccount");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user