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.
823 lines
30 KiB
C#
823 lines
30 KiB
C#
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) { }
|
|
}
|
|
}
|