using Microsoft.Extensions.Logging.Abstractions; using NATS.Server; using NATS.Server.Auth; using NATS.Server.Imports; using NATS.Server.Subscriptions; namespace NATS.Server.Tests.Accounts; /// /// Tests for cross-account stream export/import delivery and account isolation semantics. /// Reference: Go accounts_test.go TestAccountIsolationExportImport, TestMultiAccountsIsolation. /// public class AccountImportExportTests { /// /// Verifies that stream export/import wiring allows messages published in the /// exporter account to be delivered to subscribers in the importing account. /// Mirrors Go TestAccountIsolationExportImport (conf variant) at the server API level. /// /// Setup: Account A exports "events.>", Account B imports "events.>" from A. /// When a message is published to "events.order" in Account A, a shadow subscription /// in Account A (wired for the import) should forward to Account B subscribers. /// Since stream import shadow subscription wiring is not yet integrated in ProcessMessage, /// this test exercises the export/import API and ProcessServiceImport path to verify /// cross-account delivery mechanics. /// [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, mapped to "imported.events.>" 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 // through the ProcessServiceImport path (which IS wired in ProcessMessage). exporter.AddServiceExport("svc.>", ServiceResponseType.Singleton, null); importer.AddServiceImport(exporter, "requests.>", "svc.>"); // Subscribe in the exporter account's SubList to receive forwarded messages 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); // Process a service import: simulates client in B publishing "requests.order" // which should transform to "svc.order" and deliver to A's subscriber var si = importer.Imports.Services["requests.>"][0]; server.ProcessServiceImport(si, "requests.order", null, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty); // Verify the message crossed accounts received.Count.ShouldBe(1); received[0].Subject.ShouldBe("svc.order"); received[0].Sid.ShouldBe("s1"); } /// /// Verifies that account isolation prevents cross-account delivery when multiple /// accounts use wildcard subscriptions and NO imports/exports are configured. /// Extends the basic isolation test in AccountIsolationTests by testing with /// three accounts and wildcard (">") subscriptions, matching the Go /// TestMultiAccountsIsolation pattern where multiple importing accounts must /// remain isolated from each other. /// /// Setup: Three accounts (A, B, C), no exports/imports. Each account subscribes /// to "orders.>" via its own SubList. Publishing in A should only match A's /// subscribers; B and C should receive nothing. /// [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"); // Each account has its own independent SubList accountA.SubList.ShouldNotBeSameAs(accountB.SubList); accountB.SubList.ShouldNotBeSameAs(accountC.SubList); // Set up wildcard subscribers in all three accounts var receivedA = new List(); var receivedB = new List(); var receivedC = new List(); 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); // Subscribe to wildcard "orders.>" in each account's SubList 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 }); // Publish in Account A's subject space — only A's SubList is matched 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, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty); } // Account A received the message receivedA.Count.ShouldBe(1); receivedA[0].ShouldBe("orders.client.stream.entry"); // Accounts B and C did NOT receive anything (isolation) receivedB.Count.ShouldBe(0); receivedC.Count.ShouldBe(0); // Now publish in Account B's subject space var resultB = accountB.SubList.Match("orders.other.stream.entry"); resultB.PlainSubs.Length.ShouldBe(1); foreach (var sub in resultB.PlainSubs) { sub.Client?.SendMessage("orders.other.stream.entry", sub.Sid, null, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty); } // Account B received the message receivedB.Count.ShouldBe(1); receivedB[0].ShouldBe("orders.other.stream.entry"); // Account A still has only its original message, Account C still empty receivedA.Count.ShouldBe(1); receivedC.Count.ShouldBe(0); } private static NatsServer CreateTestServer() { var port = GetFreePort(); return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance); } private static int GetFreePort() { using var sock = new System.Net.Sockets.Socket( System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0)); return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port; } /// /// Minimal test double for INatsClient used in import/export tests. /// 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, 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) { } } }