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:
Joseph Doherty
2026-03-12 15:54:07 -04:00
parent 0c086522a4
commit 36b9dfa654
53 changed files with 138 additions and 185 deletions

View File

@@ -1,106 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AccountIsolationTests : IAsyncLifetime
{
private NatsServer _server = null!;
private int _port;
private readonly CancellationTokenSource _cts = new();
private Task _serverTask = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync()
{
_port = 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 (timeout)
}
}
}

View File

@@ -1,68 +0,0 @@
using NATS.Server.Auth.Jwt;
namespace NATS.Server.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);
}
}

View File

@@ -1,48 +0,0 @@
using NATS.Server.Auth;
namespace NATS.Server.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);
}
}

View File

@@ -1,71 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.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
}
}

View File

@@ -1,420 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.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 = GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <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) { }
}
}

View File

@@ -1,521 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests.Accounts;
/// <summary>
/// Tests for account creation, registration, isolation, and basic account lifecycle.
/// Reference: Go accounts_test.go — TestRegisterDuplicateAccounts, TestAccountIsolation,
/// TestAccountFromOptions, TestAccountSimpleConfig, TestAccountParseConfig,
/// TestMultiAccountsIsolation, TestNewAccountAndRequireNewAlwaysError, etc.
/// </summary>
public class AccountIsolationTests
{
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static NatsServer CreateTestServer(NatsOptions? options = null)
{
var port = GetFreePort();
options ??= new NatsOptions();
options.Port = port;
return new NatsServer(options, NullLoggerFactory.Instance);
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{
var port = GetFreePort();
options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return (server, port, cts);
}
private static bool ExceptionChainContains(Exception ex, string substring)
{
Exception? current = ex;
while (current != null)
{
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
return true;
current = current.InnerException;
}
return false;
}
// Go: TestRegisterDuplicateAccounts server/accounts_test.go:50
[Fact]
public void Register_duplicate_account_returns_existing()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("$foo");
var foo2 = server.GetOrCreateAccount("$foo");
// GetOrCreateAccount returns the same instance if already registered
foo.ShouldBeSameAs(foo2);
}
// Go: TestAccountIsolation server/accounts_test.go:57
[Fact]
public void Account_isolation_separate_sublists()
{
using var server = CreateTestServer();
var fooAcc = server.GetOrCreateAccount("$foo");
var barAcc = server.GetOrCreateAccount("$bar");
// Accounts must have different SubLists
fooAcc.SubList.ShouldNotBeSameAs(barAcc.SubList);
fooAcc.Name.ShouldBe("$foo");
barAcc.Name.ShouldBe("$bar");
}
// Go: TestAccountIsolation server/accounts_test.go:57
[Fact]
public void Account_isolation_messages_do_not_cross()
{
using var server = CreateTestServer();
var fooAcc = server.GetOrCreateAccount("$foo");
var barAcc = server.GetOrCreateAccount("$bar");
var receivedFoo = new List<string>();
var receivedBar = new List<string>();
var clientFoo = new TestNatsClient(1, fooAcc);
clientFoo.OnMessage = (subject, _, _, _, _) => receivedFoo.Add(subject);
var clientBar = new TestNatsClient(2, barAcc);
clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject);
// Subscribe to "foo" in both accounts
barAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientBar });
fooAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientFoo });
// Publish to foo account's SubList
var result = fooAcc.SubList.Match("foo");
foreach (var sub in result.PlainSubs)
sub.Client?.SendMessage("foo", sub.Sid, null, default, default);
// Only foo account subscriber should receive
receivedFoo.Count.ShouldBe(1);
receivedBar.Count.ShouldBe(0);
}
// Go: TestAccountFromOptions server/accounts_test.go:386
[Fact]
public void Account_from_options_creates_accounts()
{
using var server = CreateTestServer(new NatsOptions
{
Accounts = new Dictionary<string, AccountConfig>
{
["foo"] = new AccountConfig(),
["bar"] = new AccountConfig(),
},
});
var fooAcc = server.GetOrCreateAccount("foo");
var barAcc = server.GetOrCreateAccount("bar");
fooAcc.ShouldNotBeNull();
barAcc.ShouldNotBeNull();
fooAcc.SubList.ShouldNotBeNull();
barAcc.SubList.ShouldNotBeNull();
}
// Go: TestAccountFromOptions server/accounts_test.go:386
[Fact]
public void Account_from_options_applies_config()
{
using var server = CreateTestServer(new NatsOptions
{
Accounts = new Dictionary<string, AccountConfig>
{
["limited"] = new AccountConfig { MaxConnections = 5, MaxSubscriptions = 10 },
},
});
var acc = server.GetOrCreateAccount("limited");
acc.MaxConnections.ShouldBe(5);
acc.MaxSubscriptions.ShouldBe(10);
}
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
[Fact]
public async Task Multi_accounts_isolation_only_correct_importer_receives()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "public", Password = "public", Account = "PUBLIC" },
new User { Username = "client", Password = "client", Account = "CLIENT" },
new User { Username = "client2", Password = "client2", Account = "CLIENT2" },
],
});
try
{
await using var publicNc = new NatsConnection(new NatsOpts
{
Url = $"nats://public:public@127.0.0.1:{port}",
});
await using var clientNc = new NatsConnection(new NatsOpts
{
Url = $"nats://client:client@127.0.0.1:{port}",
});
await using var client2Nc = new NatsConnection(new NatsOpts
{
Url = $"nats://client2:client2@127.0.0.1:{port}",
});
await publicNc.ConnectAsync();
await clientNc.ConnectAsync();
await client2Nc.ConnectAsync();
// Subscribe on both client accounts
await using var clientSub = await clientNc.SubscribeCoreAsync<string>("orders.>");
await using var client2Sub = await client2Nc.SubscribeCoreAsync<string>("orders.>");
await clientNc.PingAsync();
await client2Nc.PingAsync();
// Publish from the same account as client - CLIENT should get it
await clientNc.PublishAsync("orders.entry", "test1");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var msg = await clientSub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("test1");
// CLIENT2 should NOT receive messages from CLIENT account
using var shortTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
try
{
await client2Sub.Msgs.ReadAsync(shortTimeout.Token);
throw new Exception("CLIENT2 should not have received a message from CLIENT account");
}
catch (OperationCanceledException)
{
// Expected
}
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestAccountIsolation server/accounts_test.go:57 (integration variant)
[Fact]
public async Task Same_account_receives_messages_integration()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "alice", Password = "pass", Account = "acct-a" },
new User { Username = "charlie", Password = "pass", Account = "acct-a" },
],
});
try
{
await using var alice = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:pass@127.0.0.1:{port}",
});
await using var charlie = new NatsConnection(new NatsOpts
{
Url = $"nats://charlie:pass@127.0.0.1:{port}",
});
await alice.ConnectAsync();
await charlie.ConnectAsync();
await using var sub = await charlie.SubscribeCoreAsync<string>("test.subject");
await charlie.PingAsync();
await alice.PublishAsync("test.subject", "from-alice");
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var msg = await sub.Msgs.ReadAsync(timeout.Token);
msg.Data.ShouldBe("from-alice");
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestAccountIsolation server/accounts_test.go:57 (integration variant)
[Fact]
public async Task Different_account_does_not_receive_messages_integration()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "alice", Password = "pass", Account = "acct-a" },
new User { Username = "bob", Password = "pass", Account = "acct-b" },
],
});
try
{
await using var alice = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:pass@127.0.0.1:{port}",
});
await using var bob = new NatsConnection(new NatsOpts
{
Url = $"nats://bob:pass@127.0.0.1:{port}",
});
await alice.ConnectAsync();
await bob.ConnectAsync();
await using var sub = await bob.SubscribeCoreAsync<string>("test.subject");
await bob.PingAsync();
await alice.PublishAsync("test.subject", "from-alice");
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
try
{
await sub.Msgs.ReadAsync(timeout.Token);
throw new Exception("Bob should not have received a message from a different account");
}
catch (OperationCanceledException)
{
// Expected
}
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
[Fact]
public void Three_account_isolation_with_wildcard_subs()
{
using var server = CreateTestServer();
var accountA = server.GetOrCreateAccount("acct-a");
var accountB = server.GetOrCreateAccount("acct-b");
var accountC = server.GetOrCreateAccount("acct-c");
accountA.SubList.ShouldNotBeSameAs(accountB.SubList);
accountB.SubList.ShouldNotBeSameAs(accountC.SubList);
var receivedA = new List<string>();
var receivedB = new List<string>();
var receivedC = new List<string>();
var clientA = new TestNatsClient(1, accountA);
clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
var clientB = new TestNatsClient(2, accountB);
clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);
var clientC = new TestNatsClient(3, accountC);
clientC.OnMessage = (subject, _, _, _, _) => receivedC.Add(subject);
accountA.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "a1", Client = clientA });
accountB.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "b1", Client = clientB });
accountC.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "c1", Client = clientC });
// Publish in Account A's subject space
var resultA = accountA.SubList.Match("orders.client.stream.entry");
foreach (var sub in resultA.PlainSubs)
sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null, default, default);
receivedA.Count.ShouldBe(1);
receivedB.Count.ShouldBe(0);
receivedC.Count.ShouldBe(0);
// Publish in Account B
var resultB = accountB.SubList.Match("orders.other.stream.entry");
foreach (var sub in resultB.PlainSubs)
sub.Client?.SendMessage("orders.other.stream.entry", sub.Sid, null, default, default);
receivedA.Count.ShouldBe(1); // unchanged
receivedB.Count.ShouldBe(1);
receivedC.Count.ShouldBe(0);
}
// Go: TestAccountGlobalDefault server/accounts_test.go:2254
[Fact]
public void Global_account_has_default_name()
{
Account.GlobalAccountName.ShouldBe("$G");
}
// Go: TestRegisterDuplicateAccounts server/accounts_test.go:50
[Fact]
public void GetOrCreateAccount_returns_same_instance()
{
using var server = CreateTestServer();
var acc1 = server.GetOrCreateAccount("test-acc");
var acc2 = server.GetOrCreateAccount("test-acc");
acc1.ShouldBeSameAs(acc2);
}
// Go: TestAccountIsolation server/accounts_test.go:57 — verifies accounts are different objects
[Fact]
public void Accounts_are_distinct_objects()
{
using var server = CreateTestServer();
var foo = server.GetOrCreateAccount("foo");
var bar = server.GetOrCreateAccount("bar");
foo.ShouldNotBeSameAs(bar);
foo.Name.ShouldNotBe(bar.Name);
}
// Go: TestAccountMapsUsers server/accounts_test.go:2138
[Fact]
public async Task Users_mapped_to_correct_accounts()
{
var (server, port, cts) = await StartServerAsync(new NatsOptions
{
Users =
[
new User { Username = "alice", Password = "pass", Account = "acct-a" },
new User { Username = "bob", Password = "pass", Account = "acct-b" },
],
});
try
{
// Both should connect successfully to their respective accounts
await using var alice = new NatsConnection(new NatsOpts
{
Url = $"nats://alice:pass@127.0.0.1:{port}",
});
await using var bob = new NatsConnection(new NatsOpts
{
Url = $"nats://bob:pass@127.0.0.1:{port}",
});
await alice.ConnectAsync();
await alice.PingAsync();
await bob.ConnectAsync();
await bob.PingAsync();
// Verify isolation: publish in A, subscribe in B should not receive
await using var bobSub = await bob.SubscribeCoreAsync<string>("mapped.test");
await bob.PingAsync();
await alice.PublishAsync("mapped.test", "hello");
using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
try
{
await bobSub.Msgs.ReadAsync(timeout.Token);
throw new Exception("Bob should not receive messages from Alice's account");
}
catch (OperationCanceledException)
{
// Expected — accounts are isolated
}
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
// Go: TestAccountIsolation server/accounts_test.go:57 — wildcard subscriber in isolated account
[Fact]
public void Wildcard_subscriber_in_isolated_account_no_cross_delivery()
{
using var server = CreateTestServer();
var fooAcc = server.GetOrCreateAccount("$foo");
var barAcc = server.GetOrCreateAccount("$bar");
var receivedBar = new List<string>();
var clientBar = new TestNatsClient(2, barAcc);
clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject);
// Bar subscribes to wildcard
barAcc.SubList.Insert(new Subscription { Subject = ">", Sid = "1", Client = clientBar });
// Publish in foo account
var result = fooAcc.SubList.Match("anything.goes.here");
result.PlainSubs.Length.ShouldBe(0); // No subscribers in foo
// Bar should still have no messages
receivedBar.Count.ShouldBe(0);
}
// Go: TestAccountIsolation server/accounts_test.go:57 — multiple subs same account
[Fact]
public void Multiple_subscribers_same_account_all_receive()
{
using var server = CreateTestServer();
var acc = server.GetOrCreateAccount("test");
var received1 = new List<string>();
var received2 = new List<string>();
var client1 = new TestNatsClient(1, acc);
client1.OnMessage = (subject, _, _, _, _) => received1.Add(subject);
var client2 = new TestNatsClient(2, acc);
client2.OnMessage = (subject, _, _, _, _) => received2.Add(subject);
acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s1", Client = client1 });
acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s2", Client = client2 });
var result = acc.SubList.Match("events.order.created");
result.PlainSubs.Length.ShouldBe(2);
foreach (var sub in result.PlainSubs)
sub.Client?.SendMessage("events.order.created", sub.Sid, null, default, default);
received1.Count.ShouldBe(1);
received2.Count.ShouldBe(1);
}
/// <summary>
/// Minimal test double for INatsClient used in isolation tests.
/// </summary>
private sealed class TestNatsClient(ulong id, Account account) : INatsClient
{
public ulong Id => id;
public ClientKind Kind => ClientKind.Client;
public Account? Account => account;
public Protocol.ClientOptions? ClientOpts => null;
public ClientPermissions? Permissions => null;
public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? OnMessage { get; set; }
public void SendMessage(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
}
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
public void RemoveSubscription(string sid) { }
}
}

