diff --git a/tests/NATS.Server.Tests/Accounts/AuthCalloutTests.cs b/tests/NATS.Server.Tests/Accounts/AuthCalloutTests.cs new file mode 100644 index 0000000..8cbc4aa --- /dev/null +++ b/tests/NATS.Server.Tests/Accounts/AuthCalloutTests.cs @@ -0,0 +1,822 @@ +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; + +/// +/// 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 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 + { + ["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) + { + 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 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 bool QueueOutbound(ReadOnlyMemory data) => true; + public void RemoveSubscription(string sid) { } + } +}