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; /// /// 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. /// 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.Empty, ReadOnlyMemory.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(); 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); 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(() => 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(() => 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(); 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(); 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(); 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 { [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); } /// /// 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 void SendMessageNoFlush(string subject, string sid, string? replyTo, ReadOnlyMemory headers, ReadOnlyMemory payload) { OnMessage?.Invoke(subject, sid, replyTo, headers, payload); } public void SignalFlush() { } public bool QueueOutbound(ReadOnlyMemory data) => true; public void RemoveSubscription(string sid) { } } }