View File

@@ -1,822 +0,0 @@
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;
namespace NATS.Server.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 int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static NatsServer CreateTestServer(NatsOptions? options = null)
{
var port = GetFreePort();
options ??= new NatsOptions();
options.Port = port;
return new NatsServer(options, NullLoggerFactory.Instance);
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{
var port = GetFreePort();
options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return (server, port, cts);
}
private static bool ExceptionChainContains(Exception ex, string substring)
{
Exception? current = ex;
while (current != null)
{
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
return true;
current = current.InnerException;
}
return false;
}
// ── 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)
{
await Task.Delay(delay, ct);
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) { }
}
}

View File

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

View File

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

View File

@@ -1,173 +0,0 @@
// 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.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);
}
}

View File

@@ -1,135 +0,0 @@
using NATS.Server.Auth;
using Shouldly;
namespace NATS.Server.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);
}
}

View File

@@ -1,481 +0,0 @@
// 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.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();
}
}

View File

@@ -1,211 +0,0 @@
// Tests for account import/export cycle detection.
// Go reference: accounts_test.go TestAccountImportCycleDetection.
using NATS.Server.Auth;
using NATS.Server.Imports;
namespace NATS.Server.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);
}
}

View File

@@ -1,169 +0,0 @@
// Tests for per-account JetStream resource limits.
// Go reference: accounts_test.go TestAccountLimits, TestJetStreamLimits.
using NATS.Server.Auth;
namespace NATS.Server.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);
}
}

