feat: add auth callout and account limit tests (Go parity)

Adds 37 tests in AuthCalloutTests.cs covering auth callout handler
registration/invocation, timeout, account assignment, max-connections
and max-subscriptions enforcement, user revocation (including wildcard
revocation), and cross-account service import/export communication.
This commit is contained in:
Joseph Doherty
2026-02-24 09:05:28 -05:00
parent 9317b92a9c
commit fde1710eb0

View File

@@ -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;
/// <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) { }
}
}