using System.Net; using System.Net.Sockets; using Microsoft.Extensions.Logging.Abstractions; using NATS.Client.Core; using NATS.Server; using NATS.Server.Auth; using NATS.Server.Imports; using NATS.Server.Protocol; using NATS.Server.Subscriptions; using NATS.Server.TestUtilities; namespace NATS.Server.Auth.Tests.Accounts; /// /// 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. /// public class AuthCalloutTests { private static NatsServer CreateTestServer(NatsOptions? options = null) { var port = TestPortAllocator.GetFreePort(); options ??= new NatsOptions(); options.Port = port; return new NatsServer(options, NullLoggerFactory.Instance); } private static async Task<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options) { var port = TestPortAllocator.GetFreePort(); options.Port = port; var server = new NatsServer(options, NullLoggerFactory.Instance); var cts = new CancellationTokenSource(); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); return (server, port, cts); } private static bool ExceptionChainContains(Exception ex, string substring) { Exception? current = ex; while (current != null) { if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase)) return true; current = current.InnerException; } return false; } // ── Auth callout handler registration ──────────────────────────────────── // Go: TestAuthCallout auth_callout_test.go — callout registered in options [Fact] public void AuthCallout_handler_registered_in_options() { var client = new StubExternalAuthClient(allow: true, identity: "callout-user"); var options = new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2), }, }; var authService = AuthService.Build(options); authService.IsAuthRequired.ShouldBeTrue(); } // Go: TestAuthCallout auth_callout_test.go — callout invoked with valid credentials [Fact] public void AuthCallout_valid_credentials_returns_auth_result() { var client = new StubExternalAuthClient(allow: true, identity: "callout-user", account: "acct-a"); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) }, }); var result = authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Username = "user", Password = "pass" }, Nonce = [], }); result.ShouldNotBeNull(); result!.Identity.ShouldBe("callout-user"); result.AccountName.ShouldBe("acct-a"); } // Go: TestAuthCallout auth_callout_test.go — callout with invalid credentials fails [Fact] public void AuthCallout_invalid_credentials_returns_null() { var client = new StubExternalAuthClient(allow: false); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) }, }); var result = authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Username = "bad-user", Password = "bad-pass" }, Nonce = [], }); result.ShouldBeNull(); } // Go: TestAuthCalloutTimeout auth_callout_test.go — callout timeout returns null [Fact] public void AuthCallout_timeout_returns_null() { var client = new DelayedExternalAuthClient(delay: TimeSpan.FromSeconds(5)); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromMilliseconds(50), }, }); var result = authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Username = "user", Password = "pass" }, Nonce = [], }); result.ShouldBeNull(); } // Go: TestAuthCallout auth_callout_test.go — callout response assigns account [Fact] public void AuthCallout_response_assigns_account_name() { var client = new StubExternalAuthClient(allow: true, identity: "alice", account: "tenant-1"); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) }, }); var result = authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Username = "alice", Password = "x" }, Nonce = [], }); result.ShouldNotBeNull(); result!.AccountName.ShouldBe("tenant-1"); } // Go: TestAuthCallout auth_callout_test.go — callout with no account in response [Fact] public void AuthCallout_no_account_in_response_returns_null_account_name() { var client = new StubExternalAuthClient(allow: true, identity: "anonymous-user", account: null); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) }, }); var result = authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Username = "anon", Password = "x" }, Nonce = [], }); result.ShouldNotBeNull(); result!.AccountName.ShouldBeNull(); } // Go: TestAuthCallout auth_callout_test.go — callout invoked (receives request data) [Fact] public void AuthCallout_receives_username_and_password() { var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u"); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) }, }); authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Username = "myuser", Password = "mypass" }, Nonce = [], }); captureClient.LastRequest.ShouldNotBeNull(); captureClient.LastRequest!.Username.ShouldBe("myuser"); captureClient.LastRequest.Password.ShouldBe("mypass"); } // Go: TestAuthCallout auth_callout_test.go — callout invoked with token [Fact] public void AuthCallout_receives_token() { var captureClient = new CapturingExternalAuthClient(allow: true, identity: "u"); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = captureClient, Timeout = TimeSpan.FromSeconds(2) }, }); authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Token = "my-bearer-token" }, Nonce = [], }); captureClient.LastRequest.ShouldNotBeNull(); captureClient.LastRequest!.Token.ShouldBe("my-bearer-token"); } // Go: TestAuthCallout auth_callout_test.go — callout invoked for each connection [Fact] public void AuthCallout_invoked_for_each_authentication_attempt() { var client = new CountingExternalAuthClient(allow: true, identity: "u"); var authService = AuthService.Build(new NatsOptions { ExternalAuth = new ExternalAuthOptions { Enabled = true, Client = client, Timeout = TimeSpan.FromSeconds(2) }, }); for (int i = 0; i < 5; i++) { authService.Authenticate(new ClientAuthContext { Opts = new ClientOptions { Username = $"user{i}", Password = "p" }, Nonce = [], }); } client.CallCount.ShouldBe(5); } // ── Account limits: max connections ────────────────────────────────────── // Go: TestAccountMaxConns accounts_test.go — max connections limit enforced [Fact] public void Account_max_connections_enforced() { using var server = CreateTestServer(); var acc = server.GetOrCreateAccount("limited"); acc.MaxConnections = 2; acc.AddClient(1).ShouldBeTrue(); acc.AddClient(2).ShouldBeTrue(); acc.AddClient(3).ShouldBeFalse(); // limit reached } // Go: TestAccountMaxConns accounts_test.go — zero max connections means unlimited [Fact] public void Account_zero_max_connections_means_unlimited() { using var server = CreateTestServer(); var acc = server.GetOrCreateAccount("unlimited"); acc.MaxConnections = 0; // unlimited for (ulong i = 1; i <= 100; i++) acc.AddClient(i).ShouldBeTrue(); acc.ClientCount.ShouldBe(100); } // Go: TestAccountMaxConns accounts_test.go — connection count tracked [Fact] public void Account_connection_count_tracking() { using var server = CreateTestServer(); var acc = server.GetOrCreateAccount("tracked"); acc.AddClient(1); acc.AddClient(2); acc.AddClient(3); acc.ClientCount.ShouldBe(3); } // Go: TestAccountMaxConns accounts_test.go — limits reset after disconnect [Fact] public void Account_connection_limit_resets_after_disconnect() { using var server = CreateTestServer(); var acc = server.GetOrCreateAccount("resetable"); acc.MaxConnections = 2; acc.AddClient(1).ShouldBeTrue(); acc.AddClient(2).ShouldBeTrue(); acc.AddClient(3).ShouldBeFalse(); // full acc.RemoveClient(1); // disconnect one acc.AddClient(3).ShouldBeTrue(); // now room for another } // Go: TestAccountMaxConns accounts_test.go — different accounts have independent limits [Fact] public void Account_limits_are_per_account_independent() { using var server = CreateTestServer(); var accA = server.GetOrCreateAccount("acct-a"); var accB = server.GetOrCreateAccount("acct-b"); accA.MaxConnections = 2; accB.MaxConnections = 5; accA.AddClient(1).ShouldBeTrue(); accA.AddClient(2).ShouldBeTrue(); accA.AddClient(3).ShouldBeFalse(); // A is full // B is independent — should still allow accB.AddClient(10).ShouldBeTrue(); accB.AddClient(11).ShouldBeTrue(); accB.AddClient(12).ShouldBeTrue(); } // Go: TestAccountMaxConns accounts_test.go — config-driven max connections [Fact] public void Account_from_config_applies_max_connections() { using var server = CreateTestServer(new NatsOptions { Accounts = new Dictionary { ["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 { ["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(); 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(); 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(); var receivedB = new List(); 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(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 AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) => Task.FromResult(new ExternalAuthDecision(allow, identity, account)); } private sealed class DelayedExternalAuthClient(TimeSpan delay) : IExternalAuthClient { public async Task AuthorizeAsync(ExternalAuthRequest request, CancellationToken ct) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); await using var reg = ct.Register(() => tcs.TrySetCanceled(ct)); using var timer = new Timer(_ => tcs.TrySetResult(true), null, delay, Timeout.InfiniteTimeSpan); await tcs.Task; return new ExternalAuthDecision(true, "delayed"); } } private sealed class CapturingExternalAuthClient(bool allow, string identity) : IExternalAuthClient { public ExternalAuthRequest? LastRequest { get; private set; } public Task 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 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, ReadOnlyMemory>? OnMessage { get; set; } public void SendMessage(string subject, string sid, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload) { OnMessage?.Invoke(subject, sid, replyTo, headers, payload); } public void SendMessageNoFlush(string subject, string sid, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload) { OnMessage?.Invoke(subject, sid, replyTo, headers, payload); } public void SignalFlush() { } public bool QueueOutbound(ReadOnlyMemory data) => true; public void RemoveSubscription(string sid) { } } }