View File

@@ -1,117 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,200 +0,0 @@
// 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.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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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");
}
}

View File

@@ -1,46 +0,0 @@
using NATS.Server.Auth;
namespace NATS.Server.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");
}
}

View File

@@ -1,89 +0,0 @@
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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);
}
}

View File

@@ -1,58 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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)
{
await Task.Delay(delay, ct);
return new ExternalAuthDecision(true, "slow");
}
}
}

View File

@@ -1,170 +0,0 @@
// 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.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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,165 +0,0 @@
// 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.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("*");
}
}

View File

@@ -1,28 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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");
}
}

View File

@@ -1,137 +0,0 @@
// 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.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));
}
}

View File

@@ -1,174 +0,0 @@
// 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.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);
}
}

View File

@@ -1,168 +0,0 @@
// 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.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);
}
}

View File

@@ -1,175 +0,0 @@
// 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.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();
}
}

View File

@@ -1,161 +0,0 @@
using NATS.Server.Auth;
using Shouldly;
namespace NATS.Server.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);
}
}

View File

@@ -1,256 +0,0 @@
// 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;
namespace NATS.Server.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 int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
{
var port = GetFreePort();
options.Port = port;
var server = new NatsServer(options, NullLoggerFactory.Instance);
var cts = new CancellationTokenSource();
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
return (server, port, cts);
}
private static async Task<Socket> RawConnectAsync(int port)
{
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await sock.ConnectAsync(IPAddress.Loopback, port);
var buf = new byte[4096];
await sock.ReceiveAsync(buf, SocketFlags.None);
return sock;
}
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var sb = new StringBuilder();
var buf = new byte[4096];
while (!sb.ToString().Contains(expected, StringComparison.Ordinal))
{
int n;
try { n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); }
catch (OperationCanceledException) { break; }
if (n == 0) break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
// ─── 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);
}
}

