Implement Go's pcd (per-client deferred flush) pattern to reduce write-loop wakeups during fan-out delivery, optimize ack reply string construction with stack-based formatting, cache CompiledFilter on ConsumerHandle, and pool fetch message lists. Durable consumer fetch improves from 0.60x to 0.74x Go.
420 lines
16 KiB
C#
420 lines
16 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Server;
|
|
using NATS.Server.Auth;
|
|
using NATS.Server.Imports;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
using NATS.Server.TestUtilities;
|
|
|
|
namespace NATS.Server.Auth.Tests.Accounts;
|
|
|
|
/// <summary>
|
|
/// Tests for cross-account stream/service export/import delivery, authorization, and mapping.
|
|
/// Reference: Go accounts_test.go TestAccountIsolationExportImport, TestMultiAccountsIsolation,
|
|
/// TestImportAuthorized, TestSimpleMapping, TestAddServiceExport, TestAddStreamExport,
|
|
/// TestServiceExportWithWildcards, TestServiceImportWithWildcards, etc.
|
|
/// </summary>
|
|
public class AccountImportExportTests
|
|
{
|
|
// Go: TestAccountIsolationExportImport server/accounts_test.go:111
|
|
[Fact]
|
|
public void Stream_export_import_delivers_cross_account()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var exporter = server.GetOrCreateAccount("acct-a");
|
|
var importer = server.GetOrCreateAccount("acct-b");
|
|
|
|
// Account A exports "events.>"
|
|
exporter.AddStreamExport("events.>", null);
|
|
exporter.Exports.Streams.ShouldContainKey("events.>");
|
|
|
|
// Account B imports "events.>" from Account A
|
|
importer.AddStreamImport(exporter, "events.>", "imported.events.>");
|
|
importer.Imports.Streams.Count.ShouldBe(1);
|
|
importer.Imports.Streams[0].From.ShouldBe("events.>");
|
|
importer.Imports.Streams[0].To.ShouldBe("imported.events.>");
|
|
importer.Imports.Streams[0].SourceAccount.ShouldBe(exporter);
|
|
|
|
// Also set up a service export/import to verify cross-account message delivery
|
|
exporter.AddServiceExport("svc.>", ServiceResponseType.Singleton, null);
|
|
importer.AddServiceImport(exporter, "requests.>", "svc.>");
|
|
|
|
var received = new List<(string Subject, string Sid)>();
|
|
var mockClient = new TestNatsClient(1, exporter);
|
|
mockClient.OnMessage = (subject, sid, _, _, _) =>
|
|
received.Add((subject, sid));
|
|
|
|
var exportSub = new Subscription { Subject = "svc.order", Sid = "s1", Client = mockClient };
|
|
exporter.SubList.Insert(exportSub);
|
|
|
|
var si = importer.Imports.Services["requests.>"][0];
|
|
server.ProcessServiceImport(si, "requests.order", null,
|
|
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
|
|
|
|
received.Count.ShouldBe(1);
|
|
received[0].Subject.ShouldBe("svc.order");
|
|
received[0].Sid.ShouldBe("s1");
|
|
}
|
|
|
|
// Go: TestMultiAccountsIsolation server/accounts_test.go:304
|
|
[Fact]
|
|
public void Account_isolation_prevents_cross_account_delivery()
|
|
{
|
|
using var server = CreateTestServer();
|
|
|
|
var accountA = server.GetOrCreateAccount("acct-a");
|
|
var accountB = server.GetOrCreateAccount("acct-b");
|
|
var accountC = server.GetOrCreateAccount("acct-c");
|
|
|
|
accountA.SubList.ShouldNotBeSameAs(accountB.SubList);
|
|
accountB.SubList.ShouldNotBeSameAs(accountC.SubList);
|
|
|
|
var receivedA = new List<string>();
|
|
var receivedB = new List<string>();
|
|
var receivedC = new List<string>();
|
|
|
|
var clientA = new TestNatsClient(1, accountA);
|
|
clientA.OnMessage = (subject, _, _, _, _) => receivedA.Add(subject);
|
|
var clientB = new TestNatsClient(2, accountB);
|
|
clientB.OnMessage = (subject, _, _, _, _) => receivedB.Add(subject);
|
|
var clientC = new TestNatsClient(3, accountC);
|
|
clientC.OnMessage = (subject, _, _, _, _) => receivedC.Add(subject);
|
|
|
|
accountA.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "a1", Client = clientA });
|
|
accountB.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "b1", Client = clientB });
|
|
accountC.SubList.Insert(new Subscription { Subject = "orders.>", Sid = "c1", Client = clientC });
|
|
|
|
var resultA = accountA.SubList.Match("orders.client.stream.entry");
|
|
resultA.PlainSubs.Length.ShouldBe(1);
|
|
|
|
foreach (var sub in resultA.PlainSubs)
|
|
sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null, default, default);
|
|
|
|
receivedA.Count.ShouldBe(1);
|
|
receivedB.Count.ShouldBe(0);
|
|
receivedC.Count.ShouldBe(0);
|
|
}
|
|
|
|
// Go: TestAddStreamExport server/accounts_test.go:1560
|
|
[Fact]
|
|
public void Add_stream_export_public()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
|
|
foo.AddStreamExport("foo", null);
|
|
foo.Exports.Streams.ShouldContainKey("foo");
|
|
|
|
// Public export (no approved list) should be authorized for anyone
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
foo.Exports.Streams["foo"].Auth.IsAuthorized(bar).ShouldBeTrue();
|
|
}
|
|
|
|
// Go: TestAddStreamExport server/accounts_test.go:1560
|
|
[Fact]
|
|
public void Add_stream_export_with_approved_accounts()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
var baz = server.GetOrCreateAccount("baz");
|
|
|
|
foo.AddStreamExport("events.>", [bar]);
|
|
foo.Exports.Streams["events.>"].Auth.IsAuthorized(bar).ShouldBeTrue();
|
|
foo.Exports.Streams["events.>"].Auth.IsAuthorized(baz).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestAddServiceExport server/accounts_test.go:1282
|
|
[Fact]
|
|
public void Add_service_export_singleton()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
|
|
foo.AddServiceExport("help", ServiceResponseType.Singleton, null);
|
|
foo.Exports.Services.ShouldContainKey("help");
|
|
foo.Exports.Services["help"].ResponseType.ShouldBe(ServiceResponseType.Singleton);
|
|
}
|
|
|
|
// Go: TestAddServiceExport server/accounts_test.go:1282
|
|
[Fact]
|
|
public void Add_service_export_streamed()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
|
|
foo.AddServiceExport("data.feed", ServiceResponseType.Streamed, null);
|
|
foo.Exports.Services["data.feed"].ResponseType.ShouldBe(ServiceResponseType.Streamed);
|
|
}
|
|
|
|
// Go: TestAddServiceExport server/accounts_test.go:1282
|
|
[Fact]
|
|
public void Add_service_export_chunked()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
|
|
foo.AddServiceExport("photos", ServiceResponseType.Chunked, null);
|
|
foo.Exports.Services["photos"].ResponseType.ShouldBe(ServiceResponseType.Chunked);
|
|
}
|
|
|
|
// Go: TestServiceExportWithWildcards server/accounts_test.go:1319
|
|
[Fact]
|
|
public void Service_export_with_wildcard_subject()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
|
|
foo.AddServiceExport("svc.*", ServiceResponseType.Singleton, null);
|
|
foo.Exports.Services.ShouldContainKey("svc.*");
|
|
}
|
|
|
|
// Go: TestImportAuthorized server/accounts_test.go:761
|
|
[Fact]
|
|
public void Stream_import_requires_matching_export()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
|
|
// Without export, import should fail
|
|
Should.Throw<InvalidOperationException>(() =>
|
|
bar.AddStreamImport(foo, "foo", "import"));
|
|
}
|
|
|
|
// Go: TestImportAuthorized server/accounts_test.go:761
|
|
[Fact]
|
|
public void Stream_import_with_public_export_succeeds()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
|
|
foo.AddStreamExport("foo", null); // public
|
|
bar.AddStreamImport(foo, "foo", "import");
|
|
|
|
bar.Imports.Streams.Count.ShouldBe(1);
|
|
bar.Imports.Streams[0].From.ShouldBe("foo");
|
|
}
|
|
|
|
// Go: TestImportAuthorized server/accounts_test.go:761
|
|
[Fact]
|
|
public void Stream_import_from_restricted_export_unauthorized_account_fails()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
var baz = server.GetOrCreateAccount("baz");
|
|
|
|
foo.AddStreamExport("data.>", [bar]); // only bar authorized
|
|
|
|
// bar can import
|
|
bar.AddStreamImport(foo, "data.>", "imported");
|
|
bar.Imports.Streams.Count.ShouldBe(1);
|
|
|
|
// baz cannot import
|
|
Should.Throw<UnauthorizedAccessException>(() =>
|
|
baz.AddStreamImport(foo, "data.>", "imported"));
|
|
}
|
|
|
|
// Go: TestServiceImportWithWildcards server/accounts_test.go:1463
|
|
[Fact]
|
|
public void Service_import_delivers_to_exporter_via_wildcard()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var exporter = server.GetOrCreateAccount("exporter");
|
|
var importer = server.GetOrCreateAccount("importer");
|
|
|
|
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
|
|
importer.AddServiceImport(exporter, "requests.>", "api.>");
|
|
|
|
var received = new List<string>();
|
|
var mockClient = new TestNatsClient(1, exporter);
|
|
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
|
|
|
|
exporter.SubList.Insert(new Subscription { Subject = "api.orders", Sid = "s1", Client = mockClient });
|
|
|
|
var si = importer.Imports.Services["requests.>"][0];
|
|
server.ProcessServiceImport(si, "requests.orders", null, default, default);
|
|
|
|
received.Count.ShouldBe(1);
|
|
received[0].ShouldBe("api.orders");
|
|
}
|
|
|
|
// Go: TestSimpleMapping server/accounts_test.go:845
|
|
[Fact]
|
|
public void Service_import_maps_literal_subjects()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
|
|
foo.AddServiceExport("help.request", ServiceResponseType.Singleton, null);
|
|
bar.AddServiceImport(foo, "local.help", "help.request");
|
|
|
|
var received = new List<string>();
|
|
var mockClient = new TestNatsClient(1, foo);
|
|
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
|
|
|
|
foo.SubList.Insert(new Subscription { Subject = "help.request", Sid = "s1", Client = mockClient });
|
|
|
|
var si = bar.Imports.Services["local.help"][0];
|
|
server.ProcessServiceImport(si, "local.help", null, default, default);
|
|
|
|
received.Count.ShouldBe(1);
|
|
received[0].ShouldBe("help.request");
|
|
}
|
|
|
|
// Go: TestAccountDuplicateServiceImportSubject server/accounts_test.go:2411
|
|
[Fact]
|
|
public void Duplicate_service_import_same_from_subject_adds_to_list()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
var baz = server.GetOrCreateAccount("baz");
|
|
|
|
foo.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
|
|
baz.AddServiceExport("svc.a", ServiceResponseType.Singleton, null);
|
|
|
|
bar.AddServiceImport(foo, "requests.a", "svc.a");
|
|
bar.AddServiceImport(baz, "requests.a", "svc.a");
|
|
|
|
// Both imports should be stored under the same "from" key
|
|
bar.Imports.Services["requests.a"].Count.ShouldBe(2);
|
|
}
|
|
|
|
// Go: TestCrossAccountRequestReply server/accounts_test.go:1597
|
|
[Fact]
|
|
public void Service_import_preserves_reply_to()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var exporter = server.GetOrCreateAccount("exporter");
|
|
var importer = server.GetOrCreateAccount("importer");
|
|
|
|
exporter.AddServiceExport("api.time", ServiceResponseType.Singleton, null);
|
|
importer.AddServiceImport(exporter, "time.request", "api.time");
|
|
|
|
string? capturedReply = null;
|
|
var mockClient = new TestNatsClient(1, exporter);
|
|
mockClient.OnMessage = (_, _, replyTo, _, _) => capturedReply = replyTo;
|
|
|
|
exporter.SubList.Insert(new Subscription { Subject = "api.time", Sid = "s1", Client = mockClient });
|
|
|
|
var si = importer.Imports.Services["time.request"][0];
|
|
server.ProcessServiceImport(si, "time.request", "_INBOX.abc123", default, default);
|
|
|
|
capturedReply.ShouldBe("_INBOX.abc123");
|
|
}
|
|
|
|
// Go: TestAccountRemoveServiceImport server/accounts_test.go:2447
|
|
[Fact]
|
|
public void Service_import_invalid_flag_prevents_delivery()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var exporter = server.GetOrCreateAccount("exporter");
|
|
var importer = server.GetOrCreateAccount("importer");
|
|
|
|
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
|
|
importer.AddServiceImport(exporter, "requests.>", "api.>");
|
|
|
|
var received = new List<string>();
|
|
var mockClient = new TestNatsClient(1, exporter);
|
|
mockClient.OnMessage = (subject, _, _, _, _) => received.Add(subject);
|
|
|
|
exporter.SubList.Insert(new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient });
|
|
|
|
// Mark the import as invalid
|
|
var si = importer.Imports.Services["requests.>"][0];
|
|
si.Invalid = true;
|
|
|
|
server.ProcessServiceImport(si, "requests.test", null, default, default);
|
|
|
|
received.Count.ShouldBe(0);
|
|
}
|
|
|
|
// Go: TestAccountCheckStreamImportsEqual server/accounts_test.go:2274
|
|
[Fact]
|
|
public void Stream_import_tracks_source_account()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
|
|
foo.AddStreamExport("data.>", null);
|
|
bar.AddStreamImport(foo, "data.>", "feed.>");
|
|
|
|
var si = bar.Imports.Streams[0];
|
|
si.SourceAccount.ShouldBeSameAs(foo);
|
|
si.From.ShouldBe("data.>");
|
|
si.To.ShouldBe("feed.>");
|
|
}
|
|
|
|
// Go: TestExportAuth — revoked accounts cannot import
|
|
[Fact]
|
|
public void Revoked_account_cannot_access_export()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
|
|
var auth = new ExportAuth
|
|
{
|
|
RevokedAccounts = new Dictionary<string, long> { [bar.Name] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
|
|
};
|
|
|
|
auth.IsAuthorized(bar).ShouldBeFalse();
|
|
}
|
|
|
|
// Go: TestExportAuth — public export with no restrictions
|
|
[Fact]
|
|
public void Public_export_authorizes_any_account()
|
|
{
|
|
using var server = CreateTestServer();
|
|
var foo = server.GetOrCreateAccount("foo");
|
|
var bar = server.GetOrCreateAccount("bar");
|
|
|
|
var auth = new ExportAuth(); // No restrictions
|
|
auth.IsAuthorized(foo).ShouldBeTrue();
|
|
auth.IsAuthorized(bar).ShouldBeTrue();
|
|
}
|
|
|
|
private static NatsServer CreateTestServer()
|
|
{
|
|
var port = TestPortAllocator.GetFreePort();
|
|
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
|
|
}
|
|
/// <summary>
|
|
/// Minimal test double for INatsClient used in import/export tests.
|
|
/// </summary>
|
|
private sealed class TestNatsClient(ulong id, Account account) : INatsClient
|
|
{
|
|
public ulong Id => id;
|
|
public ClientKind Kind => ClientKind.Client;
|
|
public Account? Account => account;
|
|
public Protocol.ClientOptions? ClientOpts => null;
|
|
public ClientPermissions? Permissions => null;
|
|
|
|
public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? OnMessage { get; set; }
|
|
|
|
public void SendMessage(string subject, string sid, string? replyTo,
|
|
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
|
{
|
|
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
|
|
}
|
|
|
|
public void SendMessageNoFlush(string subject, string sid, string? replyTo,
|
|
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
|
{
|
|
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
|
|
}
|
|
|
|
public void SignalFlush() { }
|
|
|
|
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
|
|
|
|
public void RemoveSubscription(string sid) { }
|
|
}
|
|
}
|