View File

@@ -1,65 +0,0 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using NATS.Server.Auth;
namespace NATS.Server.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));
}
}

View File

@@ -1,169 +0,0 @@
// 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.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);
}
}

View File

@@ -1,21 +0,0 @@
using NATS.Server;
using NATS.Server.Auth;
namespace NATS.Server.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));
}
}

View File

@@ -1,256 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AuthIntegrationTests
{
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <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 = 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();
}
}
}

View File

@@ -1,56 +0,0 @@
using System.Text.Json;
using NATS.Server.Protocol;
namespace NATS.Server.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");
}
}

View File

@@ -1,172 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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");
}
}

View File

@@ -1,107 +0,0 @@
using NATS.Server.Auth;
namespace NATS.Server.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();
}
}

View File

@@ -1,338 +0,0 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.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 = GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <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) { }
}
}

View File

@@ -1,866 +0,0 @@
using System.Text;
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Auth.Jwt;
using NATS.Server.Protocol;
namespace NATS.Server.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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +0,0 @@
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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);
}
}

View File

@@ -1,82 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.NKeys;
using NATS.Server.Auth;
namespace NATS.Server.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!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync()
{
_port = 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();
});
}
}

View File

@@ -1,119 +0,0 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Client.Core;
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class PermissionIntegrationTests : IAsyncLifetime
{
private NatsServer _server = null!;
private int _port;
private readonly CancellationTokenSource _cts = new();
private Task _serverTask = null!;
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
public async Task InitializeAsync()
{
_port = 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");
}
}

View File

@@ -1,52 +0,0 @@
using NATS.Server.Auth;
namespace NATS.Server.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();
}
}

View File

@@ -1,99 +0,0 @@
namespace NATS.Server.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.>"]);
}
}

View File

@@ -1,116 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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();
}
}

View File

@@ -1,62 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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();
}
}

View File

@@ -1,120 +0,0 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server.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");
}
}