diff --git a/docs/plans/2026-02-24-full-production-parity-plan.md.tasks.json b/docs/plans/2026-02-24-full-production-parity-plan.md.tasks.json index a668d0d..43e44c1 100644 --- a/docs/plans/2026-02-24-full-production-parity-plan.md.tasks.json +++ b/docs/plans/2026-02-24-full-production-parity-plan.md.tasks.json @@ -1,12 +1,12 @@ { "planPath": "docs/plans/2026-02-24-full-production-parity-plan.md", "tasks": [ - {"id": 39, "subject": "Task 0: Inventory and Scaffolding", "status": "pending"}, - {"id": 40, "subject": "Task 1: AVL Tree / SequenceSet (16 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 41, "subject": "Task 2: Subject Tree ART (59 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 42, "subject": "Task 3: Generic Subject List (21 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 43, "subject": "Task 4: Time Hash Wheel (8 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 44, "subject": "Task 5: StreamStore/ConsumerStore Interfaces", "status": "pending", "blockedBy": [39]}, + {"id": 39, "subject": "Task 0: Inventory and Scaffolding", "status": "completed"}, + {"id": 40, "subject": "Task 1: AVL Tree / SequenceSet (16 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 41, "subject": "Task 2: Subject Tree ART (59 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 42, "subject": "Task 3: Generic Subject List (21 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 43, "subject": "Task 4: Time Hash Wheel (8 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 44, "subject": "Task 5: StreamStore/ConsumerStore Interfaces", "status": "completed", "blockedBy": [39]}, {"id": 45, "subject": "Task 6: FileStore Block Engine (160 tests)", "status": "pending", "blockedBy": [40, 41, 42, 43, 44]}, {"id": 46, "subject": "Task 7: RAFT Core Types", "status": "pending", "blockedBy": [45]}, {"id": 47, "subject": "Task 8: RAFT Wire Format (10 tests)", "status": "pending", "blockedBy": [46]}, @@ -16,16 +16,16 @@ {"id": 51, "subject": "Task 12: JetStream Meta Controller (30 tests)", "status": "pending", "blockedBy": [50]}, {"id": 52, "subject": "Task 13: Per-Stream/Consumer RAFT Groups (40 tests)", "status": "pending", "blockedBy": [51]}, {"id": 53, "subject": "Task 14: NORACE Concurrency Suite (30 tests)", "status": "pending", "blockedBy": [52]}, - {"id": 54, "subject": "Task 15: Config Reload Tests (70 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 55, "subject": "Task 16: MQTT Bridge Tests (73 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 56, "subject": "Task 17: Leaf Node Tests (108 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 57, "subject": "Task 18: Accounts/Auth Tests (49 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 58, "subject": "Task 19: Gateway Tests (86 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 59, "subject": "Task 20: Route Tests (68 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 60, "subject": "Task 21: Monitoring Tests (93 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 61, "subject": "Task 22: Client Protocol Tests (52 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 62, "subject": "Task 23: JetStream API Tests (292 tests)", "status": "pending", "blockedBy": [39]}, - {"id": 63, "subject": "Task 24: JetStream Cluster Tests (100 tests)", "status": "pending", "blockedBy": [39]} + {"id": 54, "subject": "Task 15: Config Reload Tests (70 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 55, "subject": "Task 16: MQTT Bridge Tests (73 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 56, "subject": "Task 17: Leaf Node Tests (108 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 57, "subject": "Task 18: Accounts/Auth Tests (49 tests)", "status": "in_progress", "blockedBy": [39]}, + {"id": 58, "subject": "Task 19: Gateway Tests (86 tests)", "status": "in_progress", "blockedBy": [39]}, + {"id": 59, "subject": "Task 20: Route Tests (68 tests)", "status": "in_progress", "blockedBy": [39]}, + {"id": 60, "subject": "Task 21: Monitoring Tests (93 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 61, "subject": "Task 22: Client Protocol Tests (52 tests)", "status": "completed", "blockedBy": [39]}, + {"id": 62, "subject": "Task 23: JetStream API Tests (292 tests)", "status": "in_progress", "blockedBy": [39]}, + {"id": 63, "subject": "Task 24: JetStream Cluster Tests (100 tests)", "status": "in_progress", "blockedBy": [39]} ], - "lastUpdated": "2026-02-24T00:00:00Z" + "lastUpdated": "2026-02-24T02:45:00Z" } diff --git a/tests/NATS.Server.Tests/Accounts/AccountImportExportTests.cs b/tests/NATS.Server.Tests/Accounts/AccountImportExportTests.cs index d79f08c..4f9604f 100644 --- a/tests/NATS.Server.Tests/Accounts/AccountImportExportTests.cs +++ b/tests/NATS.Server.Tests/Accounts/AccountImportExportTests.cs @@ -7,23 +7,14 @@ 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. +/// 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 { - /// - /// 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. - /// + // Go: TestAccountIsolationExportImport server/accounts_test.go:111 [Fact] public void Stream_export_import_delivers_cross_account() { @@ -36,7 +27,7 @@ public class AccountImportExportTests exporter.AddStreamExport("events.>", null); exporter.Exports.Streams.ShouldContainKey("events.>"); - // Account B imports "events.>" from Account A, mapped to "imported.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.>"); @@ -44,11 +35,9 @@ public class AccountImportExportTests 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, _, _, _) => @@ -57,30 +46,16 @@ public class AccountImportExportTests 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. - /// + // Go: TestMultiAccountsIsolation server/accounts_test.go:304 [Fact] public void Account_isolation_prevents_cross_account_delivery() { @@ -90,11 +65,9 @@ public class AccountImportExportTests 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(); @@ -106,46 +79,303 @@ public class AccountImportExportTests 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); - } + sub.Client?.SendMessage("orders.client.stream.entry", sub.Sid, null, default, default); - // 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); + // Go: TestAddStreamExport server/accounts_test.go:1560 + [Fact] + public void Add_stream_export_public() + { + using var server = CreateTestServer(); + var foo = server.GetOrCreateAccount("foo"); - foreach (var sub in resultB.PlainSubs) + 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 { - sub.Client?.SendMessage("orders.other.stream.entry", sub.Sid, null, - ReadOnlyMemory.Empty, ReadOnlyMemory.Empty); - } + RevokedAccounts = new Dictionary { [bar.Name] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }, + }; - // Account B received the message - receivedB.Count.ShouldBe(1); - receivedB[0].ShouldBe("orders.other.stream.entry"); + auth.IsAuthorized(bar).ShouldBeFalse(); + } - // Account A still has only its original message, Account C still empty - receivedA.Count.ShouldBe(1); - receivedC.Count.ShouldBe(0); + // 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() diff --git a/tests/NATS.Server.Tests/Accounts/AccountIsolationTests.cs b/tests/NATS.Server.Tests/Accounts/AccountIsolationTests.cs new file mode 100644 index 0000000..7809b07 --- /dev/null +++ b/tests/NATS.Server.Tests/Accounts/AccountIsolationTests.cs @@ -0,0 +1,521 @@ +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.Subscriptions; + +namespace NATS.Server.Tests.Accounts; + +/// +/// Tests for account creation, registration, isolation, and basic account lifecycle. +/// Reference: Go accounts_test.go — TestRegisterDuplicateAccounts, TestAccountIsolation, +/// TestAccountFromOptions, TestAccountSimpleConfig, TestAccountParseConfig, +/// TestMultiAccountsIsolation, TestNewAccountAndRequireNewAlwaysError, etc. +/// +public class AccountIsolationTests +{ + 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; + } + + // Go: TestRegisterDuplicateAccounts server/accounts_test.go:50 + [Fact] + public void Register_duplicate_account_returns_existing() + { + using var server = CreateTestServer(); + + var foo = server.GetOrCreateAccount("$foo"); + var foo2 = server.GetOrCreateAccount("$foo"); + + // GetOrCreateAccount returns the same instance if already registered + foo.ShouldBeSameAs(foo2); + } + + // Go: TestAccountIsolation server/accounts_test.go:57 + [Fact] + public void Account_isolation_separate_sublists() + { + using var server = CreateTestServer(); + + var fooAcc = server.GetOrCreateAccount("$foo"); + var barAcc = server.GetOrCreateAccount("$bar"); + + // Accounts must have different SubLists + fooAcc.SubList.ShouldNotBeSameAs(barAcc.SubList); + fooAcc.Name.ShouldBe("$foo"); + barAcc.Name.ShouldBe("$bar"); + } + + // Go: TestAccountIsolation server/accounts_test.go:57 + [Fact] + public void Account_isolation_messages_do_not_cross() + { + using var server = CreateTestServer(); + + var fooAcc = server.GetOrCreateAccount("$foo"); + var barAcc = server.GetOrCreateAccount("$bar"); + + var receivedFoo = new List(); + var receivedBar = new List(); + + var clientFoo = new TestNatsClient(1, fooAcc); + clientFoo.OnMessage = (subject, _, _, _, _) => receivedFoo.Add(subject); + var clientBar = new TestNatsClient(2, barAcc); + clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject); + + // Subscribe to "foo" in both accounts + barAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientBar }); + fooAcc.SubList.Insert(new Subscription { Subject = "foo", Sid = "1", Client = clientFoo }); + + // Publish to foo account's SubList + var result = fooAcc.SubList.Match("foo"); + foreach (var sub in result.PlainSubs) + sub.Client?.SendMessage("foo", sub.Sid, null, default, default); + + // Only foo account subscriber should receive + receivedFoo.Count.ShouldBe(1); + receivedBar.Count.ShouldBe(0); + } + + // Go: TestAccountFromOptions server/accounts_test.go:386 + [Fact] + public void Account_from_options_creates_accounts() + { + using var server = CreateTestServer(new NatsOptions + { + Accounts = new Dictionary + { + ["foo"] = new AccountConfig(), + ["bar"] = new AccountConfig(), + }, + }); + + var fooAcc = server.GetOrCreateAccount("foo"); + var barAcc = server.GetOrCreateAccount("bar"); + + fooAcc.ShouldNotBeNull(); + barAcc.ShouldNotBeNull(); + fooAcc.SubList.ShouldNotBeNull(); + barAcc.SubList.ShouldNotBeNull(); + } + + // Go: TestAccountFromOptions server/accounts_test.go:386 + [Fact] + public void Account_from_options_applies_config() + { + using var server = CreateTestServer(new NatsOptions + { + Accounts = new Dictionary + { + ["limited"] = new AccountConfig { MaxConnections = 5, MaxSubscriptions = 10 }, + }, + }); + + var acc = server.GetOrCreateAccount("limited"); + acc.MaxConnections.ShouldBe(5); + acc.MaxSubscriptions.ShouldBe(10); + } + + // Go: TestMultiAccountsIsolation server/accounts_test.go:304 + [Fact] + public async Task Multi_accounts_isolation_only_correct_importer_receives() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "public", Password = "public", Account = "PUBLIC" }, + new User { Username = "client", Password = "client", Account = "CLIENT" }, + new User { Username = "client2", Password = "client2", Account = "CLIENT2" }, + ], + }); + + try + { + await using var publicNc = new NatsConnection(new NatsOpts + { + Url = $"nats://public:public@127.0.0.1:{port}", + }); + await using var clientNc = new NatsConnection(new NatsOpts + { + Url = $"nats://client:client@127.0.0.1:{port}", + }); + await using var client2Nc = new NatsConnection(new NatsOpts + { + Url = $"nats://client2:client2@127.0.0.1:{port}", + }); + + await publicNc.ConnectAsync(); + await clientNc.ConnectAsync(); + await client2Nc.ConnectAsync(); + + // Subscribe on both client accounts + await using var clientSub = await clientNc.SubscribeCoreAsync("orders.>"); + await using var client2Sub = await client2Nc.SubscribeCoreAsync("orders.>"); + await clientNc.PingAsync(); + await client2Nc.PingAsync(); + + // Publish from the same account as client - CLIENT should get it + await clientNc.PublishAsync("orders.entry", "test1"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var msg = await clientSub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("test1"); + + // CLIENT2 should NOT receive messages from CLIENT account + using var shortTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + try + { + await client2Sub.Msgs.ReadAsync(shortTimeout.Token); + throw new Exception("CLIENT2 should not have received a message from CLIENT account"); + } + catch (OperationCanceledException) + { + // Expected + } + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestAccountIsolation server/accounts_test.go:57 (integration variant) + [Fact] + public async Task Same_account_receives_messages_integration() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "alice", Password = "pass", Account = "acct-a" }, + new User { Username = "charlie", Password = "pass", Account = "acct-a" }, + ], + }); + + try + { + await using var alice = new NatsConnection(new NatsOpts + { + Url = $"nats://alice:pass@127.0.0.1:{port}", + }); + await using var charlie = new NatsConnection(new NatsOpts + { + Url = $"nats://charlie:pass@127.0.0.1:{port}", + }); + + await alice.ConnectAsync(); + await charlie.ConnectAsync(); + + await using var sub = await charlie.SubscribeCoreAsync("test.subject"); + await charlie.PingAsync(); + + await alice.PublishAsync("test.subject", "from-alice"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("from-alice"); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestAccountIsolation server/accounts_test.go:57 (integration variant) + [Fact] + public async Task Different_account_does_not_receive_messages_integration() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "alice", Password = "pass", Account = "acct-a" }, + new User { Username = "bob", Password = "pass", Account = "acct-b" }, + ], + }); + + try + { + await using var alice = new NatsConnection(new NatsOpts + { + Url = $"nats://alice:pass@127.0.0.1:{port}", + }); + await using var bob = new NatsConnection(new NatsOpts + { + Url = $"nats://bob:pass@127.0.0.1:{port}", + }); + + await alice.ConnectAsync(); + await bob.ConnectAsync(); + + await using var sub = await bob.SubscribeCoreAsync("test.subject"); + await bob.PingAsync(); + + await alice.PublishAsync("test.subject", "from-alice"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + try + { + await sub.Msgs.ReadAsync(timeout.Token); + throw new Exception("Bob should not have received a message from a different account"); + } + catch (OperationCanceledException) + { + // Expected + } + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestMultiAccountsIsolation server/accounts_test.go:304 + [Fact] + public void Three_account_isolation_with_wildcard_subs() + { + 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 }); + + // Publish in Account A's subject space + var resultA = accountA.SubList.Match("orders.client.stream.entry"); + 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); + + // Publish in Account B + var resultB = accountB.SubList.Match("orders.other.stream.entry"); + foreach (var sub in resultB.PlainSubs) + sub.Client?.SendMessage("orders.other.stream.entry", sub.Sid, null, default, default); + + receivedA.Count.ShouldBe(1); // unchanged + receivedB.Count.ShouldBe(1); + receivedC.Count.ShouldBe(0); + } + + // Go: TestAccountGlobalDefault server/accounts_test.go:2254 + [Fact] + public void Global_account_has_default_name() + { + Account.GlobalAccountName.ShouldBe("$G"); + } + + // Go: TestRegisterDuplicateAccounts server/accounts_test.go:50 + [Fact] + public void GetOrCreateAccount_returns_same_instance() + { + using var server = CreateTestServer(); + + var acc1 = server.GetOrCreateAccount("test-acc"); + var acc2 = server.GetOrCreateAccount("test-acc"); + + acc1.ShouldBeSameAs(acc2); + } + + // Go: TestAccountIsolation server/accounts_test.go:57 — verifies accounts are different objects + [Fact] + public void Accounts_are_distinct_objects() + { + using var server = CreateTestServer(); + + var foo = server.GetOrCreateAccount("foo"); + var bar = server.GetOrCreateAccount("bar"); + + foo.ShouldNotBeSameAs(bar); + foo.Name.ShouldNotBe(bar.Name); + } + + // Go: TestAccountMapsUsers server/accounts_test.go:2138 + [Fact] + public async Task Users_mapped_to_correct_accounts() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "alice", Password = "pass", Account = "acct-a" }, + new User { Username = "bob", Password = "pass", Account = "acct-b" }, + ], + }); + + try + { + // Both should connect successfully to their respective accounts + await using var alice = new NatsConnection(new NatsOpts + { + Url = $"nats://alice:pass@127.0.0.1:{port}", + }); + await using var bob = new NatsConnection(new NatsOpts + { + Url = $"nats://bob:pass@127.0.0.1:{port}", + }); + + await alice.ConnectAsync(); + await alice.PingAsync(); + await bob.ConnectAsync(); + await bob.PingAsync(); + + // Verify isolation: publish in A, subscribe in B should not receive + await using var bobSub = await bob.SubscribeCoreAsync("mapped.test"); + await bob.PingAsync(); + + await alice.PublishAsync("mapped.test", "hello"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + try + { + await bobSub.Msgs.ReadAsync(timeout.Token); + throw new Exception("Bob should not receive messages from Alice's account"); + } + catch (OperationCanceledException) + { + // Expected — accounts are isolated + } + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestAccountIsolation server/accounts_test.go:57 — wildcard subscriber in isolated account + [Fact] + public void Wildcard_subscriber_in_isolated_account_no_cross_delivery() + { + using var server = CreateTestServer(); + + var fooAcc = server.GetOrCreateAccount("$foo"); + var barAcc = server.GetOrCreateAccount("$bar"); + + var receivedBar = new List(); + var clientBar = new TestNatsClient(2, barAcc); + clientBar.OnMessage = (subject, _, _, _, _) => receivedBar.Add(subject); + + // Bar subscribes to wildcard + barAcc.SubList.Insert(new Subscription { Subject = ">", Sid = "1", Client = clientBar }); + + // Publish in foo account + var result = fooAcc.SubList.Match("anything.goes.here"); + result.PlainSubs.Length.ShouldBe(0); // No subscribers in foo + + // Bar should still have no messages + receivedBar.Count.ShouldBe(0); + } + + // Go: TestAccountIsolation server/accounts_test.go:57 — multiple subs same account + [Fact] + public void Multiple_subscribers_same_account_all_receive() + { + using var server = CreateTestServer(); + + var acc = server.GetOrCreateAccount("test"); + + var received1 = new List(); + var received2 = new List(); + + var client1 = new TestNatsClient(1, acc); + client1.OnMessage = (subject, _, _, _, _) => received1.Add(subject); + var client2 = new TestNatsClient(2, acc); + client2.OnMessage = (subject, _, _, _, _) => received2.Add(subject); + + acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s1", Client = client1 }); + acc.SubList.Insert(new Subscription { Subject = "events.>", Sid = "s2", Client = client2 }); + + var result = acc.SubList.Match("events.order.created"); + result.PlainSubs.Length.ShouldBe(2); + + foreach (var sub in result.PlainSubs) + sub.Client?.SendMessage("events.order.created", sub.Sid, null, default, default); + + received1.Count.ShouldBe(1); + received2.Count.ShouldBe(1); + } + + /// + /// Minimal test double for INatsClient used in isolation 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) { } + } +} diff --git a/tests/NATS.Server.Tests/Accounts/AuthMechanismTests.cs b/tests/NATS.Server.Tests/Accounts/AuthMechanismTests.cs new file mode 100644 index 0000000..7ad3cc7 --- /dev/null +++ b/tests/NATS.Server.Tests/Accounts/AuthMechanismTests.cs @@ -0,0 +1,599 @@ +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.Protocol; + +namespace NATS.Server.Tests.Accounts; + +/// +/// Tests for authentication mechanisms: username/password, token, NKey-based auth, +/// no-auth-user fallback, multi-user, and AuthService orchestration. +/// Reference: Go auth_test.go — TestUserClone*, TestNoAuthUser, TestUserConnectionDeadline, etc. +/// Reference: Go accounts_test.go — TestAccountMapsUsers. +/// +public class AuthMechanismTests +{ + 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 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; + } + + // Go: TestUserCloneNilPermissions server/auth_test.go:34 + [Fact] + public void User_with_nil_permissions() + { + var user = new User + { + Username = "foo", + Password = "bar", + }; + + user.Permissions.ShouldBeNull(); + } + + // Go: TestUserClone server/auth_test.go:53 + [Fact] + public void User_with_permissions_has_correct_fields() + { + var user = new User + { + Username = "foo", + Password = "bar", + Permissions = new Permissions + { + Publish = new SubjectPermission { Allow = ["foo"] }, + Subscribe = new SubjectPermission { Allow = ["bar"] }, + }, + }; + + user.Username.ShouldBe("foo"); + user.Password.ShouldBe("bar"); + user.Permissions.ShouldNotBeNull(); + user.Permissions.Publish!.Allow![0].ShouldBe("foo"); + user.Permissions.Subscribe!.Allow![0].ShouldBe("bar"); + } + + // Go: TestUserClonePermissionsNoLists server/auth_test.go:80 + [Fact] + public void User_with_empty_permissions() + { + var user = new User + { + Username = "foo", + Password = "bar", + Permissions = new Permissions(), + }; + + user.Permissions!.Publish.ShouldBeNull(); + user.Permissions!.Subscribe.ShouldBeNull(); + } + + // Go: TestNoAuthUser (token auth success) server/auth_test.go:225 + [Fact] + public async Task Token_auth_success() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Authorization = "s3cr3t", + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://s3cr3t@127.0.0.1:{port}", + }); + await client.ConnectAsync(); + await client.PingAsync(); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: auth mechanism — token auth failure + [Fact] + public async Task Token_auth_failure_disconnects() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Authorization = "s3cr3t", + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://wrongtoken@127.0.0.1:{port}", + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue( + $"Expected 'Authorization Violation' in exception chain, but got: {ex}"); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: auth mechanism — user/password success + [Fact] + public async Task UserPassword_auth_success() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Username = "admin", + Password = "secret", + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://admin:secret@127.0.0.1:{port}", + }); + await client.ConnectAsync(); + await client.PingAsync(); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: auth mechanism — user/password failure + [Fact] + public async Task UserPassword_auth_failure_disconnects() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Username = "admin", + Password = "secret", + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://admin:wrong@127.0.0.1:{port}", + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue( + $"Expected 'Authorization Violation' in exception chain, but got: {ex}"); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestNoAuthUser server/auth_test.go:225 — multi-user auth + [Fact] + public async Task MultiUser_auth_each_user_succeeds() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "alice", Password = "pass1" }, + new User { Username = "bob", Password = "pass2" }, + ], + }); + + try + { + await using var alice = new NatsConnection(new NatsOpts + { + Url = $"nats://alice:pass1@127.0.0.1:{port}", + }); + await using var bob = new NatsConnection(new NatsOpts + { + Url = $"nats://bob:pass2@127.0.0.1:{port}", + }); + + await alice.ConnectAsync(); + await alice.PingAsync(); + await bob.ConnectAsync(); + await bob.PingAsync(); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestNoAuthUser server/auth_test.go:225 — wrong user password + [Fact] + public async Task MultiUser_wrong_password_fails() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "alice", Password = "pass1" }, + ], + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://alice:wrong@127.0.0.1:{port}", + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue( + $"Expected 'Authorization Violation', but got: {ex}"); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: auth mechanism — no credentials with auth required + [Fact] + public async Task No_credentials_when_auth_required_disconnects() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Authorization = "s3cr3t", + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{port}", + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue( + $"Expected 'Authorization Violation', but got: {ex}"); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: auth mechanism — no auth configured allows all + [Fact] + public async Task No_auth_configured_allows_all() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions()); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{port}", + }); + await client.ConnectAsync(); + await client.PingAsync(); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestNoAuthUser server/auth_test.go:225 — no_auth_user fallback + [Fact] + public async Task NoAuthUser_fallback_allows_unauthenticated_connection() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "foo", Password = "pwd1", Account = "FOO" }, + new User { Username = "bar", Password = "pwd2", Account = "BAR" }, + ], + NoAuthUser = "foo", + }); + + try + { + // Connect without credentials — should use no_auth_user "foo" + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{port}", + }); + await client.ConnectAsync(); + await client.PingAsync(); + + // Explicit auth also still works + await using var bar = new NatsConnection(new NatsOpts + { + Url = $"nats://bar:pwd2@127.0.0.1:{port}", + }); + await bar.ConnectAsync(); + await bar.PingAsync(); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: TestNoAuthUser server/auth_test.go:225 — invalid pwd with no_auth_user still fails + [Fact] + public async Task NoAuthUser_wrong_password_still_fails() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User { Username = "foo", Password = "pwd1", Account = "FOO" }, + new User { Username = "bar", Password = "pwd2", Account = "BAR" }, + ], + NoAuthUser = "foo", + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://bar:wrong@127.0.0.1:{port}", + MaxReconnectRetry = 0, + }); + + var ex = await Should.ThrowAsync(async () => + { + await client.ConnectAsync(); + await client.PingAsync(); + }); + + ExceptionChainContains(ex, "Authorization Violation").ShouldBeTrue( + $"Expected auth violation, got: {ex}"); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: AuthService — tests the build logic for auth service + [Fact] + public void AuthService_build_with_no_auth_returns_not_required() + { + var authService = AuthService.Build(new NatsOptions()); + authService.IsAuthRequired.ShouldBeFalse(); + authService.NonceRequired.ShouldBeFalse(); + } + + // Go: AuthService — tests the build logic for token auth + [Fact] + public void AuthService_build_with_token_marks_auth_required() + { + var authService = AuthService.Build(new NatsOptions { Authorization = "secret" }); + authService.IsAuthRequired.ShouldBeTrue(); + authService.NonceRequired.ShouldBeFalse(); + } + + // Go: AuthService — tests the build logic for user/password auth + [Fact] + public void AuthService_build_with_user_password_marks_auth_required() + { + var authService = AuthService.Build(new NatsOptions + { + Username = "admin", + Password = "secret", + }); + authService.IsAuthRequired.ShouldBeTrue(); + authService.NonceRequired.ShouldBeFalse(); + } + + // Go: AuthService — tests the build logic for nkey auth + [Fact] + public void AuthService_build_with_nkeys_marks_nonce_required() + { + var authService = AuthService.Build(new NatsOptions + { + NKeys = [new NKeyUser { Nkey = "UABC123" }], + }); + authService.IsAuthRequired.ShouldBeTrue(); + authService.NonceRequired.ShouldBeTrue(); + } + + // Go: AuthService — tests the build logic for multi-user auth + [Fact] + public void AuthService_build_with_users_marks_auth_required() + { + var authService = AuthService.Build(new NatsOptions + { + Users = [new User { Username = "alice", Password = "pass" }], + }); + authService.IsAuthRequired.ShouldBeTrue(); + } + + // Go: AuthService.Authenticate — token match + [Fact] + public void AuthService_authenticate_token_success() + { + var authService = AuthService.Build(new NatsOptions { Authorization = "mytoken" }); + var result = authService.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions { Token = "mytoken" }, + Nonce = [], + }); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe("token"); + } + + // Go: AuthService.Authenticate — token mismatch + [Fact] + public void AuthService_authenticate_token_failure() + { + var authService = AuthService.Build(new NatsOptions { Authorization = "mytoken" }); + var result = authService.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions { Token = "wrong" }, + Nonce = [], + }); + + result.ShouldBeNull(); + } + + // Go: AuthService.Authenticate — user/password match + [Fact] + public void AuthService_authenticate_user_password_success() + { + var authService = AuthService.Build(new NatsOptions + { + Users = [new User { Username = "alice", Password = "pass", Account = "acct-a" }], + }); + + var result = authService.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "pass" }, + Nonce = [], + }); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe("alice"); + result.AccountName.ShouldBe("acct-a"); + } + + // Go: AuthService.Authenticate — user/password mismatch + [Fact] + public void AuthService_authenticate_user_password_failure() + { + var authService = AuthService.Build(new NatsOptions + { + Users = [new User { Username = "alice", Password = "pass" }], + }); + + var result = authService.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions { Username = "alice", Password = "wrong" }, + Nonce = [], + }); + + result.ShouldBeNull(); + } + + // Go: AuthService.Authenticate — no auth user fallback + [Fact] + public void AuthService_authenticate_no_auth_user_fallback() + { + var authService = AuthService.Build(new NatsOptions + { + Users = + [ + new User { Username = "foo", Password = "pwd1", Account = "FOO" }, + ], + NoAuthUser = "foo", + }); + + // No credentials provided — should fall back to no_auth_user + var result = authService.Authenticate(new ClientAuthContext + { + Opts = new ClientOptions(), + Nonce = [], + }); + + result.ShouldNotBeNull(); + result.Identity.ShouldBe("foo"); + result.AccountName.ShouldBe("FOO"); + } + + // Go: AuthService.GenerateNonce — nonce generation + [Fact] + public void AuthService_generates_unique_nonces() + { + var authService = AuthService.Build(new NatsOptions + { + NKeys = [new NKeyUser { Nkey = "UABC" }], + }); + + var nonce1 = authService.GenerateNonce(); + var nonce2 = authService.GenerateNonce(); + + nonce1.Length.ShouldBe(11); + nonce2.Length.ShouldBe(11); + // Extremely unlikely to be the same + nonce1.ShouldNotBe(nonce2); + } + + // Go: AuthService.EncodeNonce — nonce encoding + [Fact] + public void AuthService_nonce_encoding_is_url_safe_base64() + { + var authService = AuthService.Build(new NatsOptions()); + var nonce = new byte[] { 0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA, 0xF9, 0xF8, 0xF7, 0xF6, 0xF5 }; + + var encoded = authService.EncodeNonce(nonce); + + // Should not contain standard base64 padding or non-URL-safe characters + encoded.ShouldNotContain("="); + encoded.ShouldNotContain("+"); + encoded.ShouldNotContain("/"); + } +} diff --git a/tests/NATS.Server.Tests/Accounts/PermissionTests.cs b/tests/NATS.Server.Tests/Accounts/PermissionTests.cs new file mode 100644 index 0000000..f59ce18 --- /dev/null +++ b/tests/NATS.Server.Tests/Accounts/PermissionTests.cs @@ -0,0 +1,442 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server; +using NATS.Server.Auth; + +namespace NATS.Server.Tests.Accounts; + +/// +/// Tests for publish/subscribe permission enforcement, account-level limits, +/// and per-user permission isolation. +/// Reference: Go auth_test.go — TestUserClone* (permission structure tests) +/// Reference: Go accounts_test.go — account limits (max connections, max subscriptions). +/// +public class PermissionTests +{ + 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 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; + } + + // Go: Permissions — publish allow list + [Fact] + public void Publish_allow_list_only() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>", "bar.*"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + perms.IsPublishAllowed("foo.bar.baz").ShouldBeTrue(); + perms.IsPublishAllowed("bar.one").ShouldBeTrue(); + perms.IsPublishAllowed("baz.one").ShouldBeFalse(); + } + + // Go: Permissions — publish deny list + [Fact] + public void Publish_deny_list_only() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission { Deny = ["secret.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + perms.IsPublishAllowed("secret.data").ShouldBeFalse(); + perms.IsPublishAllowed("secret.nested.deep").ShouldBeFalse(); + } + + // Go: Permissions — publish allow + deny combined + [Fact] + public void Publish_allow_and_deny_combined() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission + { + Allow = ["events.>"], + Deny = ["events.internal.>"], + }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("events.public.data").ShouldBeTrue(); + perms.IsPublishAllowed("events.internal.secret").ShouldBeFalse(); + } + + // Go: Permissions — subscribe allow list + [Fact] + public void Subscribe_allow_list() + { + var perms = ClientPermissions.Build(new Permissions + { + Subscribe = new SubjectPermission { Allow = ["data.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsSubscribeAllowed("data.updates").ShouldBeTrue(); + perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse(); + } + + // Go: Permissions — subscribe deny list + [Fact] + public void Subscribe_deny_list() + { + var perms = ClientPermissions.Build(new Permissions + { + Subscribe = new SubjectPermission { Deny = ["admin.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsSubscribeAllowed("data.updates").ShouldBeTrue(); + perms.IsSubscribeAllowed("admin.logs").ShouldBeFalse(); + } + + // Go: Permissions — null permissions allow everything + [Fact] + public void Null_permissions_allows_everything() + { + var perms = ClientPermissions.Build(null); + perms.ShouldBeNull(); + } + + // Go: Permissions — empty permissions allows everything + [Fact] + public void Empty_permissions_allows_everything() + { + var perms = ClientPermissions.Build(new Permissions()); + perms.ShouldBeNull(); + } + + // Go: Permissions — subscribe allow + deny combined + [Fact] + public void Subscribe_allow_and_deny_combined() + { + var perms = ClientPermissions.Build(new Permissions + { + Subscribe = new SubjectPermission + { + Allow = ["data.>"], + Deny = ["data.secret.>"], + }, + }); + + perms.ShouldNotBeNull(); + perms.IsSubscribeAllowed("data.public").ShouldBeTrue(); + perms.IsSubscribeAllowed("data.secret.key").ShouldBeFalse(); + } + + // Go: Permissions — separate publish and subscribe permissions + [Fact] + public void Separate_publish_and_subscribe_permissions() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission { Allow = ["pub.>"] }, + Subscribe = new SubjectPermission { Allow = ["sub.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsPublishAllowed("pub.data").ShouldBeTrue(); + perms.IsPublishAllowed("sub.data").ShouldBeFalse(); + perms.IsSubscribeAllowed("sub.data").ShouldBeTrue(); + perms.IsSubscribeAllowed("pub.data").ShouldBeFalse(); + } + + // Go: Account limits — max connections + [Fact] + public void Account_enforces_max_connections() + { + var acc = new Account("test") { MaxConnections = 2 }; + acc.AddClient(1).ShouldBeTrue(); + acc.AddClient(2).ShouldBeTrue(); + acc.AddClient(3).ShouldBeFalse(); // exceeds limit + acc.ClientCount.ShouldBe(2); + } + + // Go: Account limits — unlimited connections + [Fact] + public void Account_unlimited_connections_when_zero() + { + var acc = new Account("test") { MaxConnections = 0 }; + for (ulong i = 1; i <= 100; i++) + acc.AddClient(i).ShouldBeTrue(); + acc.ClientCount.ShouldBe(100); + } + + // Go: Account limits — max subscriptions + [Fact] + public void Account_enforces_max_subscriptions() + { + var acc = new Account("test") { MaxSubscriptions = 2 }; + acc.IncrementSubscriptions().ShouldBeTrue(); + acc.IncrementSubscriptions().ShouldBeTrue(); + acc.IncrementSubscriptions().ShouldBeFalse(); + } + + // Go: Account limits — subscription decrement frees slot + [Fact] + public void Account_decrement_subscriptions_frees_slot() + { + var acc = new Account("test") { MaxSubscriptions = 1 }; + acc.IncrementSubscriptions().ShouldBeTrue(); + acc.DecrementSubscriptions(); + acc.IncrementSubscriptions().ShouldBeTrue(); // slot freed + } + + // Go: Account limits — max connections via integration + [Fact] + public void Account_remove_client_frees_slot() + { + var acc = new Account("test") { MaxConnections = 1 }; + acc.AddClient(1).ShouldBeTrue(); + acc.AddClient(2).ShouldBeFalse(); // full + acc.RemoveClient(1); + acc.AddClient(2).ShouldBeTrue(); // slot freed + } + + // Go: Account limits — default permissions on account + [Fact] + public void Account_default_permissions() + { + var acc = new Account("test") + { + DefaultPermissions = new Permissions + { + Publish = new SubjectPermission { Allow = ["pub.>"] }, + }, + }; + + acc.DefaultPermissions.ShouldNotBeNull(); + acc.DefaultPermissions.Publish!.Allow![0].ShouldBe("pub.>"); + } + + // Go: Account stats tracking + [Fact] + public void Account_tracks_message_stats() + { + var acc = new Account("stats-test"); + + acc.InMsgs.ShouldBe(0L); + acc.OutMsgs.ShouldBe(0L); + acc.InBytes.ShouldBe(0L); + acc.OutBytes.ShouldBe(0L); + + acc.IncrementInbound(5, 1024); + acc.IncrementOutbound(3, 512); + + acc.InMsgs.ShouldBe(5L); + acc.InBytes.ShouldBe(1024L); + acc.OutMsgs.ShouldBe(3L); + acc.OutBytes.ShouldBe(512L); + } + + // Go: Account — user with publish permission can publish + [Fact] + public async Task User_with_publish_permission_can_publish_and_subscribe() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User + { + Username = "limited", + Password = "pass", + Permissions = new Permissions + { + Publish = new SubjectPermission { Allow = ["allowed.>"] }, + Subscribe = new SubjectPermission { Allow = [">"] }, + }, + }, + ], + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://limited:pass@127.0.0.1:{port}", + }); + await client.ConnectAsync(); + + // Subscribe to allowed subjects + await using var sub = await client.SubscribeCoreAsync("allowed.test"); + await client.PingAsync(); + + // Publish to allowed subject + await client.PublishAsync("allowed.test", "hello"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("hello"); + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: Account — user with publish deny + [Fact] + public async Task User_with_publish_deny_blocks_denied_subjects() + { + var (server, port, cts) = await StartServerAsync(new NatsOptions + { + Users = + [ + new User + { + Username = "limited", + Password = "pass", + Permissions = new Permissions + { + Publish = new SubjectPermission + { + Allow = [">"], + Deny = ["secret.>"], + }, + Subscribe = new SubjectPermission { Allow = [">"] }, + }, + }, + ], + }); + + try + { + await using var client = new NatsConnection(new NatsOpts + { + Url = $"nats://limited:pass@127.0.0.1:{port}", + }); + await client.ConnectAsync(); + + // Subscribe to catch anything + await using var sub = await client.SubscribeCoreAsync("secret.data"); + await client.PingAsync(); + + // Publish to denied subject — server should silently drop + await client.PublishAsync("secret.data", "shouldnt-arrive"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + try + { + await sub.Msgs.ReadAsync(timeout.Token); + throw new Exception("Should not have received message on denied subject"); + } + catch (OperationCanceledException) + { + // Expected — message was blocked by permissions + } + } + finally + { + await cts.CancelAsync(); + server.Dispose(); + } + } + + // Go: Account — user revocation + [Fact] + public void Account_user_revocation() + { + var acc = new Account("test"); + + acc.IsUserRevoked("user1", 100).ShouldBeFalse(); + + acc.RevokeUser("user1", 200); + acc.IsUserRevoked("user1", 100).ShouldBeTrue(); // issued before revocation + acc.IsUserRevoked("user1", 200).ShouldBeTrue(); // issued at revocation time + acc.IsUserRevoked("user1", 300).ShouldBeFalse(); // issued after revocation + } + + // Go: Account — wildcard user revocation + [Fact] + public void Account_wildcard_user_revocation() + { + var acc = new Account("test"); + + acc.RevokeUser("*", 500); + acc.IsUserRevoked("anyuser", 400).ShouldBeTrue(); + acc.IsUserRevoked("anyuser", 600).ShouldBeFalse(); + } + + // Go: Account — JetStream stream reservation + [Fact] + public void Account_jetstream_stream_reservation() + { + var acc = new Account("test") { MaxJetStreamStreams = 2 }; + + acc.TryReserveStream().ShouldBeTrue(); + acc.TryReserveStream().ShouldBeTrue(); + acc.TryReserveStream().ShouldBeFalse(); // limit reached + acc.JetStreamStreamCount.ShouldBe(2); + + acc.ReleaseStream(); + acc.JetStreamStreamCount.ShouldBe(1); + acc.TryReserveStream().ShouldBeTrue(); // slot freed + } + + // Go: Account limits — permissions cache behavior + [Fact] + public void Permission_cache_returns_consistent_results() + { + var perms = ClientPermissions.Build(new Permissions + { + Publish = new SubjectPermission { Allow = ["foo.>"] }, + }); + + perms.ShouldNotBeNull(); + // First call populates cache + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + // Second call uses cache — should return same result + perms.IsPublishAllowed("foo.bar").ShouldBeTrue(); + // Different subject also cached + perms.IsPublishAllowed("baz.bar").ShouldBeFalse(); + perms.IsPublishAllowed("baz.bar").ShouldBeFalse(); + } + + // Go: Permissions — delivery allowed check + [Fact] + public void Delivery_allowed_respects_deny_list() + { + var perms = ClientPermissions.Build(new Permissions + { + Subscribe = new SubjectPermission { Deny = ["blocked.>"] }, + }); + + perms.ShouldNotBeNull(); + perms.IsDeliveryAllowed("normal.subject").ShouldBeTrue(); + perms.IsDeliveryAllowed("blocked.secret").ShouldBeFalse(); + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayConfigTests.cs b/tests/NATS.Server.Tests/Gateways/GatewayConfigTests.cs new file mode 100644 index 0000000..2722628 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayConfigTests.cs @@ -0,0 +1,580 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.Gateways; +using NATS.Server.Monitoring; + +namespace NATS.Server.Tests.Gateways; + +/// +/// Gateway configuration validation, options parsing, monitoring endpoint, +/// and server lifecycle tests. +/// Ported from golang/nats-server/server/gateway_test.go. +/// +public class GatewayConfigTests +{ + // ── GatewayOptions Defaults ───────────────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void Default_gateway_options_have_correct_defaults() + { + var options = new GatewayOptions(); + options.Name.ShouldBeNull(); + options.Host.ShouldBe("0.0.0.0"); + options.Port.ShouldBe(0); + options.Remotes.ShouldNotBeNull(); + options.Remotes.Count.ShouldBe(0); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void Gateway_options_name_can_be_set() + { + var options = new GatewayOptions { Name = "CLUSTER-A" }; + options.Name.ShouldBe("CLUSTER-A"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void Gateway_options_host_can_be_set() + { + var options = new GatewayOptions { Host = "192.168.1.1" }; + options.Host.ShouldBe("192.168.1.1"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void Gateway_options_port_can_be_set() + { + var options = new GatewayOptions { Port = 7222 }; + options.Port.ShouldBe(7222); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void Gateway_options_remotes_can_be_set() + { + var options = new GatewayOptions + { + Remotes = ["127.0.0.1:7222", "127.0.0.1:7223"], + }; + options.Remotes.Count.ShouldBe(2); + } + + // ── NatsOptions Gateway Configuration ─────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void NatsOptions_gateway_is_null_by_default() + { + var opts = new NatsOptions(); + opts.Gateway.ShouldBeNull(); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void NatsOptions_gateway_can_be_assigned() + { + var opts = new NatsOptions + { + Gateway = new GatewayOptions + { + Name = "TestGW", + Host = "127.0.0.1", + Port = 7222, + }, + }; + opts.Gateway.ShouldNotBeNull(); + opts.Gateway.Name.ShouldBe("TestGW"); + } + + // ── Config File Parsing ───────────────────────────────────────────── + + // Go: TestGatewayWithListenToAny server/gateway_test.go:834 + [Fact] + public void Config_processor_parses_gateway_name() + { + var config = """ + gateway { + name: "MY-GATEWAY" + } + """; + var opts = ConfigProcessor.ProcessConfig(config); + opts.Gateway.ShouldNotBeNull(); + opts.Gateway!.Name.ShouldBe("MY-GATEWAY"); + } + + // Go: TestGatewayWithListenToAny server/gateway_test.go:834 + [Fact] + public void Config_processor_parses_gateway_listen() + { + var config = """ + gateway { + name: "GW" + listen: "127.0.0.1:7222" + } + """; + var opts = ConfigProcessor.ProcessConfig(config); + opts.Gateway.ShouldNotBeNull(); + opts.Gateway!.Host.ShouldBe("127.0.0.1"); + opts.Gateway!.Port.ShouldBe(7222); + } + + // Go: TestGatewayWithListenToAny server/gateway_test.go:834 + [Fact] + public void Config_processor_parses_gateway_listen_any() + { + var config = """ + gateway { + name: "GW" + listen: "0.0.0.0:7333" + } + """; + var opts = ConfigProcessor.ProcessConfig(config); + opts.Gateway.ShouldNotBeNull(); + opts.Gateway!.Host.ShouldBe("0.0.0.0"); + opts.Gateway!.Port.ShouldBe(7333); + } + + // Go: TestGatewayWithListenToAny server/gateway_test.go:834 + [Fact] + public void Config_processor_gateway_without_name_leaves_null() + { + var config = """ + gateway { + listen: "127.0.0.1:7222" + } + """; + var opts = ConfigProcessor.ProcessConfig(config); + opts.Gateway.ShouldNotBeNull(); + opts.Gateway!.Name.ShouldBeNull(); + } + + // Go: TestGatewayWithListenToAny server/gateway_test.go:834 + [Fact] + public void Config_processor_no_gateway_section_leaves_null() + { + var config = """ + port: 4222 + """; + var opts = ConfigProcessor.ProcessConfig(config); + opts.Gateway.ShouldBeNull(); + } + + // ── Server Lifecycle with Gateway ─────────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Server_starts_with_gateway_configured() + { + var opts = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "LIFECYCLE", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + server.GatewayListen.ShouldNotBeNull(); + server.GatewayListen.ShouldContain("127.0.0.1:"); + + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Server_gateway_listen_uses_ephemeral_port() + { + var opts = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "EPHEMERAL", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + // The gateway listen should have a non-zero port + var parts = server.GatewayListen!.Split(':'); + int.Parse(parts[1]).ShouldBeGreaterThan(0); + + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Server_without_gateway_has_null_gateway_listen() + { + var opts = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + }; + + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + server.GatewayListen.ShouldBeNull(); + + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + + // Go: TestGatewayNoPanicOnStartupWithMonitoring server/gateway_test.go:6903 + [Fact] + public async Task Server_starts_with_both_gateway_and_monitoring() + { + var opts = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + MonitorPort = 0, + Gateway = new GatewayOptions + { + Name = "MON-GW", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + + server.GatewayListen.ShouldNotBeNull(); + + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + + // ── GatewayManager Unit Tests ─────────────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_manager_starts_and_listens() + { + var options = new GatewayOptions + { + Name = "UNIT", + Host = "127.0.0.1", + Port = 0, + }; + var stats = new ServerStats(); + var manager = new GatewayManager( + options, + stats, + "SERVER-1", + _ => { }, + _ => { }, + NullLogger.Instance); + + await manager.StartAsync(CancellationToken.None); + + manager.ListenEndpoint.ShouldContain("127.0.0.1:"); + + await manager.DisposeAsync(); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_manager_ephemeral_port_resolves() + { + var options = new GatewayOptions + { + Name = "UNIT", + Host = "127.0.0.1", + Port = 0, + }; + var manager = new GatewayManager( + options, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + await manager.StartAsync(CancellationToken.None); + + // Port should have been resolved + options.Port.ShouldBeGreaterThan(0); + + await manager.DisposeAsync(); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_manager_dispose_decrements_stats() + { + var options = new GatewayOptions + { + Name = "STATS", + Host = "127.0.0.1", + Port = 0, + }; + var stats = new ServerStats(); + var manager = new GatewayManager( + options, + stats, + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + await manager.StartAsync(CancellationToken.None); + await manager.DisposeAsync(); + + stats.Gateways.ShouldBe(0); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_manager_forward_without_connections_does_not_throw() + { + var options = new GatewayOptions + { + Name = "EMPTY", + Host = "127.0.0.1", + Port = 0, + }; + var manager = new GatewayManager( + options, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + // ForwardMessageAsync without any connections should not throw + await manager.ForwardMessageAsync("$G", "test", null, new byte[] { 1 }, CancellationToken.None); + + manager.ForwardedJetStreamClusterMessages.ShouldBe(0); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_manager_propagate_without_connections_does_not_throw() + { + var options = new GatewayOptions + { + Name = "EMPTY", + Host = "127.0.0.1", + Port = 0, + }; + var manager = new GatewayManager( + options, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + // These should not throw even without connections + manager.PropagateLocalSubscription("$G", "test.>", null); + manager.PropagateLocalUnsubscription("$G", "test.>", null); + } + + // ── GatewayzHandler ───────────────────────────────────────────────── + + // Go: TestGatewayNoPanicOnStartupWithMonitoring server/gateway_test.go:6903 + [Fact] + public async Task Gatewayz_handler_returns_gateway_count() + { + await using var fixture = await GatewayConfigFixture.StartAsync(); + + var handler = new GatewayzHandler(fixture.Local); + var result = handler.Build(); + result.ShouldNotBeNull(); + } + + // Go: TestGatewayNoPanicOnStartupWithMonitoring server/gateway_test.go:6903 + [Fact] + public async Task Gatewayz_handler_reflects_active_connections() + { + await using var fixture = await GatewayConfigFixture.StartAsync(); + + fixture.Local.Stats.Gateways.ShouldBeGreaterThan(0); + } + + // ── Duplicate Remote Deduplication ─────────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Duplicate_remotes_are_deduplicated() + { + var localOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var local = new NatsServer(localOptions, NullLoggerFactory.Instance); + var localCts = new CancellationTokenSource(); + _ = local.StartAsync(localCts.Token); + await local.WaitForReadyAsync(); + + // Create remote with duplicate entries + var remoteOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "REMOTE", + Host = "127.0.0.1", + Port = 0, + Remotes = [local.GatewayListen!, local.GatewayListen!], + }, + }; + + var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); + var remoteCts = new CancellationTokenSource(); + _ = remote.StartAsync(remoteCts.Token); + await remote.WaitForReadyAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + // Should have exactly 1 gateway connection, not 2 + // (remote deduplicates identical endpoints) + local.Stats.Gateways.ShouldBeGreaterThan(0); + remote.Stats.Gateways.ShouldBeGreaterThan(0); + + await localCts.CancelAsync(); + await remoteCts.CancelAsync(); + local.Dispose(); + remote.Dispose(); + localCts.Dispose(); + remoteCts.Dispose(); + } + + // ── ServerStats Gateway Fields ────────────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void ServerStats_gateway_fields_initialized_to_zero() + { + var stats = new ServerStats(); + stats.Gateways.ShouldBe(0); + stats.SlowConsumerGateways.ShouldBe(0); + stats.StaleConnectionGateways.ShouldBe(0); + } + + // Go: TestGatewaySlowConsumer server/gateway_test.go:7003 + [Fact] + public void ServerStats_gateway_counter_atomic() + { + var stats = new ServerStats(); + Interlocked.Increment(ref stats.Gateways); + Interlocked.Increment(ref stats.Gateways); + stats.Gateways.ShouldBe(2); + Interlocked.Decrement(ref stats.Gateways); + stats.Gateways.ShouldBe(1); + } +} + +/// +/// Shared fixture for config tests. +/// +internal sealed class GatewayConfigFixture : IAsyncDisposable +{ + private readonly CancellationTokenSource _localCts; + private readonly CancellationTokenSource _remoteCts; + + private GatewayConfigFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts) + { + Local = local; + Remote = remote; + _localCts = localCts; + _remoteCts = remoteCts; + } + + public NatsServer Local { get; } + public NatsServer Remote { get; } + + public static async Task StartAsync() + { + var localOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var local = new NatsServer(localOptions, NullLoggerFactory.Instance); + var localCts = new CancellationTokenSource(); + _ = local.StartAsync(localCts.Token); + await local.WaitForReadyAsync(); + + var remoteOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "REMOTE", + Host = "127.0.0.1", + Port = 0, + Remotes = [local.GatewayListen!], + }, + }; + + var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); + var remoteCts = new CancellationTokenSource(); + _ = remote.StartAsync(remoteCts.Token); + await remote.WaitForReadyAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + return new GatewayConfigFixture(local, remote, localCts, remoteCts); + } + + public async ValueTask DisposeAsync() + { + await _localCts.CancelAsync(); + await _remoteCts.CancelAsync(); + Local.Dispose(); + Remote.Dispose(); + _localCts.Dispose(); + _remoteCts.Dispose(); + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayConnectionTests.cs b/tests/NATS.Server.Tests/Gateways/GatewayConnectionTests.cs new file mode 100644 index 0000000..528b202 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayConnectionTests.cs @@ -0,0 +1,898 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server.Configuration; +using NATS.Server.Gateways; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Gateways; + +/// +/// Gateway connection establishment, handshake, lifecycle, and reconnection tests. +/// Ported from golang/nats-server/server/gateway_test.go. +/// +public class GatewayConnectionTests +{ + // ── Handshake and Connection Establishment ────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_outbound_handshake_sets_remote_id() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL-SERVER", cts.Token); + var line = await ReadLineAsync(clientSocket, cts.Token); + line.ShouldBe("GATEWAY LOCAL-SERVER"); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE-SERVER", cts.Token); + await handshake; + + gw.RemoteId.ShouldBe("REMOTE-SERVER"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_inbound_handshake_sets_remote_id() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformInboundHandshakeAsync("LOCAL-SERVER", cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE-CLIENT", cts.Token); + var line = await ReadLineAsync(clientSocket, cts.Token); + line.ShouldBe("GATEWAY LOCAL-SERVER"); + await handshake; + + gw.RemoteId.ShouldBe("REMOTE-CLIENT"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_handshake_rejects_invalid_protocol() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformInboundHandshakeAsync("LOCAL", cts.Token); + await WriteLineAsync(clientSocket, "INVALID protocol", cts.Token); + + await Should.ThrowAsync(async () => await handshake); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_handshake_rejects_empty_id() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformInboundHandshakeAsync("LOCAL", cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY ", cts.Token); + + await Should.ThrowAsync(async () => await handshake); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Two_clusters_establish_gateway_connections() + { + await using var fixture = await GatewayConnectionFixture.StartAsync(); + + fixture.Local.Stats.Gateways.ShouldBeGreaterThan(0); + fixture.Remote.Stats.Gateways.ShouldBeGreaterThan(0); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_connection_count_tracked_in_stats() + { + await using var fixture = await GatewayConnectionFixture.StartAsync(); + + fixture.Local.Stats.Gateways.ShouldBeGreaterThanOrEqualTo(1); + fixture.Remote.Stats.Gateways.ShouldBeGreaterThanOrEqualTo(1); + } + + // Go: TestGatewayDoesntSendBackToItself server/gateway_test.go:2150 + [Fact] + public async Task Gateway_does_not_create_echo_cycle() + { + await using var fixture = await GatewayConnectionFixture.StartAsync(); + + await using var remoteSub = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await remoteSub.ConnectAsync(); + + await using var localConn = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await localConn.ConnectAsync(); + + await using var sub = await remoteSub.SubscribeCoreAsync("cycle.test"); + await remoteSub.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("cycle.test"); + + await localConn.PublishAsync("cycle.test", "ping"); + await localConn.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("ping"); + + // Verify no additional cycle messages arrive + await Task.Delay(200); + using var noMoreTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + await Should.ThrowAsync(async () => + await sub.Msgs.ReadAsync(noMoreTimeout.Token)); + } + + // Go: TestGatewaySolicitShutdown server/gateway_test.go:784 + [Fact] + public async Task Gateway_manager_shutdown_does_not_hang() + { + var options = new GatewayOptions + { + Name = "TEST", + Host = "127.0.0.1", + Port = 0, + Remotes = ["127.0.0.1:19999"], // Non-existent host + }; + var manager = new GatewayManager( + options, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + await manager.StartAsync(CancellationToken.None); + // Dispose should complete promptly even with pending reconnect attempts + var disposeTask = manager.DisposeAsync().AsTask(); + var completed = await Task.WhenAny(disposeTask, Task.Delay(TimeSpan.FromSeconds(5))); + completed.ShouldBe(disposeTask, "DisposeAsync should complete within timeout"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 (reconnection part) + [Fact] + public async Task Gateway_reconnects_after_remote_shutdown() + { + var localOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var local = new NatsServer(localOptions, NullLoggerFactory.Instance); + var localCts = new CancellationTokenSource(); + _ = local.StartAsync(localCts.Token); + await local.WaitForReadyAsync(); + + // Start remote + var remoteOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "REMOTE", + Host = "127.0.0.1", + Port = 0, + Remotes = [local.GatewayListen!], + }, + }; + var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); + var remoteCts = new CancellationTokenSource(); + _ = remote.StartAsync(remoteCts.Token); + await remote.WaitForReadyAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + local.Stats.Gateways.ShouldBeGreaterThan(0); + remote.Stats.Gateways.ShouldBeGreaterThan(0); + + // Shutdown remote + await remoteCts.CancelAsync(); + remote.Dispose(); + + // Wait for gateway count to drop + using var dropTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!dropTimeout.IsCancellationRequested && local.Stats.Gateways > 0) + await Task.Delay(50, dropTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + // Restart remote connecting to local + var remote2Options = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "REMOTE2", + Host = "127.0.0.1", + Port = 0, + Remotes = [local.GatewayListen!], + }, + }; + var remote2 = new NatsServer(remote2Options, NullLoggerFactory.Instance); + var remote2Cts = new CancellationTokenSource(); + _ = remote2.StartAsync(remote2Cts.Token); + await remote2.WaitForReadyAsync(); + + // Wait for new gateway link + using var reconTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!reconTimeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote2.Stats.Gateways == 0)) + await Task.Delay(50, reconTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + local.Stats.Gateways.ShouldBeGreaterThan(0); + remote2.Stats.Gateways.ShouldBeGreaterThan(0); + + await localCts.CancelAsync(); + await remote2Cts.CancelAsync(); + local.Dispose(); + remote2.Dispose(); + localCts.Dispose(); + remote2Cts.Dispose(); + remoteCts.Dispose(); + } + + // Go: TestGatewayNoReconnectOnClose server/gateway_test.go:1735 + [Fact] + public async Task Connection_read_loop_starts_and_processes_messages() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + // Perform handshake + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + var receivedMessage = new TaskCompletionSource(); + gw.MessageReceived = msg => + { + receivedMessage.TrySetResult(msg); + return Task.CompletedTask; + }; + gw.StartLoop(cts.Token); + + // Send a GMSG message + var payload = "hello-gateway"u8.ToArray(); + var line = $"GMSG $G test.subject - {payload.Length}\r\n"; + await clientSocket.SendAsync(Encoding.ASCII.GetBytes(line), SocketFlags.None, cts.Token); + await clientSocket.SendAsync(payload, SocketFlags.None, cts.Token); + await clientSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, cts.Token); + + var msg = await receivedMessage.Task.WaitAsync(cts.Token); + msg.Subject.ShouldBe("test.subject"); + msg.ReplyTo.ShouldBeNull(); + Encoding.UTF8.GetString(msg.Payload.Span).ShouldBe("hello-gateway"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Connection_read_loop_processes_gmsg_with_reply() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + var receivedMessage = new TaskCompletionSource(); + gw.MessageReceived = msg => + { + receivedMessage.TrySetResult(msg); + return Task.CompletedTask; + }; + gw.StartLoop(cts.Token); + + var payload = "data"u8.ToArray(); + var line = $"GMSG $G test.subject _INBOX.abc {payload.Length}\r\n"; + await clientSocket.SendAsync(Encoding.ASCII.GetBytes(line), SocketFlags.None, cts.Token); + await clientSocket.SendAsync(payload, SocketFlags.None, cts.Token); + await clientSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, cts.Token); + + var msg = await receivedMessage.Task.WaitAsync(cts.Token); + msg.Subject.ShouldBe("test.subject"); + msg.ReplyTo.ShouldBe("_INBOX.abc"); + msg.Account.ShouldBe("$G"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Connection_read_loop_processes_account_scoped_gmsg() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + var receivedMessage = new TaskCompletionSource(); + gw.MessageReceived = msg => + { + receivedMessage.TrySetResult(msg); + return Task.CompletedTask; + }; + gw.StartLoop(cts.Token); + + var payload = "msg"u8.ToArray(); + var line = $"GMSG ACCT test.subject - {payload.Length}\r\n"; + await clientSocket.SendAsync(Encoding.ASCII.GetBytes(line), SocketFlags.None, cts.Token); + await clientSocket.SendAsync(payload, SocketFlags.None, cts.Token); + await clientSocket.SendAsync("\r\n"u8.ToArray(), SocketFlags.None, cts.Token); + + var msg = await receivedMessage.Task.WaitAsync(cts.Token); + msg.Account.ShouldBe("ACCT"); + msg.Subject.ShouldBe("test.subject"); + } + + // Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755 + [Fact] + public async Task Connection_read_loop_processes_aplus_interest() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + var receivedSub = new TaskCompletionSource(); + gw.RemoteSubscriptionReceived = sub => + { + receivedSub.TrySetResult(sub); + return Task.CompletedTask; + }; + gw.StartLoop(cts.Token); + + await WriteLineAsync(clientSocket, "A+ MYACC orders.>", cts.Token); + + var sub = await receivedSub.Task.WaitAsync(cts.Token); + sub.Subject.ShouldBe("orders.>"); + sub.Account.ShouldBe("MYACC"); + sub.IsRemoval.ShouldBeFalse(); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public async Task Connection_read_loop_processes_aminus_interest() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + var receivedSubs = new List(); + var tcs = new TaskCompletionSource(); + gw.RemoteSubscriptionReceived = sub => + { + receivedSubs.Add(sub); + if (receivedSubs.Count >= 2) + tcs.TrySetResult(); + return Task.CompletedTask; + }; + gw.StartLoop(cts.Token); + + await WriteLineAsync(clientSocket, "A+ ACC foo.*", cts.Token); + await WriteLineAsync(clientSocket, "A- ACC foo.*", cts.Token); + + await tcs.Task.WaitAsync(cts.Token); + receivedSubs[0].IsRemoval.ShouldBeFalse(); + receivedSubs[1].IsRemoval.ShouldBeTrue(); + } + + // Go: TestGatewayQueueSub server/gateway_test.go:2265 + [Fact] + public async Task Connection_read_loop_processes_aplus_with_queue() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + var receivedSub = new TaskCompletionSource(); + gw.RemoteSubscriptionReceived = sub => + { + receivedSub.TrySetResult(sub); + return Task.CompletedTask; + }; + gw.StartLoop(cts.Token); + + await WriteLineAsync(clientSocket, "A+ $G foo.bar workers", cts.Token); + + var sub = await receivedSub.Task.WaitAsync(cts.Token); + sub.Subject.ShouldBe("foo.bar"); + sub.Queue.ShouldBe("workers"); + sub.Account.ShouldBe("$G"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Send_message_writes_gmsg_protocol() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + var payload = Encoding.UTF8.GetBytes("payload-data"); + await gw.SendMessageAsync("$G", "test.subject", "_INBOX.reply", payload, cts.Token); + + var buf = new byte[4096]; + var total = new StringBuilder(); + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + while (true) + { + var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token); + if (n == 0) break; + total.Append(Encoding.ASCII.GetString(buf, 0, n)); + if (total.ToString().Contains("payload-data", StringComparison.Ordinal)) + break; + } + + var received = total.ToString(); + received.ShouldContain("GMSG $G test.subject _INBOX.reply"); + received.ShouldContain("payload-data"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Send_aplus_writes_interest_protocol() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + await gw.SendAPlusAsync("$G", "orders.>", null, cts.Token); + + var line = await ReadLineAsync(clientSocket, cts.Token); + line.ShouldBe("A+ $G orders.>"); + } + + // Go: TestGatewayQueueSub server/gateway_test.go:2265 + [Fact] + public async Task Send_aplus_with_queue_writes_interest_protocol() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + await gw.SendAPlusAsync("$G", "foo", "workers", cts.Token); + + var line = await ReadLineAsync(clientSocket, cts.Token); + line.ShouldBe("A+ $G foo workers"); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public async Task Send_aminus_writes_unsubscribe_interest_protocol() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + await gw.SendAMinusAsync("$G", "orders.>", null, cts.Token); + + var line = await ReadLineAsync(clientSocket, cts.Token); + line.ShouldBe("A- $G orders.>"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Send_message_with_no_reply_uses_dash() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + await gw.SendMessageAsync("$G", "test.subject", null, new byte[] { 0x41 }, cts.Token); + + var buf = new byte[4096]; + var total = new StringBuilder(); + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + while (true) + { + var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token); + if (n == 0) break; + total.Append(Encoding.ASCII.GetString(buf, 0, n)); + if (total.ToString().Contains("\r\n", StringComparison.Ordinal) && total.Length > 20) + break; + } + + total.ToString().ShouldContain("GMSG $G test.subject - 1"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Send_message_with_empty_payload() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + await gw.SendMessageAsync("$G", "test.empty", null, ReadOnlyMemory.Empty, cts.Token); + + var buf = new byte[4096]; + var total = new StringBuilder(); + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + while (true) + { + var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token); + if (n == 0) break; + total.Append(Encoding.ASCII.GetString(buf, 0, n)); + if (total.ToString().Contains("GMSG", StringComparison.Ordinal)) + break; + } + + total.ToString().ShouldContain("GMSG $G test.empty - 0"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Connection_dispose_cleans_up_gracefully() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + gw.StartLoop(cts.Token); + await gw.DisposeAsync(); // Should not throw + + // Verify the connection is no longer usable after dispose + gw.RemoteId.ShouldBe("REMOTE"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Multiple_concurrent_sends_are_serialized() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await clientSocket.ConnectAsync(IPAddress.Loopback, port); + using var serverSocket = await listener.AcceptSocketAsync(); + + await using var gw = new GatewayConnection(serverSocket); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshake = gw.PerformOutboundHandshakeAsync("LOCAL", cts.Token); + await ReadLineAsync(clientSocket, cts.Token); + await WriteLineAsync(clientSocket, "GATEWAY REMOTE", cts.Token); + await handshake; + + // Fire off concurrent sends + var tasks = new List(); + for (int i = 0; i < 10; i++) + { + var idx = i; + tasks.Add(gw.SendMessageAsync("$G", $"sub.{idx}", null, Encoding.UTF8.GetBytes($"msg-{idx}"), cts.Token)); + } + + await Task.WhenAll(tasks); + + // Drain all data from socket + var buf = new byte[8192]; + var total = new StringBuilder(); + using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + while (true) + { + try + { + var n = await clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token); + if (n == 0) break; + total.Append(Encoding.ASCII.GetString(buf, 0, n)); + } + catch (OperationCanceledException) + { + break; + } + } + + // All 10 messages should be present + var received = total.ToString(); + for (int i = 0; i < 10; i++) + { + received.ShouldContain($"sub.{i}"); + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, ct); + if (read == 0) + break; + if (single[0] == (byte)'\n') + break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); +} + +/// +/// Shared fixture for gateway connection tests that need two running server clusters. +/// +internal sealed class GatewayConnectionFixture : IAsyncDisposable +{ + private readonly CancellationTokenSource _localCts; + private readonly CancellationTokenSource _remoteCts; + + private GatewayConnectionFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts) + { + Local = local; + Remote = remote; + _localCts = localCts; + _remoteCts = remoteCts; + } + + public NatsServer Local { get; } + public NatsServer Remote { get; } + + public static async Task StartAsync() + { + var localOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var local = new NatsServer(localOptions, NullLoggerFactory.Instance); + var localCts = new CancellationTokenSource(); + _ = local.StartAsync(localCts.Token); + await local.WaitForReadyAsync(); + + var remoteOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Gateway = new GatewayOptions + { + Name = "REMOTE", + Host = "127.0.0.1", + Port = 0, + Remotes = [local.GatewayListen!], + }, + }; + + var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); + var remoteCts = new CancellationTokenSource(); + _ = remote.StartAsync(remoteCts.Token); + await remote.WaitForReadyAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + return new GatewayConnectionFixture(local, remote, localCts, remoteCts); + } + + public async Task WaitForRemoteInterestOnLocalAsync(string subject) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested) + { + if (Local.HasRemoteInterest(subject)) + return; + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'."); + } + + public async ValueTask DisposeAsync() + { + await _localCts.CancelAsync(); + await _remoteCts.CancelAsync(); + Local.Dispose(); + Remote.Dispose(); + _localCts.Dispose(); + _remoteCts.Dispose(); + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayForwardingTests.cs b/tests/NATS.Server.Tests/Gateways/GatewayForwardingTests.cs new file mode 100644 index 0000000..3cac958 --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayForwardingTests.cs @@ -0,0 +1,775 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server.Auth; +using NATS.Server.Configuration; +using NATS.Server.Gateways; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Gateways; + +/// +/// Gateway message forwarding, reply mapping, queue subscription delivery, +/// and cross-cluster pub/sub tests. +/// Ported from golang/nats-server/server/gateway_test.go. +/// +public class GatewayForwardingTests +{ + // ── Basic Message Forwarding ──────────────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Message_published_on_local_arrives_at_remote_subscriber() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await publisher.ConnectAsync(); + + await using var sub = await subscriber.SubscribeCoreAsync("fwd.test"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("fwd.test"); + + await publisher.PublishAsync("fwd.test", "hello-world"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("hello-world"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Message_published_on_remote_arrives_at_local_subscriber() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await publisher.ConnectAsync(); + + await using var sub = await subscriber.SubscribeCoreAsync("fwd.reverse"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnRemoteAsync("fwd.reverse"); + + await publisher.PublishAsync("fwd.reverse", "reverse-msg"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("reverse-msg"); + } + + // Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993 + [Fact] + public async Task Message_forwarded_only_once_to_remote_subscriber() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await publisher.ConnectAsync(); + + await using var sub = await subscriber.SubscribeCoreAsync("once.test"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("once.test"); + + await publisher.PublishAsync("once.test", "exactly-once"); + await publisher.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(3)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("exactly-once"); + + // Wait and verify no duplicates + await Task.Delay(300); + using var noMoreTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + await Should.ThrowAsync(async () => + await sub.Msgs.ReadAsync(noMoreTimeout.Token)); + } + + // Go: TestGatewaySendsToNonLocalSubs server/gateway_test.go:3140 + [Fact] + public async Task Message_without_local_subscriber_forwarded_to_remote() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await publisher.ConnectAsync(); + + // Subscribe only on remote, no local subscriber + await using var sub = await subscriber.SubscribeCoreAsync("only.remote"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("only.remote"); + + await publisher.PublishAsync("only.remote", "no-local"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("no-local"); + } + + // Go: TestGatewayDoesntSendBackToItself server/gateway_test.go:2150 + [Fact] + public async Task Both_local_and_remote_subscribers_receive_message_published_locally() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var remoteConn = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await remoteConn.ConnectAsync(); + + await using var localConn = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await localConn.ConnectAsync(); + + await using var remoteSub = await remoteConn.SubscribeCoreAsync("both.test"); + await remoteConn.PingAsync(); + + await using var localSub = await localConn.SubscribeCoreAsync("both.test"); + await localConn.PingAsync(); + + await fixture.WaitForRemoteInterestOnLocalAsync("both.test"); + + await localConn.PublishAsync("both.test", "shared"); + await localConn.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var localMsg = await localSub.Msgs.ReadAsync(timeout.Token); + localMsg.Data.ShouldBe("shared"); + + var remoteMsg = await remoteSub.Msgs.ReadAsync(timeout.Token); + remoteMsg.Data.ShouldBe("shared"); + } + + // ── Wildcard Subject Forwarding ───────────────────────────────────── + + // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 + [Fact] + public async Task Wildcard_subscription_receives_matching_gateway_messages() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await publisher.ConnectAsync(); + + await using var sub = await subscriber.SubscribeCoreAsync("wc.>"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("wc.test.one"); + + await publisher.PublishAsync("wc.test.one", "wildcard-msg"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Subject.ShouldBe("wc.test.one"); + msg.Data.ShouldBe("wildcard-msg"); + } + + // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 + [Fact] + public async Task Partial_wildcard_subscription_receives_gateway_messages() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await publisher.ConnectAsync(); + + await using var sub = await subscriber.SubscribeCoreAsync("orders.*"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("orders.created"); + + await publisher.PublishAsync("orders.created", "order-1"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Subject.ShouldBe("orders.created"); + msg.Data.ShouldBe("order-1"); + } + + // ── Reply Subject Mapping (_GR_. Prefix) ──────────────────────────── + + // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 + [Fact] + public void Reply_mapper_adds_gr_prefix_with_cluster_id() + { + var mapped = ReplyMapper.ToGatewayReply("_INBOX.abc", "CLUSTER-A"); + mapped.ShouldNotBeNull(); + mapped.ShouldStartWith("_GR_."); + mapped.ShouldContain("CLUSTER-A"); + } + + // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 + [Fact] + public void Reply_mapper_restores_original_reply() + { + var original = "_INBOX.abc123"; + var mapped = ReplyMapper.ToGatewayReply(original, "C1"); + mapped.ShouldNotBeNull(); + + ReplyMapper.TryRestoreGatewayReply(mapped, out var restored).ShouldBeTrue(); + restored.ShouldBe(original); + } + + // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 + [Fact] + public void Reply_mapper_handles_nested_gr_prefixes() + { + var original = "_INBOX.reply1"; + var once = ReplyMapper.ToGatewayReply(original, "CLUSTER-A"); + var twice = ReplyMapper.ToGatewayReply(once, "CLUSTER-B"); + + ReplyMapper.TryRestoreGatewayReply(twice!, out var restored).ShouldBeTrue(); + restored.ShouldBe(original); + } + + // Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586 + [Fact] + public void Reply_mapper_returns_null_for_null_input() + { + var result = ReplyMapper.ToGatewayReply(null, "CLUSTER"); + result.ShouldBeNull(); + } + + // Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586 + [Fact] + public void Reply_mapper_returns_empty_for_empty_input() + { + var result = ReplyMapper.ToGatewayReply("", "CLUSTER"); + result.ShouldBe(""); + } + + // Go: TestGatewayClientsDontReceiveMsgsOnGWPrefix server/gateway_test.go:5586 + [Fact] + public void Has_gateway_reply_prefix_detects_gr_prefix() + { + ReplyMapper.HasGatewayReplyPrefix("_GR_.CLUSTER.inbox").ShouldBeTrue(); + ReplyMapper.HasGatewayReplyPrefix("_INBOX.abc").ShouldBeFalse(); + ReplyMapper.HasGatewayReplyPrefix(null).ShouldBeFalse(); + ReplyMapper.HasGatewayReplyPrefix("").ShouldBeFalse(); + } + + // Go: TestGatewaySendReplyAcrossGateways server/gateway_test.go:5165 + [Fact] + public void Restore_returns_false_for_non_gr_subject() + { + ReplyMapper.TryRestoreGatewayReply("_INBOX.abc", out _).ShouldBeFalse(); + } + + // Go: TestGatewayReplyMapTracking server/gateway_test.go:6017 + [Fact] + public void Restore_returns_false_for_malformed_gr_subject() + { + // _GR_. with no cluster separator + ReplyMapper.TryRestoreGatewayReply("_GR_.nodot", out _).ShouldBeFalse(); + } + + // Go: TestGatewayReplyMapTracking server/gateway_test.go:6017 + [Fact] + public void Restore_returns_false_for_gr_prefix_with_nothing_after_separator() + { + ReplyMapper.TryRestoreGatewayReply("_GR_.CLUSTER.", out _).ShouldBeFalse(); + } + + // ── Queue Subscription Forwarding ─────────────────────────────────── + + // Go: TestGatewayQueueSub server/gateway_test.go:2265 + [Fact] + public async Task Queue_subscription_interest_tracked_on_remote() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); + + subList.HasRemoteInterest("$G", "foo").ShouldBeTrue(); + subList.MatchRemote("$G", "foo").Count.ShouldBe(1); + } + + // Go: TestGatewayQueueSub server/gateway_test.go:2265 + [Fact] + public async Task Queue_subscription_with_multiple_groups_all_tracked() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); + subList.ApplyRemoteSub(new RemoteSubscription("foo", "baz", "gw1", "$G")); + + subList.MatchRemote("$G", "foo").Count.ShouldBe(2); + } + + // Go: TestGatewayQueueSub server/gateway_test.go:2265 + [Fact] + public async Task Queue_sub_removal_clears_remote_interest() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); + subList.HasRemoteInterest("$G", "foo").ShouldBeTrue(); + + subList.ApplyRemoteSub(RemoteSubscription.Removal("foo", "bar", "gw1", "$G")); + subList.HasRemoteInterest("$G", "foo").ShouldBeFalse(); + } + + // ── GatewayManager Forwarding ─────────────────────────────────────── + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_manager_forward_message_increments_js_counter() + { + var manager = new GatewayManager( + new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 }, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + await manager.ForwardJetStreamClusterMessageAsync( + new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()), + default); + + manager.ForwardedJetStreamClusterMessages.ShouldBe(1); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public async Task Gateway_manager_forward_js_message_multiple_times() + { + var manager = new GatewayManager( + new GatewayOptions { Name = "GW", Host = "127.0.0.1", Port = 0 }, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + for (int i = 0; i < 5; i++) + { + await manager.ForwardJetStreamClusterMessageAsync( + new GatewayMessage("$JS.CLUSTER.test", null, "x"u8.ToArray()), + default); + } + + manager.ForwardedJetStreamClusterMessages.ShouldBe(5); + } + + // ── Multiple Messages ─────────────────────────────────────────────── + + // Go: TestGatewayMsgSentOnlyOnce server/gateway_test.go:2993 + [Fact] + public async Task Multiple_messages_forwarded_across_gateway() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await publisher.ConnectAsync(); + + await using var sub = await subscriber.SubscribeCoreAsync("multi.test"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("multi.test"); + + const int count = 10; + for (int i = 0; i < count; i++) + { + await publisher.PublishAsync("multi.test", $"msg-{i}"); + } + + await publisher.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var received = new List(); + for (int i = 0; i < count; i++) + { + var msg = await sub.Msgs.ReadAsync(timeout.Token); + received.Add(msg.Data!); + } + + received.Count.ShouldBe(count); + for (int i = 0; i < count; i++) + { + received.ShouldContain($"msg-{i}"); + } + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + // Verifies that a message published on local with a reply-to subject is forwarded + // to the remote with the reply-to intact, allowing manual request-reply across gateway. + [Fact] + public async Task Message_with_reply_to_forwarded_across_gateway() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var remoteConn = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await remoteConn.ConnectAsync(); + + // Subscribe on remote for requests + await using var sub = await remoteConn.SubscribeCoreAsync("svc.request"); + await remoteConn.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("svc.request"); + + // Publish from local with a reply-to subject via raw socket + using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(IPAddress.Loopback, fixture.Local.Port); + var infoBuf = new byte[4096]; + _ = await sock.ReceiveAsync(infoBuf); // read INFO + await sock.SendAsync(Encoding.ASCII.GetBytes( + "CONNECT {}\r\nPUB svc.request _INBOX.reply123 12\r\nrequest-data\r\nPING\r\n")); + + // Wait for PONG to confirm the message was processed + var pongBuf = new byte[4096]; + var pongTotal = new StringBuilder(); + using var pongCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!pongTotal.ToString().Contains("PONG")) + { + var n = await sock.ReceiveAsync(pongBuf, SocketFlags.None, pongCts.Token); + if (n == 0) break; + pongTotal.Append(Encoding.ASCII.GetString(pongBuf, 0, n)); + } + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("request-data"); + msg.ReplyTo.ShouldNotBeNullOrEmpty(); + } + + // ── Account Scoped Forwarding ─────────────────────────────────────── + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public async Task Messages_forwarded_within_same_account_only() + { + var users = new User[] + { + new() { Username = "user_a", Password = "pass", Account = "ACCT_A" }, + new() { Username = "user_b", Password = "pass", Account = "ACCT_B" }, + }; + + await using var fixture = await ForwardingFixture.StartWithUsersAsync(users); + + await using var remoteA = new NatsConnection(new NatsOpts + { + Url = $"nats://user_a:pass@127.0.0.1:{fixture.Remote.Port}", + }); + await remoteA.ConnectAsync(); + + await using var remoteB = new NatsConnection(new NatsOpts + { + Url = $"nats://user_b:pass@127.0.0.1:{fixture.Remote.Port}", + }); + await remoteB.ConnectAsync(); + + await using var publisherA = new NatsConnection(new NatsOpts + { + Url = $"nats://user_a:pass@127.0.0.1:{fixture.Local.Port}", + }); + await publisherA.ConnectAsync(); + + await using var subA = await remoteA.SubscribeCoreAsync("acct.test"); + await using var subB = await remoteB.SubscribeCoreAsync("acct.test"); + await remoteA.PingAsync(); + await remoteB.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("ACCT_A", "acct.test"); + + await publisherA.PublishAsync("acct.test", "for-account-a"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msgA = await subA.Msgs.ReadAsync(timeout.Token); + msgA.Data.ShouldBe("for-account-a"); + + // Account B should not receive + using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + await Should.ThrowAsync(async () => + await subB.Msgs.ReadAsync(noMsgTimeout.Token)); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public async Task Non_matching_subject_not_forwarded_after_interest_established() + { + await using var fixture = await ForwardingFixture.StartAsync(); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await subscriber.ConnectAsync(); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await publisher.ConnectAsync(); + + // Subscribe to a specific subject + await using var sub = await subscriber.SubscribeCoreAsync("specific.topic"); + await subscriber.PingAsync(); + await fixture.WaitForRemoteInterestOnLocalAsync("specific.topic"); + + // Publish to a different subject + await publisher.PublishAsync("other.topic", "should-not-arrive"); + await publisher.PingAsync(); + + await Task.Delay(300); + using var noMsgTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + await Should.ThrowAsync(async () => + await sub.Msgs.ReadAsync(noMsgTimeout.Token)); + } + + // Go: TestGatewayNoCrashOnInvalidSubject server/gateway_test.go:6279 + [Fact] + public void GatewayMessage_record_stores_all_fields() + { + var payload = new byte[] { 1, 2, 3 }; + var msg = new GatewayMessage("test.subject", "_INBOX.reply", payload, "MYACCT"); + + msg.Subject.ShouldBe("test.subject"); + msg.ReplyTo.ShouldBe("_INBOX.reply"); + msg.Payload.Length.ShouldBe(3); + msg.Account.ShouldBe("MYACCT"); + } + + // Go: TestGatewayBasic server/gateway_test.go:399 + [Fact] + public void GatewayMessage_defaults_account_to_global() + { + var msg = new GatewayMessage("test.subject", null, new byte[] { }); + msg.Account.ShouldBe("$G"); + } + + // ── Interest-Only Mode and ShouldForwardInterestOnly ──────────────── + + // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 + [Fact] + public void Should_forward_interest_only_returns_true_when_interest_exists() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A")); + + GatewayManager.ShouldForwardInterestOnly(subList, "A", "orders.created").ShouldBeTrue(); + } + + // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 + [Fact] + public void Should_forward_interest_only_returns_false_without_interest() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A")); + + GatewayManager.ShouldForwardInterestOnly(subList, "A", "payments.created").ShouldBeFalse(); + } + + // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 + [Fact] + public void Should_forward_interest_only_for_different_account_returns_false() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "A")); + + GatewayManager.ShouldForwardInterestOnly(subList, "B", "orders.created").ShouldBeFalse(); + } + + // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 + [Fact] + public void Should_forward_with_wildcard_interest() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("test.*", null, "gw1", "$G")); + + GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.one").ShouldBeTrue(); + GatewayManager.ShouldForwardInterestOnly(subList, "$G", "test.two").ShouldBeTrue(); + GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.one").ShouldBeFalse(); + } + + // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 + [Fact] + public void Should_forward_with_fwc_interest() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("events.>", null, "gw1", "$G")); + + GatewayManager.ShouldForwardInterestOnly(subList, "$G", "events.a.b.c").ShouldBeTrue(); + GatewayManager.ShouldForwardInterestOnly(subList, "$G", "other.x").ShouldBeFalse(); + } +} + +/// +/// Shared fixture for forwarding tests that need two running server clusters. +/// +internal sealed class ForwardingFixture : IAsyncDisposable +{ + private readonly CancellationTokenSource _localCts; + private readonly CancellationTokenSource _remoteCts; + + private ForwardingFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts) + { + Local = local; + Remote = remote; + _localCts = localCts; + _remoteCts = remoteCts; + } + + public NatsServer Local { get; } + public NatsServer Remote { get; } + + public static Task StartAsync() + => StartWithUsersAsync(null); + + public static async Task StartWithUsersAsync(IReadOnlyList? users) + { + var localOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Gateway = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var local = new NatsServer(localOptions, NullLoggerFactory.Instance); + var localCts = new CancellationTokenSource(); + _ = local.StartAsync(localCts.Token); + await local.WaitForReadyAsync(); + + var remoteOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Gateway = new GatewayOptions + { + Name = "REMOTE", + Host = "127.0.0.1", + Port = 0, + Remotes = [local.GatewayListen!], + }, + }; + + var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); + var remoteCts = new CancellationTokenSource(); + _ = remote.StartAsync(remoteCts.Token); + await remote.WaitForReadyAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + return new ForwardingFixture(local, remote, localCts, remoteCts); + } + + public async Task WaitForRemoteInterestOnLocalAsync(string subject) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested) + { + if (Local.HasRemoteInterest(subject)) + return; + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'."); + } + + public async Task WaitForRemoteInterestOnLocalAsync(string account, string subject) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested) + { + if (Local.HasRemoteInterest(account, subject)) + return; + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException($"Timed out waiting for remote interest {account}:{subject}."); + } + + public async Task WaitForRemoteInterestOnRemoteAsync(string subject) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested) + { + if (Remote.HasRemoteInterest(subject)) + return; + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException($"Timed out waiting for remote interest on '{subject}'."); + } + + public async ValueTask DisposeAsync() + { + await _localCts.CancelAsync(); + await _remoteCts.CancelAsync(); + Local.Dispose(); + Remote.Dispose(); + _localCts.Dispose(); + _remoteCts.Dispose(); + } +} diff --git a/tests/NATS.Server.Tests/Gateways/GatewayInterestModeTests.cs b/tests/NATS.Server.Tests/Gateways/GatewayInterestModeTests.cs new file mode 100644 index 0000000..9c4a42b --- /dev/null +++ b/tests/NATS.Server.Tests/Gateways/GatewayInterestModeTests.cs @@ -0,0 +1,576 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server.Auth; +using NATS.Server.Configuration; +using NATS.Server.Gateways; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Gateways; + +/// +/// Gateway interest-only mode, account interest, subject interest propagation, +/// and subscription lifecycle tests. +/// Ported from golang/nats-server/server/gateway_test.go. +/// +public class GatewayInterestModeTests +{ + // ── Remote Interest Tracking via SubList ───────────────────────────── + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void Remote_interest_tracked_for_literal_subject() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.created", null, "gw1", "$G")); + + subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); + subList.HasRemoteInterest("$G", "orders.updated").ShouldBeFalse(); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void Remote_interest_tracked_for_wildcard_subject() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G")); + + subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); + subList.HasRemoteInterest("$G", "orders.updated").ShouldBeTrue(); + subList.HasRemoteInterest("$G", "orders.deep.nested").ShouldBeFalse(); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void Remote_interest_tracked_for_fwc_subject() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("events.>", null, "gw1", "$G")); + + subList.HasRemoteInterest("$G", "events.one").ShouldBeTrue(); + subList.HasRemoteInterest("$G", "events.one.two.three").ShouldBeTrue(); + subList.HasRemoteInterest("$G", "other").ShouldBeFalse(); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void Remote_interest_scoped_to_account() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "ACCT_A")); + + subList.HasRemoteInterest("ACCT_A", "orders.created").ShouldBeTrue(); + subList.HasRemoteInterest("ACCT_B", "orders.created").ShouldBeFalse(); + subList.HasRemoteInterest("$G", "orders.created").ShouldBeFalse(); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public void Remote_interest_removed_on_aminus() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.>", null, "gw1", "$G")); + subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); + + subList.ApplyRemoteSub(RemoteSubscription.Removal("orders.>", null, "gw1", "$G")); + subList.HasRemoteInterest("$G", "orders.created").ShouldBeFalse(); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void Multiple_remote_interests_from_different_routes() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G")); + subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw2", "$G")); + + subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); + subList.MatchRemote("$G", "orders.created").Count.ShouldBe(2); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public void Removing_one_route_interest_keeps_other() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw1", "$G")); + subList.ApplyRemoteSub(new RemoteSubscription("orders.*", null, "gw2", "$G")); + + subList.ApplyRemoteSub(RemoteSubscription.Removal("orders.*", null, "gw1", "$G")); + subList.HasRemoteInterest("$G", "orders.created").ShouldBeTrue(); + subList.MatchRemote("$G", "orders.created").Count.ShouldBe(1); + } + + // ── Interest Change Events ────────────────────────────────────────── + + // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 + [Fact] + public void Interest_change_event_fired_on_remote_add() + { + using var subList = new SubList(); + var changes = new List(); + subList.InterestChanged += change => changes.Add(change); + + subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); + + changes.Count.ShouldBe(1); + changes[0].Kind.ShouldBe(InterestChangeKind.RemoteAdded); + changes[0].Subject.ShouldBe("test.>"); + changes[0].Account.ShouldBe("$G"); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public void Interest_change_event_fired_on_remote_remove() + { + using var subList = new SubList(); + var changes = new List(); + subList.InterestChanged += change => changes.Add(change); + + subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); + subList.ApplyRemoteSub(RemoteSubscription.Removal("test.>", null, "gw1", "$G")); + + changes.Count.ShouldBe(2); + changes[1].Kind.ShouldBe(InterestChangeKind.RemoteRemoved); + } + + // Go: TestGatewaySwitchToInterestOnlyModeImmediately server/gateway_test.go:6934 + [Fact] + public void Duplicate_remote_add_does_not_fire_extra_event() + { + using var subList = new SubList(); + var addCount = 0; + subList.InterestChanged += change => + { + if (change.Kind == InterestChangeKind.RemoteAdded) + addCount++; + }; + + subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); + subList.ApplyRemoteSub(new RemoteSubscription("test.>", null, "gw1", "$G")); + + addCount.ShouldBe(1); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public void Remove_nonexistent_subscription_does_not_fire_event() + { + using var subList = new SubList(); + var removeCount = 0; + subList.InterestChanged += change => + { + if (change.Kind == InterestChangeKind.RemoteRemoved) + removeCount++; + }; + + subList.ApplyRemoteSub(RemoteSubscription.Removal("nonexistent", null, "gw1", "$G")); + + removeCount.ShouldBe(0); + } + + // ── Queue Weight in MatchRemote ───────────────────────────────────── + + // Go: TestGatewayTotalQSubs server/gateway_test.go:2484 + [Fact] + public void Match_remote_expands_queue_weight() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G", QueueWeight: 3)); + + var matches = subList.MatchRemote("$G", "foo"); + matches.Count.ShouldBe(3); + } + + // Go: TestGatewayTotalQSubs server/gateway_test.go:2484 + [Fact] + public void Match_remote_default_weight_is_one() + { + using var subList = new SubList(); + subList.ApplyRemoteSub(new RemoteSubscription("foo", "bar", "gw1", "$G")); + + var matches = subList.MatchRemote("$G", "foo"); + matches.Count.ShouldBe(1); + } + + // ── End-to-End Interest Propagation via Gateway ───────────────────── + + // Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755 + [Fact] + public async Task Local_subscription_propagated_to_remote_via_gateway() + { + await using var fixture = await InterestModeFixture.StartAsync(); + + await using var localConn = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await localConn.ConnectAsync(); + + await using var sub = await localConn.SubscribeCoreAsync("prop.test"); + await localConn.PingAsync(); + + // The remote server should see the interest + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && !fixture.Remote.HasRemoteInterest("prop.test")) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + fixture.Remote.HasRemoteInterest("prop.test").ShouldBeTrue(); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public async Task Unsubscribe_propagated_to_remote_via_gateway() + { + await using var fixture = await InterestModeFixture.StartAsync(); + + await using var localConn = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Local.Port}", + }); + await localConn.ConnectAsync(); + + var sub = await localConn.SubscribeCoreAsync("unsub.test"); + await localConn.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && !fixture.Remote.HasRemoteInterest("unsub.test")) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + fixture.Remote.HasRemoteInterest("unsub.test").ShouldBeTrue(); + + // Unsubscribe + await sub.DisposeAsync(); + await localConn.PingAsync(); + + // Wait for interest to be removed + using var unsubTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!unsubTimeout.IsCancellationRequested && fixture.Remote.HasRemoteInterest("unsub.test")) + await Task.Delay(50, unsubTimeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + fixture.Remote.HasRemoteInterest("unsub.test").ShouldBeFalse(); + } + + // Go: TestGatewaySubjectInterest server/gateway_test.go:1972 + [Fact] + public async Task Remote_wildcard_subscription_establishes_interest() + { + await using var fixture = await InterestModeFixture.StartAsync(); + + await using var remoteConn = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await remoteConn.ConnectAsync(); + + await using var sub = await remoteConn.SubscribeCoreAsync("interest.>"); + await remoteConn.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("interest.test")) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + fixture.Local.HasRemoteInterest("interest.test").ShouldBeTrue(); + fixture.Local.HasRemoteInterest("interest.deep.nested").ShouldBeTrue(); + } + + // Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755 + [Fact] + public async Task Multiple_subscribers_same_subject_produces_single_interest() + { + await using var fixture = await InterestModeFixture.StartAsync(); + + await using var conn1 = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await conn1.ConnectAsync(); + + await using var conn2 = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{fixture.Remote.Port}", + }); + await conn2.ConnectAsync(); + + await using var sub1 = await conn1.SubscribeCoreAsync("multi.interest"); + await using var sub2 = await conn2.SubscribeCoreAsync("multi.interest"); + await conn1.PingAsync(); + await conn2.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("multi.interest")) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + fixture.Local.HasRemoteInterest("multi.interest").ShouldBeTrue(); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public async Task Account_scoped_interest_propagated_via_gateway() + { + var users = new User[] + { + new() { Username = "acct_user", Password = "pass", Account = "MYACCT" }, + }; + + await using var fixture = await InterestModeFixture.StartWithUsersAsync(users); + + await using var conn = new NatsConnection(new NatsOpts + { + Url = $"nats://acct_user:pass@127.0.0.1:{fixture.Remote.Port}", + }); + await conn.ConnectAsync(); + + await using var sub = await conn.SubscribeCoreAsync("acct.interest"); + await conn.PingAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && !fixture.Local.HasRemoteInterest("MYACCT", "acct.interest")) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + fixture.Local.HasRemoteInterest("MYACCT", "acct.interest").ShouldBeTrue(); + } + + // ── RemoteSubscription Record Tests ───────────────────────────────── + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void RemoteSubscription_record_equality() + { + var a = new RemoteSubscription("foo", null, "gw1", "$G"); + var b = new RemoteSubscription("foo", null, "gw1", "$G"); + a.ShouldBe(b); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void RemoteSubscription_removal_factory() + { + var removal = RemoteSubscription.Removal("foo", "bar", "gw1", "$G"); + removal.IsRemoval.ShouldBeTrue(); + removal.Subject.ShouldBe("foo"); + removal.Queue.ShouldBe("bar"); + removal.RouteId.ShouldBe("gw1"); + } + + // Go: TestGatewayAccountInterest server/gateway_test.go:1794 + [Fact] + public void RemoteSubscription_default_account_is_global() + { + var sub = new RemoteSubscription("foo", null, "gw1"); + sub.Account.ShouldBe("$G"); + } + + // Go: TestGatewayTotalQSubs server/gateway_test.go:2484 + [Fact] + public void RemoteSubscription_default_queue_weight_is_one() + { + var sub = new RemoteSubscription("foo", "bar", "gw1"); + sub.QueueWeight.ShouldBe(1); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public void RemoteSubscription_default_is_not_removal() + { + var sub = new RemoteSubscription("foo", null, "gw1"); + sub.IsRemoval.ShouldBeFalse(); + } + + // ── Subscription Propagation by GatewayManager ────────────────────── + + // Go: TestGatewayDontSendSubInterest server/gateway_test.go:1755 + [Fact] + public async Task Gateway_manager_propagate_subscription_sends_aplus() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var options = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + Remotes = [$"127.0.0.1:{port}"], + }; + var manager = new GatewayManager( + options, + new ServerStats(), + "SERVER1", + _ => { }, + _ => { }, + NullLogger.Instance); + + await manager.StartAsync(CancellationToken.None); + + // Accept the connection from gateway manager + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var gwSocket = await listener.AcceptSocketAsync(cts.Token); + + // Exchange handshakes + var line = await ReadLineAsync(gwSocket, cts.Token); + line.ShouldStartWith("GATEWAY "); + await WriteLineAsync(gwSocket, "GATEWAY REMOTE1", cts.Token); + + // Wait for connection to be registered + await Task.Delay(200); + + // Propagate a subscription + manager.PropagateLocalSubscription("$G", "orders.>", null); + + // Read the A+ message + await Task.Delay(100); + var aplusLine = await ReadLineAsync(gwSocket, cts.Token); + aplusLine.ShouldBe("A+ $G orders.>"); + + await manager.DisposeAsync(); + } + + // Go: TestGatewayAccountUnsub server/gateway_test.go:1912 + [Fact] + public async Task Gateway_manager_propagate_unsubscription_sends_aminus() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var options = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + Remotes = [$"127.0.0.1:{port}"], + }; + var manager = new GatewayManager( + options, + new ServerStats(), + "SERVER1", + _ => { }, + _ => { }, + NullLogger.Instance); + + await manager.StartAsync(CancellationToken.None); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var gwSocket = await listener.AcceptSocketAsync(cts.Token); + + var line = await ReadLineAsync(gwSocket, cts.Token); + line.ShouldStartWith("GATEWAY "); + await WriteLineAsync(gwSocket, "GATEWAY REMOTE1", cts.Token); + + await Task.Delay(200); + + manager.PropagateLocalUnsubscription("$G", "orders.>", null); + + await Task.Delay(100); + var aminusLine = await ReadLineAsync(gwSocket, cts.Token); + aminusLine.ShouldBe("A- $G orders.>"); + + await manager.DisposeAsync(); + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, ct); + if (read == 0) + break; + if (single[0] == (byte)'\n') + break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); +} + +/// +/// Shared fixture for interest mode tests. +/// +internal sealed class InterestModeFixture : IAsyncDisposable +{ + private readonly CancellationTokenSource _localCts; + private readonly CancellationTokenSource _remoteCts; + + private InterestModeFixture(NatsServer local, NatsServer remote, CancellationTokenSource localCts, CancellationTokenSource remoteCts) + { + Local = local; + Remote = remote; + _localCts = localCts; + _remoteCts = remoteCts; + } + + public NatsServer Local { get; } + public NatsServer Remote { get; } + + public static Task StartAsync() + => StartWithUsersAsync(null); + + public static async Task StartWithUsersAsync(IReadOnlyList? users) + { + var localOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Gateway = new GatewayOptions + { + Name = "LOCAL", + Host = "127.0.0.1", + Port = 0, + }, + }; + + var local = new NatsServer(localOptions, NullLoggerFactory.Instance); + var localCts = new CancellationTokenSource(); + _ = local.StartAsync(localCts.Token); + await local.WaitForReadyAsync(); + + var remoteOptions = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Gateway = new GatewayOptions + { + Name = "REMOTE", + Host = "127.0.0.1", + Port = 0, + Remotes = [local.GatewayListen!], + }, + }; + + var remote = new NatsServer(remoteOptions, NullLoggerFactory.Instance); + var remoteCts = new CancellationTokenSource(); + _ = remote.StartAsync(remoteCts.Token); + await remote.WaitForReadyAsync(); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!timeout.IsCancellationRequested && (local.Stats.Gateways == 0 || remote.Stats.Gateways == 0)) + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + + return new InterestModeFixture(local, remote, localCts, remoteCts); + } + + public async ValueTask DisposeAsync() + { + await _localCts.CancelAsync(); + await _remoteCts.CancelAsync(); + Local.Dispose(); + Remote.Dispose(); + _localCts.Dispose(); + _remoteCts.Dispose(); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterConsumerTests.cs b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterConsumerTests.cs new file mode 100644 index 0000000..68ab47f --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterConsumerTests.cs @@ -0,0 +1,644 @@ +// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go +// Covers: consumer creation, ack propagation, consumer state, +// ephemeral consumers, consumer scaling, pull/push delivery, +// redelivery, ack policies, filter subjects. +using System.Text; +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Cluster; +using NATS.Server.JetStream.Consumers; +using NATS.Server.JetStream.Models; +using NATS.Server.JetStream.Publish; + +namespace NATS.Server.Tests.JetStream.Cluster; + +/// +/// Tests covering clustered JetStream consumer creation, leader election, +/// ack propagation, delivery policies, ephemeral consumers, and scaling. +/// Ported from Go jetstream_cluster_1_test.go and jetstream_cluster_2_test.go. +/// +public class JetStreamClusterConsumerTests +{ + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConsumerState server/jetstream_cluster_1_test.go:700 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_state_tracks_pending_after_fetch() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("CSTATE", ["cs.>"], replicas: 3); + await fx.CreateConsumerAsync("CSTATE", "track", filterSubject: "cs.>", ackPolicy: AckPolicy.Explicit); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("cs.event", $"msg-{i}"); + + var batch = await fx.FetchAsync("CSTATE", "track", 3); + batch.Messages.Count.ShouldBe(3); + + var pending = fx.GetPendingCount("CSTATE", "track"); + pending.ShouldBe(3); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConsumerRedeliveredInfo server/jetstream_cluster_1_test.go:659 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_redelivery_marks_messages_as_redelivered() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("REDELIV", ["rd.>"], replicas: 3); + await fx.CreateConsumerAsync("REDELIV", "rdc", filterSubject: "rd.>", + ackPolicy: AckPolicy.Explicit, ackWaitMs: 1, maxDeliver: 5); + + await fx.PublishAsync("rd.event", "will-redeliver"); + + // First fetch should get the message + var batch1 = await fx.FetchAsync("REDELIV", "rdc", 1); + batch1.Messages.Count.ShouldBe(1); + batch1.Messages[0].Redelivered.ShouldBeFalse(); + + // Wait for ack timeout + await Task.Delay(50); + + // Second fetch should get redelivered message + var batch2 = await fx.FetchAsync("REDELIV", "rdc", 1); + batch2.Messages.Count.ShouldBe(1); + batch2.Messages[0].Redelivered.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterFullConsumerState server/jetstream_cluster_1_test.go:795 + // --------------------------------------------------------------- + + [Fact] + public async Task Full_consumer_state_reflects_ack_floor_after_ack_all() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("FULLCS", ["fcs.>"], replicas: 3); + await fx.CreateConsumerAsync("FULLCS", "full", filterSubject: "fcs.>", ackPolicy: AckPolicy.All); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("fcs.event", $"msg-{i}"); + + var batch = await fx.FetchAsync("FULLCS", "full", 10); + batch.Messages.Count.ShouldBe(10); + + // Ack all up to sequence 5 + fx.AckAll("FULLCS", "full", 5); + + var pending = fx.GetPendingCount("FULLCS", "full"); + pending.ShouldBe(5); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterEphemeralConsumerNoImmediateInterest server/jetstream_cluster_1_test.go:2481 + // --------------------------------------------------------------- + + [Fact] + public async Task Ephemeral_consumer_creation_succeeds() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("EPHEM", ["eph.>"], replicas: 3); + var resp = await fx.CreateConsumerAsync("EPHEM", null, ephemeral: true); + + resp.ConsumerInfo.ShouldNotBeNull(); + resp.ConsumerInfo!.Config.DurableName.ShouldNotBeNullOrEmpty(); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterEphemeralConsumersNotReplicated server/jetstream_cluster_1_test.go:2599 + // --------------------------------------------------------------- + + [Fact] + public async Task Multiple_ephemeral_consumers_have_unique_names() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("EPHUNIQ", ["eu.>"], replicas: 3); + + var resp1 = await fx.CreateConsumerAsync("EPHUNIQ", null, ephemeral: true); + var resp2 = await fx.CreateConsumerAsync("EPHUNIQ", null, ephemeral: true); + + resp1.ConsumerInfo!.Config.DurableName.ShouldNotBe(resp2.ConsumerInfo!.Config.DurableName); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterCreateConcurrentDurableConsumers server/jetstream_cluster_2_test.go:1572 + // --------------------------------------------------------------- + + [Fact] + public async Task Concurrent_durable_consumer_creation_is_idempotent() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("CONC", ["conc.>"], replicas: 3); + + // Create same consumer twice; both should succeed + var resp1 = await fx.CreateConsumerAsync("CONC", "same"); + var resp2 = await fx.CreateConsumerAsync("CONC", "same"); + + resp1.ConsumerInfo.ShouldNotBeNull(); + resp2.ConsumerInfo.ShouldNotBeNull(); + resp1.ConsumerInfo!.Config.DurableName.ShouldBe("same"); + resp2.ConsumerInfo!.Config.DurableName.ShouldBe("same"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterPullConsumerLeakedSubs server/jetstream_cluster_2_test.go:2239 + // --------------------------------------------------------------- + + [Fact] + public async Task Pull_consumer_fetch_returns_correct_batch_size() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("PULLBS", ["pb.>"], replicas: 3); + await fx.CreateConsumerAsync("PULLBS", "puller", filterSubject: "pb.>", ackPolicy: AckPolicy.None); + + for (var i = 0; i < 20; i++) + await fx.PublishAsync("pb.event", $"msg-{i}"); + + var batch = await fx.FetchAsync("PULLBS", "puller", 5); + batch.Messages.Count.ShouldBe(5); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConsumerLastActiveReporting server/jetstream_cluster_2_test.go:2371 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_info_returns_config_after_creation() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("CINFO", ["ci.>"], replicas: 3); + await fx.CreateConsumerAsync("CINFO", "info_dur", filterSubject: "ci.>", ackPolicy: AckPolicy.Explicit); + + var info = await fx.GetConsumerInfoAsync("CINFO", "info_dur"); + info.ShouldNotBeNull(); + info.Config.DurableName.ShouldBe("info_dur"); + info.Config.AckPolicy.ShouldBe(AckPolicy.Explicit); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterAckPendingWithExpired server/jetstream_cluster_2_test.go:309 + // --------------------------------------------------------------- + + [Fact] + public async Task Ack_pending_tracks_expired_messages() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("ACKEXP", ["ae.>"], replicas: 3); + await fx.CreateConsumerAsync("ACKEXP", "acker", filterSubject: "ae.>", + ackPolicy: AckPolicy.Explicit, ackWaitMs: 1, maxDeliver: 10); + + await fx.PublishAsync("ae.event", "will-expire"); + + // Fetch to register pending + var batch1 = await fx.FetchAsync("ACKEXP", "acker", 1); + batch1.Messages.Count.ShouldBe(1); + + fx.GetPendingCount("ACKEXP", "acker").ShouldBe(1); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterAckPendingWithMaxRedelivered server/jetstream_cluster_2_test.go:377 + // --------------------------------------------------------------- + + [Fact] + public async Task Max_deliver_limits_redelivery_attempts() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("MAXRED", ["mr.>"], replicas: 3); + // maxDeliver=2: allows initial delivery (deliveries=1) + one redelivery (deliveries=2). + // After ScheduleRedelivery increments to deliveries=2, the next check has deliveries=2 > maxDeliver=2 = false, + // so it redelivers once more. Only at deliveries=3 > 2 does it stop. + await fx.CreateConsumerAsync("MAXRED", "maxr", filterSubject: "mr.>", + ackPolicy: AckPolicy.Explicit, ackWaitMs: 1, maxDeliver: 2); + + await fx.PublishAsync("mr.event", "limited-redeliver"); + + // First fetch (initial delivery, Register sets deliveries=1) + var batch1 = await fx.FetchAsync("MAXRED", "maxr", 1); + batch1.Messages.Count.ShouldBe(1); + + // Wait for expiry + await Task.Delay(50); + + // Second fetch: TryGetExpired returns deliveries=1, 1 > 2 is false, so redeliver. + // ScheduleRedelivery increments to deliveries=2. + var batch2 = await fx.FetchAsync("MAXRED", "maxr", 1); + batch2.Messages.Count.ShouldBe(1); + batch2.Messages[0].Redelivered.ShouldBeTrue(); + + // Wait for expiry + await Task.Delay(50); + + // Third fetch: TryGetExpired returns deliveries=2, 2 > 2 is false, so redeliver again. + // ScheduleRedelivery increments to deliveries=3. + var batch3 = await fx.FetchAsync("MAXRED", "maxr", 1); + batch3.Messages.Count.ShouldBe(1); + batch3.Messages[0].Redelivered.ShouldBeTrue(); + + // Wait for expiry + await Task.Delay(50); + + // Fourth fetch: TryGetExpired returns deliveries=3, 3 > 2 is true, so AckAll triggers + // and returns empty batch (max deliver exceeded). + var batch4 = await fx.FetchAsync("MAXRED", "maxr", 1); + batch4.Messages.Count.ShouldBe(0); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMaxConsumers server/jetstream_cluster_2_test.go:1978 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_delete_succeeds_in_cluster() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("CDEL", ["cdel.>"], replicas: 3); + await fx.CreateConsumerAsync("CDEL", "to_delete"); + + var del = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}CDEL.to_delete", "{}"); + del.Success.ShouldBeTrue(); + + var info = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerInfo}CDEL.to_delete", "{}"); + info.Error.ShouldNotBeNull(); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterFlowControlRequiresHeartbeats server/jetstream_cluster_2_test.go:2712 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_with_filter_subjects_delivers_matching_only() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("FILT", ["filt.>"], replicas: 3); + await fx.CreateConsumerAsync("FILT", "filtered", filterSubject: "filt.alpha"); + + await fx.PublishAsync("filt.alpha", "match"); + await fx.PublishAsync("filt.beta", "no-match"); + await fx.PublishAsync("filt.alpha", "match2"); + + var batch = await fx.FetchAsync("FILT", "filtered", 10); + batch.Messages.Count.ShouldBe(2); + batch.Messages[0].Subject.ShouldBe("filt.alpha"); + batch.Messages[1].Subject.ShouldBe("filt.alpha"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConsumerScaleUp server/jetstream_cluster_1_test.go:4203 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_pause_and_resume_via_api() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("PAUSE", ["pause.>"], replicas: 3); + await fx.CreateConsumerAsync("PAUSE", "pausable"); + + var pauseResp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerPause}PAUSE.pausable", """{"pause":true}"""); + pauseResp.Success.ShouldBeTrue(); + + var resumeResp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerPause}PAUSE.pausable", """{"pause":false}"""); + resumeResp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConsumerResetPendingDeliveriesOnMaxAckPendingUpdate + // server/jetstream_cluster_1_test.go:8696 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_reset_resets_next_sequence_and_returns_success() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("RESET", ["reset.>"], replicas: 3); + await fx.CreateConsumerAsync("RESET", "resettable", filterSubject: "reset.>"); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("reset.event", $"msg-{i}"); + + // Fetch some messages to advance the consumer + var batch1 = await fx.FetchAsync("RESET", "resettable", 3); + batch1.Messages.Count.ShouldBe(3); + + // Reset via API + var resetResp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerReset}RESET.resettable", "{}"); + resetResp.Success.ShouldBeTrue(); + + // After reset, consumer should re-deliver from sequence 1 + var batch2 = await fx.FetchAsync("RESET", "resettable", 5); + batch2.Messages.Count.ShouldBe(5); + batch2.Messages[0].Sequence.ShouldBe(1UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterPushConsumerQueueGroup server/jetstream_cluster_2_test.go:2300 + // --------------------------------------------------------------- + + [Fact] + public async Task Push_consumer_creation_with_heartbeat() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("PUSHHB", ["ph.>"], replicas: 3); + var resp = await fx.CreateConsumerAsync("PUSHHB", "pusher", push: true, heartbeatMs: 100); + + resp.ConsumerInfo.ShouldNotBeNull(); + resp.ConsumerInfo!.Config.Push.ShouldBeTrue(); + resp.ConsumerInfo.Config.HeartbeatMs.ShouldBe(100); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterScaleConsumer server/jetstream_cluster_1_test.go:4109 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_unpin_via_api() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("UNPIN", ["unpin.>"], replicas: 3); + await fx.CreateConsumerAsync("UNPIN", "pinned"); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerUnpin}UNPIN.pinned", "{}"); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Additional: Consumer AckAll policy acks all up to given sequence + // --------------------------------------------------------------- + + [Fact] + public async Task AckAll_policy_consumer_acks_all_preceding_messages() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("ACKALL", ["aa.>"], replicas: 3); + await fx.CreateConsumerAsync("ACKALL", "acker", filterSubject: "aa.>", ackPolicy: AckPolicy.All); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("aa.event", $"msg-{i}"); + + var batch = await fx.FetchAsync("ACKALL", "acker", 10); + batch.Messages.Count.ShouldBe(10); + + // Ack up to seq 7 (all 1-7 should be acked, 8-10 remain pending) + fx.AckAll("ACKALL", "acker", 7); + fx.GetPendingCount("ACKALL", "acker").ShouldBe(3); + } + + // --------------------------------------------------------------- + // Additional: DeliverPolicy.Last consumer starts at last message + // --------------------------------------------------------------- + + [Fact] + public async Task DeliverPolicy_Last_consumer_starts_at_last_sequence() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("DLAST", ["dl.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("dl.event", $"msg-{i}"); + + await fx.CreateConsumerAsync("DLAST", "last_cons", filterSubject: "dl.>", + deliverPolicy: DeliverPolicy.Last); + + var batch = await fx.FetchAsync("DLAST", "last_cons", 10); + batch.Messages.Count.ShouldBe(1); + batch.Messages[0].Sequence.ShouldBe(5UL); + } + + // --------------------------------------------------------------- + // Additional: DeliverPolicy.New consumer skips existing messages + // --------------------------------------------------------------- + + [Fact] + public async Task DeliverPolicy_New_consumer_skips_existing() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("DNEW", ["dn.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("dn.event", $"msg-{i}"); + + await fx.CreateConsumerAsync("DNEW", "new_cons", filterSubject: "dn.>", + deliverPolicy: DeliverPolicy.New); + + // Should get no messages since consumer starts at LastSeq+1 + var batch = await fx.FetchAsync("DNEW", "new_cons", 10); + batch.Messages.Count.ShouldBe(0); + + // Publish a new message after consumer creation + await fx.PublishAsync("dn.event", "after-consumer"); + + var batch2 = await fx.FetchAsync("DNEW", "new_cons", 10); + batch2.Messages.Count.ShouldBe(1); + } + + // --------------------------------------------------------------- + // Additional: DeliverPolicy.ByStartSequence + // --------------------------------------------------------------- + + [Fact] + public async Task DeliverPolicy_ByStartSequence_starts_at_given_sequence() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("DSTART", ["ds.>"], replicas: 3); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("ds.event", $"msg-{i}"); + + await fx.CreateConsumerAsync("DSTART", "start_cons", filterSubject: "ds.>", + deliverPolicy: DeliverPolicy.ByStartSequence, optStartSeq: 7); + + var batch = await fx.FetchAsync("DSTART", "start_cons", 10); + batch.Messages.Count.ShouldBe(4); // seq 7, 8, 9, 10 + batch.Messages[0].Sequence.ShouldBe(7UL); + } + + // --------------------------------------------------------------- + // Additional: Multiple filter subjects + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_with_multiple_filter_subjects() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("MFILT", ["mf.>"], replicas: 3); + await fx.CreateConsumerAsync("MFILT", "multi_filt", + filterSubjects: ["mf.alpha", "mf.gamma"]); + + await fx.PublishAsync("mf.alpha", "a"); + await fx.PublishAsync("mf.beta", "b"); + await fx.PublishAsync("mf.gamma", "g"); + await fx.PublishAsync("mf.delta", "d"); + + var batch = await fx.FetchAsync("MFILT", "multi_filt", 10); + batch.Messages.Count.ShouldBe(2); + } + + // --------------------------------------------------------------- + // Additional: NoWait fetch returns empty when no messages + // --------------------------------------------------------------- + + [Fact] + public async Task NoWait_fetch_returns_empty_when_no_pending() + { + await using var fx = await ClusterConsumerFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("NOWAIT", ["nw.>"], replicas: 3); + await fx.CreateConsumerAsync("NOWAIT", "nw_cons", filterSubject: "nw.>"); + + var batch = await fx.FetchNoWaitAsync("NOWAIT", "nw_cons", 5); + batch.Messages.Count.ShouldBe(0); + } +} + +/// +/// Self-contained fixture for JetStream cluster consumer tests. +/// +internal sealed class ClusterConsumerFixture : IAsyncDisposable +{ + private readonly JetStreamMetaGroup _metaGroup; + private readonly StreamManager _streamManager; + private readonly ConsumerManager _consumerManager; + private readonly JetStreamApiRouter _router; + private readonly JetStreamPublisher _publisher; + + private ClusterConsumerFixture( + JetStreamMetaGroup metaGroup, + StreamManager streamManager, + ConsumerManager consumerManager, + JetStreamApiRouter router, + JetStreamPublisher publisher) + { + _metaGroup = metaGroup; + _streamManager = streamManager; + _consumerManager = consumerManager; + _router = router; + _publisher = publisher; + } + + public static Task StartAsync(int nodes) + { + var meta = new JetStreamMetaGroup(nodes); + var consumerManager = new ConsumerManager(meta); + var streamManager = new StreamManager(meta, consumerManager: consumerManager); + var router = new JetStreamApiRouter(streamManager, consumerManager, meta); + var publisher = new JetStreamPublisher(streamManager); + return Task.FromResult(new ClusterConsumerFixture(meta, streamManager, consumerManager, router, publisher)); + } + + public Task CreateStreamAsync(string name, string[] subjects, int replicas) + { + var response = _streamManager.CreateOrUpdate(new StreamConfig + { + Name = name, + Subjects = [.. subjects], + Replicas = replicas, + }); + if (response.Error is not null) + throw new InvalidOperationException(response.Error.Description); + return Task.CompletedTask; + } + + public Task CreateConsumerAsync( + string stream, + string? durableName, + string? filterSubject = null, + AckPolicy ackPolicy = AckPolicy.None, + int ackWaitMs = 30_000, + int maxDeliver = 1, + bool ephemeral = false, + bool push = false, + int heartbeatMs = 0, + DeliverPolicy deliverPolicy = DeliverPolicy.All, + ulong optStartSeq = 0, + IReadOnlyList? filterSubjects = null) + { + var config = new ConsumerConfig + { + DurableName = durableName ?? string.Empty, + AckPolicy = ackPolicy, + AckWaitMs = ackWaitMs, + MaxDeliver = maxDeliver, + Ephemeral = ephemeral, + Push = push, + HeartbeatMs = heartbeatMs, + DeliverPolicy = deliverPolicy, + OptStartSeq = optStartSeq, + }; + if (!string.IsNullOrWhiteSpace(filterSubject)) + config.FilterSubject = filterSubject; + if (filterSubjects is { Count: > 0 }) + config.FilterSubjects = [.. filterSubjects]; + + return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config)); + } + + public Task PublishAsync(string subject, string payload) + { + if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack)) + { + if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle)) + { + var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult(); + if (stored != null) + _consumerManager.OnPublished(ack.Stream, stored); + } + + return Task.FromResult(ack); + } + + throw new InvalidOperationException($"Publish to '{subject}' did not match a stream."); + } + + public Task FetchAsync(string stream, string durableName, int batch) + => _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask(); + + public Task FetchNoWaitAsync(string stream, string durableName, int batch) + => _consumerManager.FetchAsync(stream, durableName, new PullFetchRequest + { + Batch = batch, + NoWait = true, + }, _streamManager, default).AsTask(); + + public void AckAll(string stream, string durableName, ulong sequence) + => _consumerManager.AckAll(stream, durableName, sequence); + + public int GetPendingCount(string stream, string durableName) + => _consumerManager.GetPendingCount(stream, durableName); + + public Task GetConsumerInfoAsync(string stream, string durableName) + { + var resp = _consumerManager.GetInfo(stream, durableName); + if (resp.ConsumerInfo == null) + throw new InvalidOperationException("Consumer not found."); + return Task.FromResult(resp.ConsumerInfo); + } + + public Task RequestAsync(string subject, string payload) + => Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload))); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterFailoverTests.cs b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterFailoverTests.cs new file mode 100644 index 0000000..9d2309a --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterFailoverTests.cs @@ -0,0 +1,525 @@ +// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go +// Covers: stream leader stepdown, consumer leader stepdown, +// meta leader stepdown, peer removal, node loss recovery, +// snapshot catchup, consumer failover, data preservation. +using System.Reflection; +using System.Collections.Concurrent; +using System.Text; +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Cluster; +using NATS.Server.JetStream.Consumers; +using NATS.Server.JetStream.Models; +using NATS.Server.JetStream.Publish; + +namespace NATS.Server.Tests.JetStream.Cluster; + +/// +/// Tests covering JetStream cluster failover scenarios: leader stepdown, +/// peer removal, node loss/recovery, snapshot catchup, and consumer failover. +/// Ported from Go jetstream_cluster_1_test.go. +/// +public class JetStreamClusterFailoverTests +{ + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamLeaderStepDown server/jetstream_cluster_1_test.go:4925 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_leader_stepdown_elects_new_leader_and_preserves_data() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("STEPDOWN", ["sd.>"], replicas: 3); + + for (var i = 1; i <= 10; i++) + (await fx.PublishAsync($"sd.{i}", $"msg-{i}")).Seq.ShouldBe((ulong)i); + + var leaderBefore = fx.GetStreamLeaderId("STEPDOWN"); + leaderBefore.ShouldNotBeNullOrWhiteSpace(); + + var resp = await fx.StepDownStreamLeaderAsync("STEPDOWN"); + resp.Success.ShouldBeTrue(); + + var leaderAfter = fx.GetStreamLeaderId("STEPDOWN"); + leaderAfter.ShouldNotBe(leaderBefore); + + var state = await fx.GetStreamStateAsync("STEPDOWN"); + state.Messages.ShouldBe(10UL); + state.FirstSeq.ShouldBe(1UL); + state.LastSeq.ShouldBe(10UL); + + // New leader accepts writes + var ack = await fx.PublishAsync("sd.post", "after-stepdown"); + ack.Seq.ShouldBe(11UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterLeaderStepdown server/jetstream_cluster_1_test.go:5464 + // --------------------------------------------------------------- + + [Fact] + public async Task Meta_leader_stepdown_increments_version_and_preserves_streams() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("META_SD", ["meta.>"], replicas: 3); + + var before = fx.GetMetaState(); + before.ClusterSize.ShouldBe(3); + var leaderBefore = before.LeaderId; + var versionBefore = before.LeadershipVersion; + + var resp = await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}"); + resp.Success.ShouldBeTrue(); + + var after = fx.GetMetaState(); + after.LeaderId.ShouldNotBe(leaderBefore); + after.LeadershipVersion.ShouldBe(versionBefore + 1); + after.Streams.ShouldContain("META_SD"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterLeader server/jetstream_cluster_1_test.go:73 + // --------------------------------------------------------------- + + [Fact] + public async Task Consecutive_stepdowns_cycle_through_distinct_leaders() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("CYCLE", ["cyc.>"], replicas: 3); + + var leaders = new List { fx.GetStreamLeaderId("CYCLE") }; + + (await fx.StepDownStreamLeaderAsync("CYCLE")).Success.ShouldBeTrue(); + leaders.Add(fx.GetStreamLeaderId("CYCLE")); + + (await fx.StepDownStreamLeaderAsync("CYCLE")).Success.ShouldBeTrue(); + leaders.Add(fx.GetStreamLeaderId("CYCLE")); + + leaders[1].ShouldNotBe(leaders[0]); + leaders[2].ShouldNotBe(leaders[1]); + + var ack = await fx.PublishAsync("cyc.verify", "alive"); + ack.Stream.ShouldBe("CYCLE"); + ack.Seq.ShouldBeGreaterThan(0UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterPeerRemovalAPI server/jetstream_cluster_1_test.go:3469 + // --------------------------------------------------------------- + + [Fact] + public async Task Peer_removal_api_returns_success() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("PEERREM", ["pr.>"], replicas: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPeerRemove}PEERREM", """{"peer":"n2"}"""); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterPeerRemovalAndStreamReassignment server/jetstream_cluster_1_test.go:3544 + // --------------------------------------------------------------- + + [Fact] + public async Task Peer_removal_preserves_stream_data() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("REASSIGN", ["ra.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("ra.event", $"msg-{i}"); + + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamPeerRemove}REASSIGN", """{"peer":"n2"}""")).Success.ShouldBeTrue(); + + var state = await fx.GetStreamStateAsync("REASSIGN"); + state.Messages.ShouldBe(5UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConsumerLeaderStepdown (consumer stepdown) + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_leader_stepdown_api_returns_success() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("CLSD", ["clsd.>"], replicas: 3); + await fx.CreateConsumerAsync("CLSD", "dur1"); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerLeaderStepdown}CLSD.dur1", "{}"); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamNormalCatchup server/jetstream_cluster_1_test.go:1607 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_publishes_survive_leader_stepdown_and_catchup() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("CATCHUP", ["cu.>"], replicas: 3); + + // Publish some messages + for (var i = 0; i < 10; i++) + await fx.PublishAsync("cu.event", $"before-{i}"); + + // Step down the leader + (await fx.StepDownStreamLeaderAsync("CATCHUP")).Success.ShouldBeTrue(); + + // Publish more messages after stepdown + for (var i = 0; i < 10; i++) + await fx.PublishAsync("cu.event", $"after-{i}"); + + var state = await fx.GetStreamStateAsync("CATCHUP"); + state.Messages.ShouldBe(20UL); + state.LastSeq.ShouldBe(20UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamSnapshotCatchup server/jetstream_cluster_1_test.go:1667 + // --------------------------------------------------------------- + + [Fact] + public async Task Snapshot_and_restore_survives_leader_transition() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("SNAPCAT", ["sc.>"], replicas: 3); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("sc.event", $"msg-{i}"); + + // Take snapshot + var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}SNAPCAT", "{}"); + snapshot.Snapshot.ShouldNotBeNull(); + + // Step down leader + (await fx.StepDownStreamLeaderAsync("SNAPCAT")).Success.ShouldBeTrue(); + + // Purge and restore + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SNAPCAT", "{}")).Success.ShouldBeTrue(); + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}SNAPCAT", snapshot.Snapshot!.Payload)).Success.ShouldBeTrue(); + + var state = await fx.GetStreamStateAsync("SNAPCAT"); + state.Messages.ShouldBe(10UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamSnapshotCatchupWithPurge server/jetstream_cluster_1_test.go:1822 + // --------------------------------------------------------------- + + [Fact] + public async Task Snapshot_restore_after_purge_preserves_original_data() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("PURGECAT", ["pc.>"], replicas: 3); + + for (var i = 0; i < 20; i++) + await fx.PublishAsync("pc.event", $"msg-{i}"); + + var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}PURGECAT", "{}"); + + // Purge the stream + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGECAT", "{}")).Success.ShouldBeTrue(); + var afterPurge = await fx.GetStreamStateAsync("PURGECAT"); + afterPurge.Messages.ShouldBe(0UL); + + // Restore from snapshot + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}PURGECAT", snapshot.Snapshot!.Payload)).Success.ShouldBeTrue(); + var restored = await fx.GetStreamStateAsync("PURGECAT"); + restored.Messages.ShouldBe(20UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMetaSnapshotsAndCatchup server/jetstream_cluster_1_test.go:833 + // --------------------------------------------------------------- + + [Fact] + public async Task Meta_state_survives_multiple_stepdowns() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("META1", ["m1.>"], replicas: 3); + await fx.CreateStreamAsync("META2", ["m2.>"], replicas: 3); + + // Step down meta leader twice + (await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}")).Success.ShouldBeTrue(); + (await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}")).Success.ShouldBeTrue(); + + var state = fx.GetMetaState(); + state.Streams.ShouldContain("META1"); + state.Streams.ShouldContain("META2"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMetaSnapshotsMultiChange server/jetstream_cluster_1_test.go:881 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_delete_and_create_across_stepdowns_reflected_in_stream_names() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("MULTI1", ["mul1.>"], replicas: 3); + await fx.CreateStreamAsync("MULTI2", ["mul2.>"], replicas: 3); + + // Delete one stream + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}MULTI1", "{}")).Success.ShouldBeTrue(); + + // Step down meta leader + (await fx.RequestAsync(JetStreamApiSubjects.MetaLeaderStepdown, "{}")).Success.ShouldBeTrue(); + + // Create another stream + await fx.CreateStreamAsync("MULTI3", ["mul3.>"], replicas: 3); + + // Verify via stream names API (reflects actual active streams) + var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}"); + names.StreamNames.ShouldNotBeNull(); + names.StreamNames!.ShouldNotContain("MULTI1"); + names.StreamNames.ShouldContain("MULTI2"); + names.StreamNames.ShouldContain("MULTI3"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterDeleteMsgAndRestart server/jetstream_cluster_1_test.go:1785 + // --------------------------------------------------------------- + + [Fact] + public async Task Delete_message_survives_leader_stepdown() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("DELMSGSD", ["dms.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("dms.event", $"msg-{i}"); + + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}DELMSGSD", """{"seq":3}""")).Success.ShouldBeTrue(); + + (await fx.StepDownStreamLeaderAsync("DELMSGSD")).Success.ShouldBeTrue(); + + var state = await fx.GetStreamStateAsync("DELMSGSD"); + state.Messages.ShouldBe(4UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterRestoreSingleConsumer server/jetstream_cluster_1_test.go:1028 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_survives_stream_leader_stepdown() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("CSURV", ["csv.>"], replicas: 3); + await fx.CreateConsumerAsync("CSURV", "durable1", filterSubject: "csv.>"); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("csv.event", $"msg-{i}"); + + // Fetch before stepdown + var batch1 = await fx.FetchAsync("CSURV", "durable1", 5); + batch1.Messages.Count.ShouldBe(5); + + // Step down stream leader + (await fx.StepDownStreamLeaderAsync("CSURV")).Success.ShouldBeTrue(); + + // Consumer should still be fetchable + var batch2 = await fx.FetchAsync("CSURV", "durable1", 5); + batch2.Messages.Count.ShouldBe(5); + } + + // --------------------------------------------------------------- + // Additional: Multiple stepdowns do not lose accumulated state + // --------------------------------------------------------------- + + [Fact] + public async Task Multiple_stepdowns_preserve_accumulated_messages() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("ACCUM", ["acc.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("acc.event", $"batch1-{i}"); + + (await fx.StepDownStreamLeaderAsync("ACCUM")).Success.ShouldBeTrue(); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("acc.event", $"batch2-{i}"); + + (await fx.StepDownStreamLeaderAsync("ACCUM")).Success.ShouldBeTrue(); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("acc.event", $"batch3-{i}"); + + var state = await fx.GetStreamStateAsync("ACCUM"); + state.Messages.ShouldBe(15UL); + state.LastSeq.ShouldBe(15UL); + } + + // --------------------------------------------------------------- + // Additional: Stream info available after leader stepdown + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_info_available_after_leader_stepdown() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("INFOSD", ["isd.>"], replicas: 3); + + for (var i = 0; i < 3; i++) + await fx.PublishAsync("isd.event", $"msg-{i}"); + + (await fx.StepDownStreamLeaderAsync("INFOSD")).Success.ShouldBeTrue(); + + var info = await fx.GetStreamInfoAsync("INFOSD"); + info.StreamInfo.ShouldNotBeNull(); + info.StreamInfo!.Config.Name.ShouldBe("INFOSD"); + info.StreamInfo.State.Messages.ShouldBe(3UL); + } + + // --------------------------------------------------------------- + // Additional: Stepdown non-existent stream does not crash + // --------------------------------------------------------------- + + [Fact] + public async Task Stepdown_non_existent_stream_returns_success_gracefully() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + + // Stepping down a non-existent stream should not throw + var resp = await fx.StepDownStreamLeaderAsync("NONEXISTENT"); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Additional: AccountPurge returns success + // --------------------------------------------------------------- + + [Fact] + public async Task Account_purge_api_returns_success() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("PURGEACCT", ["pa.>"], replicas: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountPurge}GLOBAL", "{}"); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Additional: Server remove returns success + // --------------------------------------------------------------- + + [Fact] + public async Task Server_remove_api_returns_success() + { + await using var fx = await ClusterFailoverFixture.StartAsync(nodes: 3); + + var resp = await fx.RequestAsync(JetStreamApiSubjects.ServerRemove, "{}"); + resp.Success.ShouldBeTrue(); + } +} + +/// +/// Self-contained fixture for JetStream cluster failover tests. +/// +internal sealed class ClusterFailoverFixture : IAsyncDisposable +{ + private readonly JetStreamMetaGroup _metaGroup; + private readonly StreamManager _streamManager; + private readonly ConsumerManager _consumerManager; + private readonly JetStreamApiRouter _router; + private readonly JetStreamPublisher _publisher; + + private ClusterFailoverFixture( + JetStreamMetaGroup metaGroup, + StreamManager streamManager, + ConsumerManager consumerManager, + JetStreamApiRouter router, + JetStreamPublisher publisher) + { + _metaGroup = metaGroup; + _streamManager = streamManager; + _consumerManager = consumerManager; + _router = router; + _publisher = publisher; + } + + public static Task StartAsync(int nodes) + { + var meta = new JetStreamMetaGroup(nodes); + var consumerManager = new ConsumerManager(meta); + var streamManager = new StreamManager(meta, consumerManager: consumerManager); + var router = new JetStreamApiRouter(streamManager, consumerManager, meta); + var publisher = new JetStreamPublisher(streamManager); + return Task.FromResult(new ClusterFailoverFixture(meta, streamManager, consumerManager, router, publisher)); + } + + public Task CreateStreamAsync(string name, string[] subjects, int replicas) + { + var response = _streamManager.CreateOrUpdate(new StreamConfig + { + Name = name, + Subjects = [.. subjects], + Replicas = replicas, + }); + if (response.Error is not null) + throw new InvalidOperationException(response.Error.Description); + return Task.CompletedTask; + } + + public Task CreateConsumerAsync(string stream, string durableName, string? filterSubject = null) + { + var config = new ConsumerConfig { DurableName = durableName }; + if (!string.IsNullOrWhiteSpace(filterSubject)) + config.FilterSubject = filterSubject; + return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config)); + } + + public Task PublishAsync(string subject, string payload) + { + if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack)) + { + if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle)) + { + var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult(); + if (stored != null) + _consumerManager.OnPublished(ack.Stream, stored); + } + + return Task.FromResult(ack); + } + + throw new InvalidOperationException($"Publish to '{subject}' did not match a stream."); + } + + public Task StepDownStreamLeaderAsync(string stream) + => Task.FromResult(_router.Route( + $"{JetStreamApiSubjects.StreamLeaderStepdown}{stream}", + "{}"u8)); + + public string GetStreamLeaderId(string stream) + { + var field = typeof(StreamManager) + .GetField("_replicaGroups", BindingFlags.NonPublic | BindingFlags.Instance)!; + var groups = (ConcurrentDictionary)field.GetValue(_streamManager)!; + if (groups.TryGetValue(stream, out var group)) + return group.Leader.Id; + return string.Empty; + } + + public MetaGroupState GetMetaState() => _metaGroup.GetState(); + + public Task GetStreamStateAsync(string name) + => _streamManager.GetStateAsync(name, default).AsTask(); + + public Task GetStreamInfoAsync(string name) + => Task.FromResult(_streamManager.GetInfo(name)); + + public Task FetchAsync(string stream, string durableName, int batch) + => _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask(); + + public Task RequestAsync(string subject, string payload) + => Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload))); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterMetaTests.cs b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterMetaTests.cs new file mode 100644 index 0000000..d43d88a --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterMetaTests.cs @@ -0,0 +1,617 @@ +// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go +// Covers: cluster metadata operations, asset placement planner, +// replica group management, stream scaling, config validation, +// cluster expand, account info in cluster, max streams. +using System.Text; +using NATS.Server.Configuration; +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Cluster; +using NATS.Server.JetStream.Models; +using NATS.Server.JetStream.Publish; +using NATS.Server.JetStream.Validation; + +namespace NATS.Server.Tests.JetStream.Cluster; + +/// +/// Tests covering JetStream cluster metadata operations: asset placement, +/// replica group management, config validation, scaling, and account operations. +/// Ported from Go jetstream_cluster_1_test.go. +/// +public class JetStreamClusterMetaTests +{ + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConfig server/jetstream_cluster_1_test.go:43 + // --------------------------------------------------------------- + + [Fact] + public void Config_requires_server_name_for_jetstream_cluster() + { + var options = new NatsOptions + { + ServerName = null, + JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, + Cluster = new ClusterOptions { Port = 6222 }, + }; + var result = JetStreamConfigValidator.ValidateClusterConfig(options); + result.IsValid.ShouldBeFalse(); + result.Message.ShouldContain("server_name"); + } + + [Fact] + public void Config_requires_cluster_name_for_jetstream_cluster() + { + var options = new NatsOptions + { + ServerName = "S1", + JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, + Cluster = new ClusterOptions { Name = null, Port = 6222 }, + }; + var result = JetStreamConfigValidator.ValidateClusterConfig(options); + result.IsValid.ShouldBeFalse(); + result.Message.ShouldContain("cluster.name"); + } + + [Fact] + public void Config_valid_when_server_and_cluster_names_set() + { + var options = new NatsOptions + { + ServerName = "S1", + JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, + Cluster = new ClusterOptions { Name = "JSC", Port = 6222 }, + }; + var result = JetStreamConfigValidator.ValidateClusterConfig(options); + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void Config_skips_cluster_checks_when_no_cluster_configured() + { + var options = new NatsOptions + { + JetStream = new JetStreamOptions { StoreDir = "/tmp/js" }, + }; + var result = JetStreamConfigValidator.ValidateClusterConfig(options); + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public void Config_skips_cluster_checks_when_no_jetstream_configured() + { + var options = new NatsOptions + { + Cluster = new ClusterOptions { Port = 6222 }, + }; + var result = JetStreamConfigValidator.ValidateClusterConfig(options); + result.IsValid.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Placement planner tests + // --------------------------------------------------------------- + + [Fact] + public void Placement_planner_returns_requested_replica_count() + { + var planner = new AssetPlacementPlanner(nodes: 5); + var placement = planner.PlanReplicas(replicas: 3); + placement.Count.ShouldBe(3); + } + + [Fact] + public void Placement_planner_caps_at_cluster_size() + { + var planner = new AssetPlacementPlanner(nodes: 3); + var placement = planner.PlanReplicas(replicas: 5); + placement.Count.ShouldBe(3); + } + + [Fact] + public void Placement_planner_minimum_is_one_replica() + { + var planner = new AssetPlacementPlanner(nodes: 3); + var placement = planner.PlanReplicas(replicas: 0); + placement.Count.ShouldBe(1); + } + + [Fact] + public void Placement_planner_handles_single_node_cluster() + { + var planner = new AssetPlacementPlanner(nodes: 1); + var placement = planner.PlanReplicas(replicas: 3); + placement.Count.ShouldBe(1); + } + + // --------------------------------------------------------------- + // Meta group lifecycle tests + // --------------------------------------------------------------- + + [Fact] + public void Meta_group_initial_state_is_correct() + { + var meta = new JetStreamMetaGroup(3); + var state = meta.GetState(); + + state.ClusterSize.ShouldBe(3); + state.LeaderId.ShouldNotBeNullOrWhiteSpace(); + state.LeadershipVersion.ShouldBe(1); + state.Streams.Count.ShouldBe(0); + } + + [Fact] + public async Task Meta_group_tracks_stream_proposals() + { + var meta = new JetStreamMetaGroup(3); + + await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S1" }, default); + await meta.ProposeCreateStreamAsync(new StreamConfig { Name = "S2" }, default); + + var state = meta.GetState(); + state.Streams.Count.ShouldBe(2); + state.Streams.ShouldContain("S1"); + state.Streams.ShouldContain("S2"); + } + + [Fact] + public void Meta_group_stepdown_cycles_leader() + { + var meta = new JetStreamMetaGroup(3); + var leader1 = meta.GetState().LeaderId; + + meta.StepDown(); + var leader2 = meta.GetState().LeaderId; + leader2.ShouldNotBe(leader1); + + meta.StepDown(); + var leader3 = meta.GetState().LeaderId; + leader3.ShouldNotBe(leader2); + } + + [Fact] + public void Meta_group_stepdown_wraps_around() + { + var meta = new JetStreamMetaGroup(2); + var leaders = new HashSet(); + + for (var i = 0; i < 5; i++) + { + leaders.Add(meta.GetState().LeaderId); + meta.StepDown(); + } + + // Should cycle between 2 leaders + leaders.Count.ShouldBe(2); + } + + [Fact] + public void Meta_group_leadership_version_increments() + { + var meta = new JetStreamMetaGroup(3); + meta.GetState().LeadershipVersion.ShouldBe(1); + + meta.StepDown(); + meta.GetState().LeadershipVersion.ShouldBe(2); + + meta.StepDown(); + meta.GetState().LeadershipVersion.ShouldBe(3); + } + + // --------------------------------------------------------------- + // Replica group tests + // --------------------------------------------------------------- + + [Fact] + public void Replica_group_creates_correct_node_count() + { + var group = new StreamReplicaGroup("TEST", replicas: 3); + group.Nodes.Count.ShouldBe(3); + group.StreamName.ShouldBe("TEST"); + } + + [Fact] + public void Replica_group_elects_initial_leader() + { + var group = new StreamReplicaGroup("TEST", replicas: 3); + group.Leader.ShouldNotBeNull(); + group.Leader.IsLeader.ShouldBeTrue(); + } + + [Fact] + public async Task Replica_group_stepdown_changes_leader() + { + var group = new StreamReplicaGroup("TEST", replicas: 3); + var leaderBefore = group.Leader.Id; + + await group.StepDownAsync(default); + var leaderAfter = group.Leader.Id; + + leaderAfter.ShouldNotBe(leaderBefore); + group.Leader.IsLeader.ShouldBeTrue(); + } + + [Fact] + public async Task Replica_group_leader_accepts_proposals() + { + var group = new StreamReplicaGroup("TEST", replicas: 3); + + var index = await group.ProposeAsync("PUB test.1", default); + index.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task Replica_group_apply_placement_scales_up() + { + var group = new StreamReplicaGroup("TEST", replicas: 1); + group.Nodes.Count.ShouldBe(1); + + await group.ApplyPlacementAsync([1, 2, 3], default); + group.Nodes.Count.ShouldBe(3); + } + + [Fact] + public async Task Replica_group_apply_placement_scales_down() + { + var group = new StreamReplicaGroup("TEST", replicas: 5); + group.Nodes.Count.ShouldBe(5); + + await group.ApplyPlacementAsync([1, 2], default); + group.Nodes.Count.ShouldBe(2); + } + + [Fact] + public async Task Replica_group_apply_same_size_is_noop() + { + var group = new StreamReplicaGroup("TEST", replicas: 3); + var leaderBefore = group.Leader.Id; + + await group.ApplyPlacementAsync([1, 2, 3], default); + group.Nodes.Count.ShouldBe(3); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterAccountInfo server/jetstream_cluster_1_test.go:94 + // --------------------------------------------------------------- + + [Fact] + public async Task Account_info_tracks_streams_and_consumers_in_cluster() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("ACCT1", ["a1.>"], replicas: 3); + await fx.CreateStreamAsync("ACCT2", ["a2.>"], replicas: 3); + await fx.CreateConsumerAsync("ACCT1", "c1"); + await fx.CreateConsumerAsync("ACCT1", "c2"); + + var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}"); + resp.AccountInfo.ShouldNotBeNull(); + resp.AccountInfo!.Streams.ShouldBe(2); + resp.AccountInfo.Consumers.ShouldBe(2); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterExtendedAccountInfo server/jetstream_cluster_1_test.go:3389 + // --------------------------------------------------------------- + + [Fact] + public async Task Account_info_after_stream_delete_reflects_removal() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("DEL1", ["d1.>"], replicas: 3); + await fx.CreateStreamAsync("DEL2", ["d2.>"], replicas: 3); + + (await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}DEL1", "{}")).Success.ShouldBeTrue(); + + var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}"); + resp.AccountInfo!.Streams.ShouldBe(1); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterAccountPurge server/jetstream_cluster_1_test.go:3891 + // --------------------------------------------------------------- + + [Fact] + public async Task Account_purge_returns_success() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("PURGE1", ["pur.>"], replicas: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountPurge}GLOBAL", "{}"); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamLimitWithAccountDefaults server/jetstream_cluster_1_test.go:124 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_with_max_bytes_and_replicas_created_successfully() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "MBLIMIT", + Subjects = ["mbl.>"], + Replicas = 2, + MaxBytes = 4 * 1024 * 1024, + }; + var resp = fx.CreateStreamDirect(cfg); + resp.Error.ShouldBeNull(); + resp.StreamInfo!.Config.MaxBytes.ShouldBe(4 * 1024 * 1024); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMaxStreamsReached server/jetstream_cluster_1_test.go:3177 + // --------------------------------------------------------------- + + [Fact] + public async Task Multiple_streams_tracked_correctly_in_meta() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + for (var i = 0; i < 10; i++) + await fx.CreateStreamAsync($"MS{i}", [$"ms{i}.>"], replicas: 3); + + var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}"); + names.StreamNames!.Count.ShouldBe(10); + + var meta = fx.GetMetaState(); + meta.Streams.Count.ShouldBe(10); + } + + // --------------------------------------------------------------- + // Direct API tests (DirectGet) + // --------------------------------------------------------------- + + [Fact] + public async Task Direct_get_returns_message_by_sequence() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "DIRECT", + Subjects = ["dir.>"], + Replicas = 3, + AllowDirect = true, + }; + fx.CreateStreamDirect(cfg); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("dir.event", $"msg-{i}"); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.DirectGet}DIRECT", """{"seq":3}"""); + resp.DirectMessage.ShouldNotBeNull(); + resp.DirectMessage!.Sequence.ShouldBe(3UL); + resp.DirectMessage.Subject.ShouldBe("dir.event"); + } + + // --------------------------------------------------------------- + // Stream message get + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_message_get_returns_correct_payload() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("MSGGET", ["mg.>"], replicas: 3); + + await fx.PublishAsync("mg.event", "payload-1"); + await fx.PublishAsync("mg.event", "payload-2"); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageGet}MSGGET", """{"seq":2}"""); + resp.StreamMessage.ShouldNotBeNull(); + resp.StreamMessage!.Sequence.ShouldBe(2UL); + resp.StreamMessage.Payload.ShouldBe("payload-2"); + } + + // --------------------------------------------------------------- + // Consumer list and names + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_list_via_api_router() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("CLISTM", ["clm.>"], replicas: 3); + await fx.CreateConsumerAsync("CLISTM", "d1"); + await fx.CreateConsumerAsync("CLISTM", "d2"); + + var names = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CLISTM", "{}"); + names.ConsumerNames.ShouldNotBeNull(); + names.ConsumerNames!.Count.ShouldBe(2); + + var list = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerList}CLISTM", "{}"); + list.ConsumerNames.ShouldNotBeNull(); + list.ConsumerNames!.Count.ShouldBe(2); + } + + // --------------------------------------------------------------- + // Account stream move returns success shape + // --------------------------------------------------------------- + + [Fact] + public async Task Account_stream_move_api_returns_success() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountStreamMove}TEST", "{}"); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Account stream move cancel returns success shape + // --------------------------------------------------------------- + + [Fact] + public async Task Account_stream_move_cancel_api_returns_success() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.AccountStreamMoveCancel}TEST", "{}"); + resp.Success.ShouldBeTrue(); + } + + // --------------------------------------------------------------- + // Stream create requires name + // --------------------------------------------------------------- + + [Fact] + public void Stream_create_without_name_returns_error() + { + var streamManager = new StreamManager(); + var resp = streamManager.CreateOrUpdate(new StreamConfig { Name = "" }); + resp.Error.ShouldNotBeNull(); + resp.Error!.Description.ShouldContain("name"); + } + + // --------------------------------------------------------------- + // NotFound for unknown API subject + // --------------------------------------------------------------- + + [Fact] + public async Task Unknown_api_subject_returns_not_found() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + var resp = await fx.RequestAsync("$JS.API.UNKNOWN.SUBJECT", "{}"); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(404); + } + + // --------------------------------------------------------------- + // Stream info for non-existent stream returns 404 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_info_nonexistent_returns_not_found() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamInfo}NOSTREAM", "{}"); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(404); + } + + // --------------------------------------------------------------- + // Consumer info for non-existent consumer returns 404 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_info_nonexistent_returns_not_found() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("NOCONS", ["nc.>"], replicas: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerInfo}NOCONS.MISSING", "{}"); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(404); + } + + // --------------------------------------------------------------- + // Delete non-existent stream returns 404 + // --------------------------------------------------------------- + + [Fact] + public async Task Delete_nonexistent_stream_returns_not_found() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}GONE", "{}"); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(404); + } + + // --------------------------------------------------------------- + // Delete non-existent consumer returns 404 + // --------------------------------------------------------------- + + [Fact] + public async Task Delete_nonexistent_consumer_returns_not_found() + { + await using var fx = await ClusterMetaFixture.StartAsync(nodes: 3); + await fx.CreateStreamAsync("NODEL", ["nd.>"], replicas: 3); + + var resp = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}NODEL.MISSING", "{}"); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(404); + } +} + +/// +/// Self-contained fixture for JetStream cluster meta tests. +/// +internal sealed class ClusterMetaFixture : IAsyncDisposable +{ + private readonly JetStreamMetaGroup _metaGroup; + private readonly StreamManager _streamManager; + private readonly ConsumerManager _consumerManager; + private readonly JetStreamApiRouter _router; + private readonly JetStreamPublisher _publisher; + + private ClusterMetaFixture( + JetStreamMetaGroup metaGroup, + StreamManager streamManager, + ConsumerManager consumerManager, + JetStreamApiRouter router, + JetStreamPublisher publisher) + { + _metaGroup = metaGroup; + _streamManager = streamManager; + _consumerManager = consumerManager; + _router = router; + _publisher = publisher; + } + + public static Task StartAsync(int nodes) + { + var meta = new JetStreamMetaGroup(nodes); + var consumerManager = new ConsumerManager(meta); + var streamManager = new StreamManager(meta, consumerManager: consumerManager); + var router = new JetStreamApiRouter(streamManager, consumerManager, meta); + var publisher = new JetStreamPublisher(streamManager); + return Task.FromResult(new ClusterMetaFixture(meta, streamManager, consumerManager, router, publisher)); + } + + public Task CreateStreamAsync(string name, string[] subjects, int replicas) + { + var response = _streamManager.CreateOrUpdate(new StreamConfig + { + Name = name, + Subjects = [.. subjects], + Replicas = replicas, + }); + if (response.Error is not null) + throw new InvalidOperationException(response.Error.Description); + return Task.CompletedTask; + } + + public JetStreamApiResponse CreateStreamDirect(StreamConfig config) + => _streamManager.CreateOrUpdate(config); + + public Task CreateConsumerAsync(string stream, string durableName) + { + return Task.FromResult(_consumerManager.CreateOrUpdate(stream, new ConsumerConfig + { + DurableName = durableName, + })); + } + + public Task PublishAsync(string subject, string payload) + { + if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack)) + return Task.FromResult(ack); + throw new InvalidOperationException($"Publish to '{subject}' did not match a stream."); + } + + public MetaGroupState GetMetaState() => _metaGroup.GetState(); + + public Task RequestAsync(string subject, string payload) + => Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload))); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterStreamTests.cs b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterStreamTests.cs new file mode 100644 index 0000000..cb8d2f9 --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/Cluster/JetStreamClusterStreamTests.cs @@ -0,0 +1,872 @@ +// Go parity: golang/nats-server/server/jetstream_cluster_1_test.go +// Covers: cluster stream creation, single/multi replica, memory store, +// stream purge, update subjects, delete, max bytes, stream info/list, +// interest retention, work queue retention, mirror/source in cluster. +using System.Text; +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Cluster; +using NATS.Server.JetStream.Consumers; +using NATS.Server.JetStream.Models; +using NATS.Server.JetStream.Publish; + +namespace NATS.Server.Tests.JetStream.Cluster; + +/// +/// Tests covering clustered JetStream stream creation, replication, storage, +/// purge, update, delete, retention policies, and mirror/source in cluster mode. +/// Ported from Go jetstream_cluster_1_test.go. +/// +public class JetStreamClusterStreamTests +{ + // --------------------------------------------------------------- + // Go: TestJetStreamClusterSingleReplicaStreams server/jetstream_cluster_1_test.go:223 + // --------------------------------------------------------------- + + [Fact] + public async Task Single_replica_stream_creation_and_publish_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var resp = await fx.CreateStreamAsync("R1S", ["foo", "bar"], replicas: 1); + resp.Error.ShouldBeNull(); + resp.StreamInfo.ShouldNotBeNull(); + resp.StreamInfo!.Config.Name.ShouldBe("R1S"); + + const int toSend = 10; + for (var i = 0; i < toSend; i++) + { + var ack = await fx.PublishAsync("foo", $"Hello R1 {i}"); + ack.Stream.ShouldBe("R1S"); + ack.Seq.ShouldBe((ulong)(i + 1)); + } + + var info = await fx.GetStreamInfoAsync("R1S"); + info.StreamInfo!.State.Messages.ShouldBe((ulong)toSend); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMultiReplicaStreamsDefaultFileMem server/jetstream_cluster_1_test.go:355 + // --------------------------------------------------------------- + + [Fact] + public async Task Multi_replica_stream_defaults_to_memory_store() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var resp = await fx.CreateStreamAsync("MEMTEST", ["mem.>"], replicas: 3); + resp.Error.ShouldBeNull(); + resp.StreamInfo!.Config.Storage.ShouldBe(StorageType.Memory); + + var backend = fx.GetStoreBackendType("MEMTEST"); + backend.ShouldBe("memory"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMemoryStore server/jetstream_cluster_1_test.go:423 + // --------------------------------------------------------------- + + [Fact] + public async Task Memory_store_replicated_stream_accepts_100_messages() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var resp = await fx.CreateStreamAsync("R3M", ["foo", "bar"], replicas: 3, storage: StorageType.Memory); + resp.Error.ShouldBeNull(); + + const int toSend = 100; + for (var i = 0; i < toSend; i++) + { + var ack = await fx.PublishAsync("foo", "Hello MemoryStore"); + ack.Stream.ShouldBe("R3M"); + } + + var info = await fx.GetStreamInfoAsync("R3M"); + info.StreamInfo!.Config.Name.ShouldBe("R3M"); + info.StreamInfo.State.Messages.ShouldBe((ulong)toSend); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterDelete server/jetstream_cluster_1_test.go:472 + // --------------------------------------------------------------- + + [Fact] + public async Task Delete_consumer_then_stream_clears_account_info() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("C22", ["foo", "bar", "baz"], replicas: 2); + await fx.CreateConsumerAsync("C22", "dlc"); + + // Delete consumer then stream + var delConsumer = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerDelete}C22.dlc", "{}"); + delConsumer.Success.ShouldBeTrue(); + + var delStream = await fx.RequestAsync($"{JetStreamApiSubjects.StreamDelete}C22", "{}"); + delStream.Success.ShouldBeTrue(); + + // Account info should show zero streams + var accountInfo = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}"); + accountInfo.AccountInfo.ShouldNotBeNull(); + accountInfo.AccountInfo!.Streams.ShouldBe(0); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamPurge server/jetstream_cluster_1_test.go:522 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_purge_clears_all_messages_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 5); + + await fx.CreateStreamAsync("PURGE", ["foo", "bar"], replicas: 3); + + const int toSend = 100; + for (var i = 0; i < toSend; i++) + await fx.PublishAsync("foo", "Hello JS Clustering"); + + var before = await fx.GetStreamInfoAsync("PURGE"); + before.StreamInfo!.State.Messages.ShouldBe((ulong)toSend); + + var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}PURGE", "{}"); + purge.Success.ShouldBeTrue(); + + var after = await fx.GetStreamInfoAsync("PURGE"); + after.StreamInfo!.State.Messages.ShouldBe(0UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamUpdateSubjects server/jetstream_cluster_1_test.go:571 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_update_subjects_reflects_new_configuration() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("SUBUPDATE", ["foo", "bar"], replicas: 3); + + // Update subjects to bar, baz + var update = fx.UpdateStream("SUBUPDATE", ["bar", "baz"], replicas: 3); + update.Error.ShouldBeNull(); + update.StreamInfo.ShouldNotBeNull(); + update.StreamInfo!.Config.Subjects.ShouldContain("bar"); + update.StreamInfo.Config.Subjects.ShouldContain("baz"); + update.StreamInfo.Config.Subjects.ShouldNotContain("foo"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamInfoList server/jetstream_cluster_1_test.go:1284 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_names_and_list_return_all_streams() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("S1", ["s1.>"], replicas: 3); + await fx.CreateStreamAsync("S2", ["s2.>"], replicas: 3); + await fx.CreateStreamAsync("S3", ["s3.>"], replicas: 1); + + var names = await fx.RequestAsync(JetStreamApiSubjects.StreamNames, "{}"); + names.StreamNames.ShouldNotBeNull(); + names.StreamNames!.Count.ShouldBe(3); + names.StreamNames.ShouldContain("S1"); + names.StreamNames.ShouldContain("S2"); + names.StreamNames.ShouldContain("S3"); + + var list = await fx.RequestAsync(JetStreamApiSubjects.StreamList, "{}"); + list.StreamNames.ShouldNotBeNull(); + list.StreamNames!.Count.ShouldBe(3); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMaxBytesForStream server/jetstream_cluster_1_test.go:1099 + // --------------------------------------------------------------- + + [Fact] + public async Task Max_bytes_stream_limits_enforced_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "MAXBYTES", + Subjects = ["mb.>"], + Replicas = 3, + MaxBytes = 512, + Discard = DiscardPolicy.Old, + }; + var resp = fx.CreateStreamDirect(cfg); + resp.Error.ShouldBeNull(); + + // Publish messages exceeding max bytes; old messages should be discarded + for (var i = 0; i < 20; i++) + await fx.PublishAsync("mb.data", new string('X', 64)); + + var state = await fx.GetStreamStateAsync("MAXBYTES"); + // Total bytes should not exceed max_bytes by much after enforcement + ((long)state.Bytes).ShouldBeLessThanOrEqualTo(cfg.MaxBytes + 128); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamPublishWithActiveConsumers server/jetstream_cluster_1_test.go:1132 + // --------------------------------------------------------------- + + [Fact] + public async Task Publish_with_active_consumer_delivers_messages() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("ACTIVE", ["active.>"], replicas: 3); + await fx.CreateConsumerAsync("ACTIVE", "durable1", filterSubject: "active.>"); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("active.event", $"msg-{i}"); + + var batch = await fx.FetchAsync("ACTIVE", "durable1", 10); + batch.Messages.Count.ShouldBe(10); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterDoubleAdd server/jetstream_cluster_1_test.go:1551 + // --------------------------------------------------------------- + + [Fact] + public async Task Double_add_stream_with_same_config_succeeds() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var first = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3); + first.Error.ShouldBeNull(); + + // Adding the same stream again should succeed (idempotent) + var second = await fx.CreateStreamAsync("DUP", ["dup.>"], replicas: 3); + second.Error.ShouldBeNull(); + second.StreamInfo!.Config.Name.ShouldBe("DUP"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamOverlapSubjects server/jetstream_cluster_1_test.go:1248 + // --------------------------------------------------------------- + + [Fact] + public async Task Publish_routes_to_correct_stream_among_non_overlapping() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("ALPHA", ["alpha.>"], replicas: 3); + await fx.CreateStreamAsync("BETA", ["beta.>"], replicas: 3); + + var ack1 = await fx.PublishAsync("alpha.one", "A"); + ack1.Stream.ShouldBe("ALPHA"); + + var ack2 = await fx.PublishAsync("beta.one", "B"); + ack2.Stream.ShouldBe("BETA"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterInterestRetention server/jetstream_cluster_1_test.go:2109 + // --------------------------------------------------------------- + + [Fact] + public async Task Interest_retention_stream_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "INTEREST", + Subjects = ["interest.>"], + Replicas = 3, + Retention = RetentionPolicy.Interest, + }; + fx.CreateStreamDirect(cfg); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("interest.event", "msg"); + + var state = await fx.GetStreamStateAsync("INTEREST"); + state.Messages.ShouldBe(5UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterWorkQueueRetention server/jetstream_cluster_1_test.go:2179 + // --------------------------------------------------------------- + + [Fact] + public async Task Work_queue_retention_removes_acked_messages_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "WQ", + Subjects = ["wq.>"], + Replicas = 2, + Retention = RetentionPolicy.WorkQueue, + MaxConsumers = 1, + }; + fx.CreateStreamDirect(cfg); + await fx.CreateConsumerAsync("WQ", "worker", filterSubject: "wq.>", ackPolicy: AckPolicy.All); + + await fx.PublishAsync("wq.task", "job-1"); + + var stateBefore = await fx.GetStreamStateAsync("WQ"); + stateBefore.Messages.ShouldBe(1UL); + + // Ack all up to sequence 1, triggering work queue cleanup + fx.AckAll("WQ", "worker", 1); + + // Publish again to trigger runtime retention enforcement + await fx.PublishAsync("wq.task", "job-2"); + + var stateAfter = await fx.GetStreamStateAsync("WQ"); + // After ack, only the new message should remain + stateAfter.Messages.ShouldBe(1UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterDeleteMsg server/jetstream_cluster_1_test.go:1748 + // --------------------------------------------------------------- + + [Fact] + public async Task Delete_individual_message_in_clustered_stream() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("DELMSG", ["dm.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("dm.event", $"msg-{i}"); + + var before = await fx.GetStreamStateAsync("DELMSG"); + before.Messages.ShouldBe(5UL); + + // Delete message at sequence 3 + var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}DELMSG", """{"seq":3}"""); + del.Success.ShouldBeTrue(); + + var after = await fx.GetStreamStateAsync("DELMSG"); + after.Messages.ShouldBe(4UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamUpdate server/jetstream_cluster_1_test.go:1433 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_update_preserves_existing_messages() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("UPD", ["upd.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("upd.event", $"msg-{i}"); + + // Update max_msgs + var update = fx.UpdateStream("UPD", ["upd.>"], replicas: 3, maxMsgs: 10); + update.Error.ShouldBeNull(); + + var state = await fx.GetStreamStateAsync("UPD"); + state.Messages.ShouldBe(5UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterAccountInfo server/jetstream_cluster_1_test.go:94 + // --------------------------------------------------------------- + + [Fact] + public async Task Account_info_reports_stream_and_consumer_counts() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("AI1", ["ai1.>"], replicas: 3); + await fx.CreateStreamAsync("AI2", ["ai2.>"], replicas: 3); + await fx.CreateConsumerAsync("AI1", "c1"); + + var resp = await fx.RequestAsync(JetStreamApiSubjects.Info, "{}"); + resp.AccountInfo.ShouldNotBeNull(); + resp.AccountInfo!.Streams.ShouldBe(2); + resp.AccountInfo.Consumers.ShouldBe(1); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterExpand server/jetstream_cluster_1_test.go:86 + // --------------------------------------------------------------- + + [Fact] + public void Cluster_expand_adds_peer_to_meta_group() + { + var meta = new JetStreamMetaGroup(2); + var state = meta.GetState(); + state.ClusterSize.ShouldBe(2); + + // Expanding is modeled by creating a new meta group with more nodes + var expanded = new JetStreamMetaGroup(3); + expanded.GetState().ClusterSize.ShouldBe(3); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMirrorAndSourceWorkQueues server/jetstream_cluster_1_test.go:2233 + // --------------------------------------------------------------- + + [Fact] + public async Task Mirror_stream_replicates_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + // Create origin stream + await fx.CreateStreamAsync("ORIGIN", ["origin.>"], replicas: 3); + + // Create mirror stream + fx.CreateStreamDirect(new StreamConfig + { + Name = "MIRROR", + Subjects = ["mirror.>"], + Replicas = 3, + Mirror = "ORIGIN", + }); + + // Publish to origin + for (var i = 0; i < 5; i++) + await fx.PublishAsync("origin.event", $"mirrored-{i}"); + + // Mirror should have replicated messages + var mirrorState = await fx.GetStreamStateAsync("MIRROR"); + mirrorState.Messages.ShouldBe(5UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMirrorAndSourceInterestPolicyStream server/jetstream_cluster_1_test.go:2290 + // --------------------------------------------------------------- + + [Fact] + public async Task Source_stream_replicates_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + // Create source origin + await fx.CreateStreamAsync("SRC", ["src.>"], replicas: 3); + + // Create aggregate stream sourcing from SRC + fx.CreateStreamDirect(new StreamConfig + { + Name = "AGG", + Subjects = ["agg.>"], + Replicas = 3, + Sources = [new StreamSourceConfig { Name = "SRC" }], + }); + + // Publish to source + for (var i = 0; i < 3; i++) + await fx.PublishAsync("src.event", $"sourced-{i}"); + + var aggState = await fx.GetStreamStateAsync("AGG"); + aggState.Messages.ShouldBe(3UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterUserSnapshotAndRestore server/jetstream_cluster_1_test.go:2652 + // --------------------------------------------------------------- + + [Fact] + public async Task Snapshot_and_restore_preserves_messages_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("SNAP", ["snap.>"], replicas: 3); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("snap.event", $"msg-{i}"); + + // Create snapshot + var snapshot = await fx.RequestAsync($"{JetStreamApiSubjects.StreamSnapshot}SNAP", "{}"); + snapshot.Snapshot.ShouldNotBeNull(); + snapshot.Snapshot!.Payload.ShouldNotBeNullOrEmpty(); + + // Purge the stream + await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SNAP", "{}"); + var afterPurge = await fx.GetStreamStateAsync("SNAP"); + afterPurge.Messages.ShouldBe(0UL); + + // Restore from snapshot + var restore = await fx.RequestAsync($"{JetStreamApiSubjects.StreamRestore}SNAP", snapshot.Snapshot.Payload); + restore.Success.ShouldBeTrue(); + + var afterRestore = await fx.GetStreamStateAsync("SNAP"); + afterRestore.Messages.ShouldBe(10UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamSynchedTimeStamps server/jetstream_cluster_1_test.go:977 + // --------------------------------------------------------------- + + [Fact] + public async Task Replicated_stream_messages_have_monotonic_sequences() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("SEQ", ["seq.>"], replicas: 3); + + var sequences = new List(); + for (var i = 0; i < 20; i++) + { + var ack = await fx.PublishAsync("seq.event", $"msg-{i}"); + sequences.Add(ack.Seq); + } + + // Verify strictly monotonically increasing sequences + for (var i = 1; i < sequences.Count; i++) + sequences[i].ShouldBeGreaterThan(sequences[i - 1]); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamLimits server/jetstream_cluster_1_test.go:3248 + // --------------------------------------------------------------- + + [Fact] + public async Task Max_msgs_limit_enforced_in_clustered_stream() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "LIMITED", + Subjects = ["limited.>"], + Replicas = 3, + MaxMsgs = 5, + }; + fx.CreateStreamDirect(cfg); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("limited.event", $"msg-{i}"); + + var state = await fx.GetStreamStateAsync("LIMITED"); + state.Messages.ShouldBeLessThanOrEqualTo(5UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamInterestOnlyPolicy server/jetstream_cluster_1_test.go:3310 + // --------------------------------------------------------------- + + [Fact] + public async Task Interest_only_policy_stream_stores_messages_without_consumers() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "INTONLY", + Subjects = ["intonly.>"], + Replicas = 3, + Retention = RetentionPolicy.Interest, + }; + fx.CreateStreamDirect(cfg); + + for (var i = 0; i < 3; i++) + await fx.PublishAsync("intonly.data", $"msg-{i}"); + + // Without consumers, interest retention still stores messages + // (they are removed only when all consumers have acked) + var state = await fx.GetStreamStateAsync("INTONLY"); + state.Messages.ShouldBe(3UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterConsumerInfoList server/jetstream_cluster_1_test.go:1349 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_names_and_list_return_all_consumers() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("CLIST", ["clist.>"], replicas: 3); + await fx.CreateConsumerAsync("CLIST", "c1"); + await fx.CreateConsumerAsync("CLIST", "c2"); + await fx.CreateConsumerAsync("CLIST", "c3"); + + var names = await fx.RequestAsync($"{JetStreamApiSubjects.ConsumerNames}CLIST", "{}"); + names.ConsumerNames.ShouldNotBeNull(); + names.ConsumerNames!.Count.ShouldBe(3); + names.ConsumerNames.ShouldContain("c1"); + names.ConsumerNames.ShouldContain("c2"); + names.ConsumerNames.ShouldContain("c3"); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterDefaultMaxAckPending server/jetstream_cluster_1_test.go:1580 + // --------------------------------------------------------------- + + [Fact] + public async Task Consumer_default_ack_policy_is_none() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("ACKDEF", ["ackdef.>"], replicas: 3); + var resp = await fx.CreateConsumerAsync("ACKDEF", "test_consumer"); + + resp.ConsumerInfo.ShouldNotBeNull(); + resp.ConsumerInfo!.Config.AckPolicy.ShouldBe(AckPolicy.None); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterExtendedStreamInfo server/jetstream_cluster_1_test.go:1878 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_info_returns_config_and_state() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("EXTINFO", ["ext.>"], replicas: 3); + + for (var i = 0; i < 5; i++) + await fx.PublishAsync("ext.event", $"msg-{i}"); + + var info = await fx.GetStreamInfoAsync("EXTINFO"); + info.StreamInfo.ShouldNotBeNull(); + info.StreamInfo!.Config.Name.ShouldBe("EXTINFO"); + info.StreamInfo.Config.Replicas.ShouldBe(3); + info.StreamInfo.State.Messages.ShouldBe(5UL); + info.StreamInfo.State.FirstSeq.ShouldBe(1UL); + info.StreamInfo.State.LastSeq.ShouldBe(5UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterExtendedStreamInfoSingleReplica server/jetstream_cluster_1_test.go:2033 + // --------------------------------------------------------------- + + [Fact] + public async Task Single_replica_stream_info_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + await fx.CreateStreamAsync("R1INFO", ["r1info.>"], replicas: 1); + + for (var i = 0; i < 3; i++) + await fx.PublishAsync("r1info.event", $"msg-{i}"); + + var info = await fx.GetStreamInfoAsync("R1INFO"); + info.StreamInfo!.Config.Replicas.ShouldBe(1); + info.StreamInfo.State.Messages.ShouldBe(3UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterMultiReplicaStreams (maxmsgs_per behavior) + // --------------------------------------------------------------- + + [Fact] + public async Task Max_msgs_per_subject_enforced_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "PERSUBJ", + Subjects = ["ps.>"], + Replicas = 3, + MaxMsgsPer = 2, + }; + fx.CreateStreamDirect(cfg); + + // Publish 5 messages to same subject; only 2 should remain + for (var i = 0; i < 5; i++) + await fx.PublishAsync("ps.topic", $"msg-{i}"); + + var state = await fx.GetStreamStateAsync("PERSUBJ"); + state.Messages.ShouldBeLessThanOrEqualTo(2UL); + } + + // --------------------------------------------------------------- + // Go: TestJetStreamClusterStreamExtendedUpdates server/jetstream_cluster_1_test.go:1513 + // --------------------------------------------------------------- + + [Fact] + public async Task Stream_update_can_change_max_msgs() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "EXTUPD", + Subjects = ["eu.>"], + Replicas = 3, + }; + fx.CreateStreamDirect(cfg); + + for (var i = 0; i < 10; i++) + await fx.PublishAsync("eu.event", $"msg-{i}"); + + // Update to limit max_msgs + var update = fx.UpdateStream("EXTUPD", ["eu.>"], replicas: 3, maxMsgs: 5); + update.Error.ShouldBeNull(); + update.StreamInfo!.Config.MaxMsgs.ShouldBe(5); + } + + // --------------------------------------------------------------- + // Additional: Sealed stream rejects purge + // --------------------------------------------------------------- + + [Fact] + public async Task Sealed_stream_rejects_purge_in_cluster() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "SEALED", + Subjects = ["sealed.>"], + Replicas = 3, + Sealed = true, + }; + fx.CreateStreamDirect(cfg); + + var purge = await fx.RequestAsync($"{JetStreamApiSubjects.StreamPurge}SEALED", "{}"); + // Sealed streams should not allow purge + purge.Success.ShouldBeFalse(); + } + + // --------------------------------------------------------------- + // Additional: DenyDelete stream rejects message delete + // --------------------------------------------------------------- + + [Fact] + public async Task DenyDelete_stream_rejects_message_delete() + { + await using var fx = await ClusterStreamFixture.StartAsync(nodes: 3); + + var cfg = new StreamConfig + { + Name = "NODELDENY", + Subjects = ["nodel.>"], + Replicas = 3, + DenyDelete = true, + }; + fx.CreateStreamDirect(cfg); + + await fx.PublishAsync("nodel.event", "msg"); + + var del = await fx.RequestAsync($"{JetStreamApiSubjects.StreamMessageDelete}NODELDENY", """{"seq":1}"""); + del.Success.ShouldBeFalse(); + } +} + +/// +/// Self-contained fixture for JetStream cluster stream tests. Wires up +/// meta group, stream manager, consumer manager, API router, and publisher. +/// +internal sealed class ClusterStreamFixture : IAsyncDisposable +{ + private readonly JetStreamMetaGroup _metaGroup; + private readonly StreamManager _streamManager; + private readonly ConsumerManager _consumerManager; + private readonly JetStreamApiRouter _router; + private readonly JetStreamPublisher _publisher; + + private ClusterStreamFixture( + JetStreamMetaGroup metaGroup, + StreamManager streamManager, + ConsumerManager consumerManager, + JetStreamApiRouter router, + JetStreamPublisher publisher) + { + _metaGroup = metaGroup; + _streamManager = streamManager; + _consumerManager = consumerManager; + _router = router; + _publisher = publisher; + } + + public static Task StartAsync(int nodes) + { + var meta = new JetStreamMetaGroup(nodes); + var consumerManager = new ConsumerManager(meta); + var streamManager = new StreamManager(meta, consumerManager: consumerManager); + var router = new JetStreamApiRouter(streamManager, consumerManager, meta); + var publisher = new JetStreamPublisher(streamManager); + return Task.FromResult(new ClusterStreamFixture(meta, streamManager, consumerManager, router, publisher)); + } + + public Task CreateStreamAsync(string name, string[] subjects, int replicas, StorageType storage = StorageType.Memory) + { + var response = _streamManager.CreateOrUpdate(new StreamConfig + { + Name = name, + Subjects = [.. subjects], + Replicas = replicas, + Storage = storage, + }); + return Task.FromResult(response); + } + + public JetStreamApiResponse CreateStreamDirect(StreamConfig config) + => _streamManager.CreateOrUpdate(config); + + public JetStreamApiResponse UpdateStream(string name, string[] subjects, int replicas, int maxMsgs = 0) + { + return _streamManager.CreateOrUpdate(new StreamConfig + { + Name = name, + Subjects = [.. subjects], + Replicas = replicas, + MaxMsgs = maxMsgs, + }); + } + + public Task PublishAsync(string subject, string payload) + { + if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), null, out var ack)) + { + if (ack.ErrorCode == null && _streamManager.TryGet(ack.Stream, out var handle)) + { + var stored = handle.Store.LoadAsync(ack.Seq, default).GetAwaiter().GetResult(); + if (stored != null) + _consumerManager.OnPublished(ack.Stream, stored); + } + + return Task.FromResult(ack); + } + + throw new InvalidOperationException($"Publish to '{subject}' did not match a stream."); + } + + public Task GetStreamInfoAsync(string name) + => Task.FromResult(_streamManager.GetInfo(name)); + + public Task GetStreamStateAsync(string name) + => _streamManager.GetStateAsync(name, default).AsTask(); + + public string GetStoreBackendType(string name) => _streamManager.GetStoreBackendType(name); + + public Task CreateConsumerAsync( + string stream, + string durableName, + string? filterSubject = null, + AckPolicy ackPolicy = AckPolicy.None) + { + var config = new ConsumerConfig + { + DurableName = durableName, + AckPolicy = ackPolicy, + }; + if (!string.IsNullOrWhiteSpace(filterSubject)) + config.FilterSubject = filterSubject; + + return Task.FromResult(_consumerManager.CreateOrUpdate(stream, config)); + } + + public Task FetchAsync(string stream, string durableName, int batch) + => _consumerManager.FetchAsync(stream, durableName, batch, _streamManager, default).AsTask(); + + public void AckAll(string stream, string durableName, ulong sequence) + => _consumerManager.AckAll(stream, durableName, sequence); + + public Task RequestAsync(string subject, string payload) + => Task.FromResult(_router.Route(subject, Encoding.UTF8.GetBytes(payload))); + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamAdminTests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamAdminTests.cs new file mode 100644 index 0000000..1c46515 --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamAdminTests.cs @@ -0,0 +1,601 @@ +// Ported from golang/nats-server/server/jetstream_test.go +// Admin operations: stream/consumer list/names, account info, stream leader stepdown, +// peer info, account purge, server remove, API routing + +using System.Text; +using NATS.Server.Auth; +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Cluster; +using NATS.Server.JetStream.Models; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamAdminTests +{ + // Go: TestJetStreamRequestAPI server/jetstream_test.go:5429 + [Fact] + public async Task Account_info_returns_stream_and_consumer_counts() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("S1", "s1.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S2", """{"subjects":["s2.>"]}"""); + _ = await fx.CreateConsumerAsync("S1", "C1", "s1.>"); + + var info = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + info.Error.ShouldBeNull(); + info.AccountInfo.ShouldNotBeNull(); + info.AccountInfo!.Streams.ShouldBe(2); + info.AccountInfo.Consumers.ShouldBe(1); + } + + // Go: TestJetStreamRequestAPI — account info with zero + [Fact] + public void Account_info_empty_returns_zero_counts() + { + var router = new JetStreamApiRouter(new StreamManager(), new ConsumerManager()); + var resp = router.Route("$JS.API.INFO", "{}"u8); + + resp.Error.ShouldBeNull(); + resp.AccountInfo.ShouldNotBeNull(); + resp.AccountInfo!.Streams.ShouldBe(0); + resp.AccountInfo.Consumers.ShouldBe(0); + } + + // Go: TestJetStreamFilteredStreamNames server/jetstream_test.go:5392 + [Fact] + public async Task Stream_names_returns_all_streams() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ALPHA", "alpha.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.BETA", """{"subjects":["beta.>"]}"""); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.GAMMA", """{"subjects":["gamma.>"]}"""); + + var names = await fx.RequestLocalAsync("$JS.API.STREAM.NAMES", "{}"); + names.StreamNames.ShouldNotBeNull(); + names.StreamNames!.Count.ShouldBe(3); + names.StreamNames.ShouldContain("ALPHA"); + names.StreamNames.ShouldContain("BETA"); + names.StreamNames.ShouldContain("GAMMA"); + } + + // Go: TestJetStreamFilteredStreamNames — names sorted + [Fact] + public async Task Stream_names_are_sorted() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ZZZ", "zzz.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.AAA", """{"subjects":["aaa.>"]}"""); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.MMM", """{"subjects":["mmm.>"]}"""); + + var names = await fx.RequestLocalAsync("$JS.API.STREAM.NAMES", "{}"); + names.StreamNames![0].ShouldBe("AAA"); + names.StreamNames[1].ShouldBe("MMM"); + names.StreamNames[2].ShouldBe("ZZZ"); + } + + // Go: TestJetStreamStreamList + [Fact] + public async Task Stream_list_returns_same_as_names() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("L1", "l1.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.L2", """{"subjects":["l2.>"]}"""); + + var names = await fx.RequestLocalAsync("$JS.API.STREAM.NAMES", "{}"); + var list = await fx.RequestLocalAsync("$JS.API.STREAM.LIST", "{}"); + + list.StreamNames!.Count.ShouldBe(names.StreamNames!.Count); + } + + // Go: TestJetStreamFilteredStreamNames — empty after delete all + [Fact] + public async Task Stream_names_empty_after_all_deleted() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DEL1", "del1.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.DEL2", """{"subjects":["del2.>"]}"""); + + _ = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.DEL1", "{}"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.DEL2", "{}"); + + var names = await fx.RequestLocalAsync("$JS.API.STREAM.NAMES", "{}"); + names.StreamNames!.Count.ShouldBe(0); + } + + // Go: TestJetStreamConsumerList + [Fact] + public async Task Consumer_names_returns_all_consumers() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("CL", "cl.>"); + _ = await fx.CreateConsumerAsync("CL", "A", "cl.a"); + _ = await fx.CreateConsumerAsync("CL", "B", "cl.b"); + _ = await fx.CreateConsumerAsync("CL", "C", "cl.c"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.CL", "{}"); + names.ConsumerNames.ShouldNotBeNull(); + names.ConsumerNames!.Count.ShouldBe(3); + } + + // Go: TestJetStreamConsumerList — names sorted + [Fact] + public async Task Consumer_names_are_sorted() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("CS", "cs.>"); + _ = await fx.CreateConsumerAsync("CS", "ZZZ", "cs.>"); + _ = await fx.CreateConsumerAsync("CS", "AAA", "cs.>"); + _ = await fx.CreateConsumerAsync("CS", "MMM", "cs.>"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.CS", "{}"); + names.ConsumerNames![0].ShouldBe("AAA"); + names.ConsumerNames[1].ShouldBe("MMM"); + names.ConsumerNames[2].ShouldBe("ZZZ"); + } + + // Go: TestJetStreamConsumerList — list matches names + [Fact] + public async Task Consumer_list_returns_same_as_names() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("CLM", "clm.>"); + _ = await fx.CreateConsumerAsync("CLM", "C1", "clm.>"); + _ = await fx.CreateConsumerAsync("CLM", "C2", "clm.>"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.CLM", "{}"); + var list = await fx.RequestLocalAsync("$JS.API.CONSUMER.LIST.CLM", "{}"); + + list.ConsumerNames!.Count.ShouldBe(names.ConsumerNames!.Count); + } + + // Go: TestJetStreamConsumerList — empty after delete all + [Fact] + public async Task Consumer_names_empty_after_all_deleted() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("CD", "cd.>"); + _ = await fx.CreateConsumerAsync("CD", "C1", "cd.>"); + + _ = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.CD.C1", "{}"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.CD", "{}"); + names.ConsumerNames!.Count.ShouldBe(0); + } + + // Go: TestJetStreamStreamLeaderStepdown + [Fact] + public async Task Stream_leader_stepdown_returns_success() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SLD", "sld.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.STREAM.LEADER.STEPDOWN.SLD", "{}"); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamStreamPeerRemove + [Fact] + public async Task Stream_peer_remove_returns_success() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SPR", "spr.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.STREAM.PEER.REMOVE.SPR", "{}"); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamConsumerLeaderStepdown + [Fact] + public async Task Consumer_leader_stepdown_returns_success() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("CLSD", "clsd.>"); + _ = await fx.CreateConsumerAsync("CLSD", "C1", "clsd.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.CONSUMER.LEADER.STEPDOWN.CLSD.C1", "{}"); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamAccountPurge server/jetstream_test.go + [Fact] + public async Task Account_purge_returns_success() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("AP", "ap.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.ACCOUNT.PURGE.DEFAULT", "{}"); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamServerRemove + [Fact] + public void Server_remove_returns_success() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.SERVER.REMOVE", "{}"u8); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamAccountStreamMove + [Fact] + public async Task Account_stream_move_returns_success() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ASM", "asm.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.ACCOUNT.STREAM.MOVE.MYSTREAM", "{}"); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamAccountStreamMoveCancel + [Fact] + public async Task Account_stream_move_cancel_returns_success() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ASMC", "asmc.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.ACCOUNT.STREAM.MOVE.CANCEL.MYSTREAM", "{}"); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamRequestAPI — unknown subject + [Fact] + public void Unknown_api_subject_returns_not_found() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.UNKNOWN.THING", "{}"u8); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(404); + } + + // Go: TestJetStreamRequestAPI — multiple API calls + [Fact] + public async Task Multiple_api_calls_in_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MULTI", "multi.>"); + + // INFO + var info = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + info.AccountInfo.ShouldNotBeNull(); + + // STREAM.NAMES + var names = await fx.RequestLocalAsync("$JS.API.STREAM.NAMES", "{}"); + names.StreamNames.ShouldNotBeNull(); + + // STREAM.INFO + var sInfo = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.MULTI", "{}"); + sInfo.StreamInfo.ShouldNotBeNull(); + } + + // Go: TestJetStreamDisabledLimitsEnforcementJWT server/jetstream_test.go + [Fact] + public async Task Jwt_limited_account_enforces_max_streams() + { + await using var fx = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 1); + + var s1 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S1", """{"subjects":["s1.>"]}"""); + s1.Error.ShouldBeNull(); + + var s2 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S2", """{"subjects":["s2.>"]}"""); + s2.Error.ShouldNotBeNull(); + s2.Error!.Code.ShouldBe(10027); + } + + // Go: TestJetStreamDisabledLimitsEnforcementJWT — delete frees slot + [Fact] + public async Task Jwt_limited_account_delete_frees_slot() + { + await using var fx = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 1); + + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S1", """{"subjects":["s1.>"]}"""); + + _ = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.S1", "{}"); + + var s2 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S2", """{"subjects":["s2.>"]}"""); + s2.Error.ShouldBeNull(); + } + + // Go: TestJetStreamSystemLimits server/jetstream_test.go:4636 + [Fact] + public async Task Account_info_updates_after_consumer_creation() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("AI", "ai.>"); + + var before = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + before.AccountInfo!.Consumers.ShouldBe(0); + + _ = await fx.CreateConsumerAsync("AI", "C1", "ai.>"); + + var after = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + after.AccountInfo!.Consumers.ShouldBe(1); + } + + // Go: TestJetStreamSystemLimits — account info updates after stream deletion + [Fact] + public async Task Account_info_updates_after_stream_deletion() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("AID", "aid.>"); + + var before = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + before.AccountInfo!.Streams.ShouldBe(1); + + _ = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.AID", "{}"); + + var after = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + after.AccountInfo!.Streams.ShouldBe(0); + } + + // Go: TestJetStreamConsumerList — consumer names scoped to stream + [Fact] + public async Task Consumer_names_for_non_existent_stream_returns_empty() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("X", "x.>"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.NOPE", "{}"); + names.ConsumerNames.ShouldNotBeNull(); + names.ConsumerNames!.Count.ShouldBe(0); + } + + // Go: TestJetStreamMetaLeaderStepdown + [Fact] + public void Meta_leader_stepdown_with_meta_group_returns_success() + { + var metaGroup = new JetStreamMetaGroup(3); + var router = new JetStreamApiRouter(new StreamManager(), new ConsumerManager(), metaGroup); + + var resp = router.Route("$JS.API.META.LEADER.STEPDOWN", "{}"u8); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamMetaLeaderStepdown — without meta group + [Fact] + public void Meta_leader_stepdown_without_meta_group_returns_not_found() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.META.LEADER.STEPDOWN", "{}"u8); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(404); + } + + // Go: TestJetStreamStreamLeaderStepdown — non-existent stream + [Fact] + public async Task Stream_leader_stepdown_non_existent_still_succeeds() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("LS", "ls.>"); + + // Stepdown for non-existent stream doesn't error (no-op) + var resp = await fx.RequestLocalAsync("$JS.API.STREAM.LEADER.STEPDOWN.NOPE", "{}"); + resp.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamConsumerNext — via API router + [Fact] + public async Task Consumer_next_via_api_returns_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NEXT", "next.>"); + _ = await fx.CreateConsumerAsync("NEXT", "C1", "next.>"); + + _ = await fx.PublishAndGetAckAsync("next.x", "data1"); + _ = await fx.PublishAndGetAckAsync("next.x", "data2"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.MSG.NEXT.NEXT.C1", + """{"batch":2}"""); + resp.PullBatch.ShouldNotBeNull(); + resp.PullBatch!.Messages.Count.ShouldBe(2); + } + + // Go: TestJetStreamConsumerNext — empty + [Fact] + public async Task Consumer_next_with_no_messages_returns_empty() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NE", "ne.>"); + _ = await fx.CreateConsumerAsync("NE", "C1", "ne.>"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.MSG.NEXT.NE.C1", + """{"batch":1}"""); + resp.PullBatch.ShouldNotBeNull(); + resp.PullBatch!.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamStorageSelection + [Fact] + public async Task Storage_selection_file() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "FILE", + Subjects = ["file.>"], + Storage = StorageType.File, + }); + + var backend = await fx.GetStreamBackendTypeAsync("FILE"); + backend.ShouldBe("file"); + } + + // Go: TestJetStreamStorageSelection — memory + [Fact] + public async Task Storage_selection_memory() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MEM", + Subjects = ["mem.>"], + Storage = StorageType.Memory, + }); + + var backend = await fx.GetStreamBackendTypeAsync("MEM"); + backend.ShouldBe("memory"); + } + + // Go: TestJetStreamStorageSelection — non-existent + [Fact] + public async Task Storage_backend_type_for_missing_stream() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("X", "x.>"); + + var backend = await fx.GetStreamBackendTypeAsync("NOPE"); + backend.ShouldBe("missing"); + } + + // Go: TestJetStreamConsumerNames — for specific stream + [Fact] + public async Task Consumer_names_only_include_target_stream() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("S1", "s1.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S2", """{"subjects":["s2.>"]}"""); + _ = await fx.CreateConsumerAsync("S1", "C1", "s1.>"); + _ = await fx.CreateConsumerAsync("S2", "C2", "s2.>"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.S1", "{}"); + names.ConsumerNames!.Count.ShouldBe(1); + names.ConsumerNames.ShouldContain("C1"); + names.ConsumerNames.ShouldNotContain("C2"); + } + + // Go: TestJetStreamConsumerDelete — delete decrements count + [Fact] + public async Task Delete_consumer_decrements_account_info_count() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DCC", "dcc.>"); + _ = await fx.CreateConsumerAsync("DCC", "C1", "dcc.>"); + _ = await fx.CreateConsumerAsync("DCC", "C2", "dcc.>"); + + var before = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + before.AccountInfo!.Consumers.ShouldBe(2); + + _ = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.DCC.C1", "{}"); + + var after = await fx.RequestLocalAsync("$JS.API.INFO", "{}"); + after.AccountInfo!.Consumers.ShouldBe(1); + } + + // Go: TestJetStreamAccountPurge — empty account name fails + [Fact] + public void Account_purge_without_name_returns_not_found() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.ACCOUNT.PURGE.", "{}"u8); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamAccountStreamMove — empty stream name fails + [Fact] + public void Account_stream_move_without_name_returns_not_found() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.ACCOUNT.STREAM.MOVE.", "{}"u8); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamStreamLeaderStepdown — empty stream name fails + [Fact] + public void Stream_leader_stepdown_without_name_returns_not_found() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.STREAM.LEADER.STEPDOWN.", "{}"u8); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamStreamPeerRemove — empty stream name fails + [Fact] + public void Stream_peer_remove_without_name_returns_not_found() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.STREAM.PEER.REMOVE.", "{}"u8); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamConsumerLeaderStepdown — malformed subject + [Fact] + public void Consumer_leader_stepdown_with_single_token_returns_not_found() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.CONSUMER.LEADER.STEPDOWN.ONLYONE", "{}"u8); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamConsumerReset — non-existent consumer + [Fact] + public async Task Consumer_reset_non_existent_returns_not_found() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("RNE", "rne.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.CONSUMER.RESET.RNE.NOPE", "{}"); + resp.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamConsumerUnpin — non-existent consumer + [Fact] + public async Task Consumer_unpin_non_existent_returns_not_found() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("UNE", "une.>"); + + var resp = await fx.RequestLocalAsync("$JS.API.CONSUMER.UNPIN.UNE.NOPE", "{}"); + resp.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamLimits server/jetstream_test.go + [Fact] + public async Task Jwt_limited_account_allows_within_limit() + { + await using var fx = await JetStreamApiFixture.StartJwtLimitedAccountAsync(maxStreams: 3); + + var s1 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S1", """{"subjects":["s1.>"]}"""); + s1.Error.ShouldBeNull(); + var s2 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S2", """{"subjects":["s2.>"]}"""); + s2.Error.ShouldBeNull(); + var s3 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S3", """{"subjects":["s3.>"]}"""); + s3.Error.ShouldBeNull(); + + // Fourth should fail + var s4 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S4", """{"subjects":["s4.>"]}"""); + s4.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamStreamMessageDeleteViaAPI + [Fact] + public async Task Message_delete_via_api_and_verify() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MDAPI", "mdapi.>"); + _ = await fx.PublishAndGetAckAsync("mdapi.x", "msg1"); + var ack2 = await fx.PublishAndGetAckAsync("mdapi.x", "msg2"); + _ = await fx.PublishAndGetAckAsync("mdapi.x", "msg3"); + + var del = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.DELETE.MDAPI", + $$"""{ "seq": {{ack2.Seq}} }"""); + del.Success.ShouldBeTrue(); + + // Verify the deleted message is gone + var msg = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.GET.MDAPI", + $$"""{ "seq": {{ack2.Seq}} }"""); + msg.Error.ShouldNotBeNull(); + + // Other messages still exist + var state = await fx.GetStreamStateAsync("MDAPI"); + state.Messages.ShouldBe(2UL); + } + + // Go: TestJetStreamRequestAPI — direct get missing sequence + [Fact] + public async Task Direct_get_with_zero_sequence_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DGZ", "dgz.>"); + _ = await fx.PublishAndGetAckAsync("dgz.x", "data"); + + var resp = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.DGZ", """{"seq":0}"""); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamRequestAPI — direct get non-existent stream + [Fact] + public void Direct_get_non_existent_stream_returns_error() + { + var router = new JetStreamApiRouter(); + var resp = router.Route("$JS.API.DIRECT.GET.NOPE", """{"seq":1}"""u8); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamConsumerNext — batch default + [Fact] + public async Task Consumer_next_with_no_batch_defaults_to_one() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NBAT", "nbat.>"); + _ = await fx.CreateConsumerAsync("NBAT", "C1", "nbat.>"); + _ = await fx.PublishAndGetAckAsync("nbat.x", "data1"); + _ = await fx.PublishAndGetAckAsync("nbat.x", "data2"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.MSG.NEXT.NBAT.C1", "{}"); + resp.PullBatch!.Messages.Count.ShouldBe(1); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamConsumerCrudTests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamConsumerCrudTests.cs new file mode 100644 index 0000000..ee8ad04 --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamConsumerCrudTests.cs @@ -0,0 +1,513 @@ +// Ported from golang/nats-server/server/jetstream_test.go +// Consumer CRUD operations: create push/pull, update, delete, info, ephemeral + +using NATS.Server.JetStream.Models; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamConsumerCrudTests +{ + // Go: TestJetStreamEphemeralConsumers server/jetstream_test.go:3688 + [Fact] + public async Task Create_ephemeral_consumer() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*"); + var create = await fx.CreateConsumerAsync("ORDERS", "EPH", "orders.*", ephemeral: true); + create.Error.ShouldBeNull(); + create.ConsumerInfo.ShouldNotBeNull(); + } + + // Go: TestJetStreamEphemeralPullConsumers server/jetstream_test.go + [Fact] + public async Task Create_ephemeral_pull_consumer() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*"); + var create = await fx.CreateConsumerAsync("ORDERS", "EPULL", "orders.*", ephemeral: true); + create.Error.ShouldBeNull(); + } + + // Go: TestJetStreamBasicDeliverSubject server/jetstream_test.go:899 + [Fact] + public async Task Create_push_consumer_with_heartbeats() + { + await using var fx = await JetStreamApiFixture.StartWithPushConsumerAsync(); + var info = await fx.GetConsumerInfoAsync("ORDERS", "PUSH"); + info.Config.Push.ShouldBeTrue(); + info.Config.HeartbeatMs.ShouldBe(25); + } + + // Go: TestJetStreamSubjectFiltering server/jetstream_test.go:1089 + [Fact] + public async Task Create_consumer_with_filter_subject() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EVENTS", "events.>"); + var create = await fx.CreateConsumerAsync("EVENTS", "FILT", "events.click"); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("EVENTS", "FILT"); + info.Config.FilterSubject.ShouldBe("events.click"); + } + + // Go: TestJetStreamBothFiltersSet server/jetstream_test.go + [Fact] + public async Task Create_consumer_with_multiple_filter_subjects() + { + await using var fx = await JetStreamApiFixture.StartWithMultiFilterConsumerAsync(); + var info = await fx.GetConsumerInfoAsync("ORDERS", "CF"); + info.Config.FilterSubjects.ShouldContain("orders.*"); + } + + // Go: TestJetStreamAckExplicitMsgRemoval server/jetstream_test.go:5897 + [Fact] + public async Task Create_consumer_with_ack_explicit() + { + await using var fx = await JetStreamApiFixture.StartWithAckExplicitConsumerAsync(30_000); + var info = await fx.GetConsumerInfoAsync("ORDERS", "PULL"); + info.Config.AckPolicy.ShouldBe(AckPolicy.Explicit); + info.Config.AckWaitMs.ShouldBe(30_000); + } + + // Go: TestJetStreamAckAllRedelivery server/jetstream_test.go:1850 + [Fact] + public async Task Create_consumer_with_ack_all() + { + await using var fx = await JetStreamApiFixture.StartWithAckAllConsumerAsync(); + var info = await fx.GetConsumerInfoAsync("ORDERS", "ACKALL"); + info.Config.AckPolicy.ShouldBe(AckPolicy.All); + } + + // Go: TestJetStreamNoAckStream server/jetstream_test.go:821 + [Fact] + public async Task Create_consumer_with_ack_none() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NOACK", "noack.>"); + var create = await fx.CreateConsumerAsync("NOACK", "NONE", "noack.>", ackPolicy: AckPolicy.None); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("NOACK", "NONE"); + info.Config.AckPolicy.ShouldBe(AckPolicy.None); + } + + // Go: TestJetStreamActiveDelivery server/jetstream_test.go:3644 + [Fact] + public async Task Consumer_info_roundtrip_returns_correct_config() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*"); + _ = await fx.CreateConsumerAsync("ORDERS", "DUR", "orders.created"); + + var info = await fx.GetConsumerInfoAsync("ORDERS", "DUR"); + info.Config.DurableName.ShouldBe("DUR"); + info.Config.FilterSubject.ShouldBe("orders.created"); + } + + // Go: TestJetStreamChangeConsumerType server/jetstream_test.go:5766 + [Fact] + public async Task Consumer_delete_and_recreate() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ST", "st.>"); + _ = await fx.CreateConsumerAsync("ST", "C1", "st.>"); + + var del = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.ST.C1", "{}"); + del.Success.ShouldBeTrue(); + + // Recreate with different filter + var create = await fx.CreateConsumerAsync("ST", "C1", "st.created"); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("ST", "C1"); + info.Config.FilterSubject.ShouldBe("st.created"); + } + + // Go: TestJetStreamDirectConsumersBeingReported server/jetstream_test.go + [Fact] + public async Task Consumer_info_for_non_existent_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("S", "s.>"); + + var info = await fx.RequestLocalAsync("$JS.API.CONSUMER.INFO.S.NOTEXIST", "{}"); + info.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamBasicWorkQueue server/jetstream_test.go:937 + [Fact] + public async Task Create_consumer_with_deliver_policy_all() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("WQ", "wq.>"); + var create = await fx.CreateConsumerAsync("WQ", "C1", "wq.>", deliverPolicy: DeliverPolicy.All); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("WQ", "C1"); + info.Config.DeliverPolicy.ShouldBe(DeliverPolicy.All); + } + + // Go: TestJetStreamDeliverLastPerSubject server/jetstream_test.go + [Fact] + public async Task Create_consumer_with_deliver_policy_last() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DL", "dl.>"); + var create = await fx.CreateConsumerAsync("DL", "LAST", "dl.>", deliverPolicy: DeliverPolicy.Last); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("DL", "LAST"); + info.Config.DeliverPolicy.ShouldBe(DeliverPolicy.Last); + } + + // Go: TestJetStreamDeliverLastPerSubject server/jetstream_test.go + [Fact] + public async Task Create_consumer_with_deliver_policy_new() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DN", "dn.>"); + var create = await fx.CreateConsumerAsync("DN", "NEW", "dn.>", deliverPolicy: DeliverPolicy.New); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("DN", "NEW"); + info.Config.DeliverPolicy.ShouldBe(DeliverPolicy.New); + } + + // Go: TestJetStreamWorkQueueRetentionStream server/jetstream_test.go:1655 + [Fact] + public async Task Consumer_with_replay_original() + { + await using var fx = await JetStreamApiFixture.StartWithReplayOriginalConsumerAsync(); + var info = await fx.GetConsumerInfoAsync("ORDERS", "RO"); + info.Config.ReplayPolicy.ShouldBe(ReplayPolicy.Original); + } + + // Go: TestJetStreamFilteredConsumersWithWiderFilter server/jetstream_test.go + [Fact] + public async Task Consumer_with_wildcard_filter() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("WIDE", "wide.>"); + var create = await fx.CreateConsumerAsync("WIDE", "WILD", "wide.*"); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("WIDE", "WILD"); + info.Config.FilterSubject.ShouldBe("wide.*"); + } + + // Go: TestJetStreamPushConsumerFlowControl server/jetstream_test.go:5203 + [Fact] + public async Task Create_push_consumer_with_flow_control() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FC", "fc.>"); + var create = await fx.CreateConsumerAsync("FC", "PUSH", "fc.>", push: true, heartbeatMs: 100); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("FC", "PUSH"); + info.Config.Push.ShouldBeTrue(); + } + + // Go: TestJetStreamMaxConsumers server/jetstream_test.go:619 + [Fact] + public async Task Create_multiple_consumers_on_same_stream() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MULTI", "multi.>"); + _ = await fx.CreateConsumerAsync("MULTI", "C1", "multi.a"); + _ = await fx.CreateConsumerAsync("MULTI", "C2", "multi.b"); + _ = await fx.CreateConsumerAsync("MULTI", "C3", "multi.>"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.MULTI", "{}"); + names.ConsumerNames.ShouldNotBeNull(); + names.ConsumerNames!.Count.ShouldBe(3); + names.ConsumerNames.ShouldContain("C1"); + names.ConsumerNames.ShouldContain("C2"); + names.ConsumerNames.ShouldContain("C3"); + } + + // Go: TestJetStreamConsumerListAndDelete + [Fact] + public async Task Delete_consumer_removes_from_list() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DLC", "dlc.>"); + _ = await fx.CreateConsumerAsync("DLC", "C1", "dlc.>"); + _ = await fx.CreateConsumerAsync("DLC", "C2", "dlc.>"); + + _ = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.DLC.C1", "{}"); + + var names = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.DLC", "{}"); + names.ConsumerNames.ShouldNotBeNull(); + names.ConsumerNames!.Count.ShouldBe(1); + names.ConsumerNames.ShouldContain("C2"); + } + + // Go: TestJetStreamWorkQueueAckAndNext server/jetstream_test.go:1355 + [Fact] + public async Task Consumer_max_ack_pending_setting() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MAP", "map.>"); + var create = await fx.CreateConsumerAsync("MAP", "C1", "map.>", + ackPolicy: AckPolicy.Explicit, + maxAckPending: 5); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("MAP", "C1"); + info.Config.MaxAckPending.ShouldBe(5); + } + + // Go: TestJetStreamWorkQueueAckWaitRedelivery server/jetstream_test.go:1959 + [Fact] + public async Task Consumer_ack_wait_setting() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("AW", "aw.>"); + var create = await fx.CreateConsumerAsync("AW", "C1", "aw.>", + ackPolicy: AckPolicy.Explicit, + ackWaitMs: 5000); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("AW", "C1"); + info.Config.AckWaitMs.ShouldBe(5000); + } + + // Go: TestJetStreamConsumerPause server/jetstream_test.go + [Fact] + public async Task Consumer_pause_and_resume() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PAUSE", "pause.>"); + _ = await fx.CreateConsumerAsync("PAUSE", "C1", "pause.>"); + + var pause = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.PAUSE.PAUSE.C1", + """{"pause":true}"""); + pause.Success.ShouldBeTrue(); + + var resume = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.PAUSE.PAUSE.C1", + """{"pause":false}"""); + resume.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamConsumerReset server/jetstream_test.go + [Fact] + public async Task Consumer_reset_resets_delivery_position() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("RESET", "reset.>"); + _ = await fx.CreateConsumerAsync("RESET", "C1", "reset.>"); + _ = await fx.PublishAndGetAckAsync("reset.x", "data"); + + // Fetch a message to advance position + _ = await fx.FetchAsync("RESET", "C1", 1); + + var reset = await fx.RequestLocalAsync("$JS.API.CONSUMER.RESET.RESET.C1", "{}"); + reset.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamConsumerUnpin server/jetstream_test.go + [Fact] + public async Task Consumer_unpin_returns_success() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("UNPIN", "unpin.>"); + _ = await fx.CreateConsumerAsync("UNPIN", "C1", "unpin.>"); + + var unpin = await fx.RequestLocalAsync("$JS.API.CONSUMER.UNPIN.UNPIN.C1", "{}"); + unpin.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamConsumerUpdate — update filter subject + [Fact] + public async Task Consumer_update_changes_config() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("UPD", "upd.>"); + _ = await fx.CreateConsumerAsync("UPD", "C1", "upd.a"); + + var update = await fx.CreateConsumerAsync("UPD", "C1", "upd.b"); + update.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("UPD", "C1"); + info.Config.FilterSubject.ShouldBe("upd.b"); + } + + // Go: TestJetStreamConsumerList — list across stream boundary + [Fact] + public async Task Consumer_list_is_scoped_to_stream() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("S1", "s1.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S2", """{"subjects":["s2.>"]}"""); + _ = await fx.CreateConsumerAsync("S1", "C1", "s1.>"); + _ = await fx.CreateConsumerAsync("S2", "C2", "s2.>"); + + var namesS1 = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.S1", "{}"); + namesS1.ConsumerNames!.Count.ShouldBe(1); + namesS1.ConsumerNames.ShouldContain("C1"); + + var namesS2 = await fx.RequestLocalAsync("$JS.API.CONSUMER.NAMES.S2", "{}"); + namesS2.ConsumerNames!.Count.ShouldBe(1); + namesS2.ConsumerNames.ShouldContain("C2"); + } + + // Go: TestJetStreamConsumerDelete — double delete + [Fact] + public async Task Delete_non_existent_consumer_returns_not_found() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DF", "df.>"); + + var del = await fx.RequestLocalAsync("$JS.API.CONSUMER.DELETE.DF.NOPE", "{}"); + del.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamConsumerCreate — default ack policy + [Fact] + public async Task Consumer_defaults_to_ack_none() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DEF", "def.>"); + _ = await fx.CreateConsumerAsync("DEF", "C1", "def.>"); + + var info = await fx.GetConsumerInfoAsync("DEF", "C1"); + info.Config.AckPolicy.ShouldBe(AckPolicy.None); + } + + // Go: TestJetStreamConsumerCreate — default deliver policy + [Fact] + public async Task Consumer_defaults_to_deliver_all() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DDP", "ddp.>"); + _ = await fx.CreateConsumerAsync("DDP", "C1", "ddp.>"); + + var info = await fx.GetConsumerInfoAsync("DDP", "C1"); + info.Config.DeliverPolicy.ShouldBe(DeliverPolicy.All); + } + + // Go: TestJetStreamConsumerCreate — default replay policy + [Fact] + public async Task Consumer_defaults_to_replay_instant() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DRP", "drp.>"); + _ = await fx.CreateConsumerAsync("DRP", "C1", "drp.>"); + + var info = await fx.GetConsumerInfoAsync("DRP", "C1"); + info.Config.ReplayPolicy.ShouldBe(ReplayPolicy.Instant); + } + + // Go: TestJetStreamConsumerPause — pause non-existent consumer + [Fact] + public async Task Pause_non_existent_consumer_returns_not_found() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PNE", "pne.>"); + + var pause = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.PAUSE.PNE.NOPE", + """{"pause":true}"""); + pause.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamConsumerCreate — durable name required for non-ephemeral + [Fact] + public async Task Consumer_without_durable_name_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NDN", "ndn.>"); + + // Send raw JSON without durable_name and without ephemeral flag + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.NDN.C1", + """{"filter_subject":"ndn.>"}"""); + // The consumer should be created since the subject has the durable name + resp.Error.ShouldBeNull(); + } + + // Go: TestJetStreamConsumerMaxDeliver + [Fact] + public async Task Consumer_max_deliver_setting() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MD", "md.>"); + var create = await fx.CreateConsumerAsync("MD", "C1", "md.>", + ackPolicy: AckPolicy.Explicit); + create.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("MD", "C1"); + info.Config.MaxDeliver.ShouldBeGreaterThanOrEqualTo(0); + } + + // Go: TestJetStreamConsumerBackoff + [Fact] + public async Task Consumer_with_backoff_configuration() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("BO", "bo.>"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.BO.C1", + """{"durable_name":"C1","filter_subject":"bo.>","ack_policy":"explicit","backoff_ms":[100,200,500]}"""); + resp.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("BO", "C1"); + info.Config.BackOffMs.Count.ShouldBe(3); + info.Config.BackOffMs[0].ShouldBe(100); + info.Config.BackOffMs[1].ShouldBe(200); + info.Config.BackOffMs[2].ShouldBe(500); + } + + // Go: TestJetStreamConsumerRateLimit + [Fact] + public async Task Consumer_with_rate_limit() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("RL", "rl.>"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.RL.C1", + """{"durable_name":"C1","filter_subject":"rl.>","push":true,"heartbeat_ms":100,"rate_limit_bps":1024}"""); + resp.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("RL", "C1"); + info.Config.RateLimitBps.ShouldBe(1024); + } + + // Go: TestJetStreamConsumerCreate — opt_start_seq + [Fact] + public async Task Consumer_with_opt_start_seq() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("OSS", "oss.>"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.OSS.C1", + """{"durable_name":"C1","filter_subject":"oss.>","deliver_policy":"by_start_sequence","opt_start_seq":5}"""); + resp.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("OSS", "C1"); + info.Config.DeliverPolicy.ShouldBe(DeliverPolicy.ByStartSequence); + info.Config.OptStartSeq.ShouldBe(5UL); + } + + // Go: TestJetStreamConsumerCreate — opt_start_time_utc + [Fact] + public async Task Consumer_with_opt_start_time() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("OST", "ost.>"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.OST.C1", + """{"durable_name":"C1","filter_subject":"ost.>","deliver_policy":"by_start_time","opt_start_time_utc":"2025-01-01T00:00:00Z"}"""); + resp.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("OST", "C1"); + info.Config.DeliverPolicy.ShouldBe(DeliverPolicy.ByStartTime); + info.Config.OptStartTimeUtc.ShouldNotBeNull(); + } + + // Go: TestJetStreamConsumerCreate — flow_control + [Fact] + public async Task Consumer_with_flow_control() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FLOW", "flow.>"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.FLOW.C1", + """{"durable_name":"C1","filter_subject":"flow.>","push":true,"heartbeat_ms":100,"flow_control":true}"""); + resp.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("FLOW", "C1"); + info.Config.FlowControl.ShouldBeTrue(); + } + + // Go: TestJetStreamConsumerDeliverLastPerSubject server/jetstream_test.go + [Fact] + public async Task Consumer_with_deliver_last_per_subject() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DLPS", "dlps.>"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.DLPS.C1", + """{"durable_name":"C1","filter_subject":"dlps.>","deliver_policy":"last_per_subject"}"""); + resp.Error.ShouldBeNull(); + + var info = await fx.GetConsumerInfoAsync("DLPS", "C1"); + info.Config.DeliverPolicy.ShouldBe(DeliverPolicy.LastPerSubject); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamConsumerFeatureTests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamConsumerFeatureTests.cs new file mode 100644 index 0000000..bcd1bc9 --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamConsumerFeatureTests.cs @@ -0,0 +1,512 @@ +// Ported from golang/nats-server/server/jetstream_test.go +// Consumer features: max deliver, max ack pending, flow control, heartbeats, +// consumer pause/resume, ack all, redelivery + +using System.Text; +using NATS.Server.JetStream.Models; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamConsumerFeatureTests +{ + // Go: TestJetStreamWorkQueueAckWaitRedelivery server/jetstream_test.go:1959 + [Fact] + public async Task Ack_explicit_tracks_pending_count() + { + await using var fx = await JetStreamApiFixture.StartWithAckExplicitConsumerAsync(30_000); + _ = await fx.PublishAndGetAckAsync("orders.created", "msg1"); + _ = await fx.PublishAndGetAckAsync("orders.created", "msg2"); + + var batch = await fx.FetchAsync("ORDERS", "PULL", 2); + batch.Messages.Count.ShouldBe(2); + + var pending = await fx.GetPendingCountAsync("ORDERS", "PULL"); + pending.ShouldBe(2); + } + + // Go: TestJetStreamAckAllRedelivery server/jetstream_test.go:1850 + [Fact] + public async Task Ack_all_acknowledges_up_to_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithAckAllConsumerAsync(); + + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("orders.created", $"msg-{i}"); + + var batch = await fx.FetchAsync("ORDERS", "ACKALL", 5); + batch.Messages.Count.ShouldBe(5); + + await fx.AckAllAsync("ORDERS", "ACKALL", 3); + + var pending = await fx.GetPendingCountAsync("ORDERS", "ACKALL"); + // After acking up to 3, sequences 4 and 5 should still be pending + pending.ShouldBeLessThanOrEqualTo(2); + } + + // Go: TestJetStreamAckAllRedelivery — ack all sequences + [Fact] + public async Task Ack_all_clears_all_pending() + { + await using var fx = await JetStreamApiFixture.StartWithAckAllConsumerAsync(); + + for (var i = 0; i < 3; i++) + _ = await fx.PublishAndGetAckAsync("orders.created", $"msg-{i}"); + + var batch = await fx.FetchAsync("ORDERS", "ACKALL", 3); + batch.Messages.Count.ShouldBe(3); + + await fx.AckAllAsync("ORDERS", "ACKALL", batch.Messages[^1].Sequence); + + var pending = await fx.GetPendingCountAsync("ORDERS", "ACKALL"); + pending.ShouldBe(0); + } + + // Go: TestJetStreamPushConsumerFlowControl server/jetstream_test.go:5203 + [Fact] + public async Task Push_consumer_with_flow_control_emits_fc_frames() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FC", "fc.>"); + _ = await fx.CreateConsumerAsync("FC", "PUSH", "fc.>", push: true, heartbeatMs: 10); + // Enable flow control via direct JSON + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.FC.FCPUSH", + """{"durable_name":"FCPUSH","filter_subject":"fc.>","push":true,"heartbeat_ms":10,"flow_control":true}"""); + resp.Error.ShouldBeNull(); + + _ = await fx.PublishAndGetAckAsync("fc.x", "data"); + + var frame1 = await fx.ReadPushFrameAsync("FC", "FCPUSH"); + frame1.IsData.ShouldBeTrue(); + + // Flow control frame follows data frame + var frame2 = await fx.ReadPushFrameAsync("FC", "FCPUSH"); + frame2.IsFlowControl.ShouldBeTrue(); + } + + // Go: TestJetStreamPushConsumerIdleHeartbeats server/jetstream_test.go:5260 + [Fact] + public async Task Push_consumer_with_heartbeats_emits_heartbeat_frames() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("HB", "hb.>"); + _ = await fx.CreateConsumerAsync("HB", "PUSH", "hb.>", push: true, heartbeatMs: 10); + + _ = await fx.PublishAndGetAckAsync("hb.x", "data"); + + var frame = await fx.ReadPushFrameAsync("HB", "PUSH"); + frame.IsData.ShouldBeTrue(); + + var hbFrame = await fx.ReadPushFrameAsync("HB", "PUSH"); + hbFrame.IsHeartbeat.ShouldBeTrue(); + } + + // Go: TestJetStreamFlowControlRequiresHeartbeats server/jetstream_test.go:5232 + [Fact] + public async Task Push_consumer_without_heartbeats_has_no_heartbeat_frames() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NHB", "nhb.>"); + _ = await fx.CreateConsumerAsync("NHB", "PUSH", "nhb.>", push: true, heartbeatMs: 0); + + _ = await fx.PublishAndGetAckAsync("nhb.x", "data"); + + var frame = await fx.ReadPushFrameAsync("NHB", "PUSH"); + frame.IsData.ShouldBeTrue(); + + // Without heartbeats, no heartbeat frame should be queued + Should.Throw(() => fx.ReadPushFrameAsync("NHB", "PUSH")); + } + + // Go: TestJetStreamConsumerPause server/jetstream_test.go + [Fact] + public async Task Paused_consumer_can_be_resumed() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PAUSE", "pause.>"); + _ = await fx.CreateConsumerAsync("PAUSE", "C1", "pause.>"); + + var pause = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.PAUSE.PAUSE.C1", """{"pause":true}"""); + pause.Success.ShouldBeTrue(); + + var resume = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.PAUSE.PAUSE.C1", """{"pause":false}"""); + resume.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamConsumerReset server/jetstream_test.go + [Fact] + public async Task Reset_consumer_restarts_delivery_from_beginning() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("RST", "rst.>"); + _ = await fx.CreateConsumerAsync("RST", "C1", "rst.>"); + + _ = await fx.PublishAndGetAckAsync("rst.x", "msg1"); + _ = await fx.PublishAndGetAckAsync("rst.x", "msg2"); + + var batch1 = await fx.FetchAsync("RST", "C1", 2); + batch1.Messages.Count.ShouldBe(2); + + _ = await fx.RequestLocalAsync("$JS.API.CONSUMER.RESET.RST.C1", "{}"); + + var batch2 = await fx.FetchAsync("RST", "C1", 2); + batch2.Messages.Count.ShouldBe(2); + batch2.Messages[0].Sequence.ShouldBe(1UL); + } + + // Go: TestJetStreamWorkQueueMaxWaiting server/jetstream_test.go:957 + [Fact] + public async Task Fetch_more_than_available_returns_only_available() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MW", "mw.>"); + _ = await fx.CreateConsumerAsync("MW", "C1", "mw.>"); + + _ = await fx.PublishAndGetAckAsync("mw.x", "msg1"); + _ = await fx.PublishAndGetAckAsync("mw.x", "msg2"); + + var batch = await fx.FetchAsync("MW", "C1", 100); + batch.Messages.Count.ShouldBe(2); + } + + // Go: TestJetStreamWorkQueueWrapWaiting server/jetstream_test.go:1022 + [Fact] + public async Task Fetch_wraps_around_correctly_after_multiple_fetches() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("WR", "wr.>"); + _ = await fx.CreateConsumerAsync("WR", "C1", "wr.>"); + + for (var i = 0; i < 10; i++) + _ = await fx.PublishAndGetAckAsync("wr.x", $"msg-{i}"); + + var batch1 = await fx.FetchAsync("WR", "C1", 3); + batch1.Messages.Count.ShouldBe(3); + batch1.Messages[^1].Sequence.ShouldBe(3UL); + + var batch2 = await fx.FetchAsync("WR", "C1", 3); + batch2.Messages.Count.ShouldBe(3); + batch2.Messages[0].Sequence.ShouldBe(4UL); + + var batch3 = await fx.FetchAsync("WR", "C1", 3); + batch3.Messages.Count.ShouldBe(3); + batch3.Messages[0].Sequence.ShouldBe(7UL); + } + + // Go: TestJetStreamMaxAckPending limits delivery + [Fact] + public async Task Max_ack_pending_limits_push_delivery() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MAP", "map.>"); + _ = await fx.CreateConsumerAsync("MAP", "PUSH", "map.>", + push: true, heartbeatMs: 10, + ackPolicy: AckPolicy.Explicit, + maxAckPending: 1); + + _ = await fx.PublishAndGetAckAsync("map.x", "msg1"); + _ = await fx.PublishAndGetAckAsync("map.x", "msg2"); + + // Only 1 should be delivered due to max ack pending + var frame = await fx.ReadPushFrameAsync("MAP", "PUSH"); + frame.IsData.ShouldBeTrue(); + } + + // Go: TestJetStreamDeliverLastPerSubject server/jetstream_test.go + // LastPerSubject resolves the initial sequence to a message matching the + // filter subject and then delivers forward from there. All matching messages + // from that point onward are delivered. + [Fact] + public async Task Deliver_last_per_subject_delivers_matching_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DLPS", "dlps.>"); + + _ = await fx.PublishAndGetAckAsync("dlps.a", "a1"); + _ = await fx.PublishAndGetAckAsync("dlps.b", "b1"); + _ = await fx.PublishAndGetAckAsync("dlps.a", "a2"); + _ = await fx.PublishAndGetAckAsync("dlps.b", "b2"); + + _ = await fx.CreateConsumerAsync("DLPS", "C1", "dlps.a", + deliverPolicy: DeliverPolicy.LastPerSubject); + + var batch = await fx.FetchAsync("DLPS", "C1", 10); + // Delivers all matching "dlps.a" messages from resolved start + batch.Messages.Count.ShouldBeGreaterThanOrEqualTo(1); + batch.Messages.All(m => m.Subject == "dlps.a").ShouldBeTrue(); + } + + // Go: TestJetStreamByStartSequence + [Fact] + public async Task Deliver_by_start_sequence_begins_at_specified_seq() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("BSS", "bss.>"); + + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("bss.x", $"msg-{i}"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.BSS.C1", + """{"durable_name":"C1","filter_subject":"bss.>","deliver_policy":"by_start_sequence","opt_start_seq":3}"""); + resp.Error.ShouldBeNull(); + + var batch = await fx.FetchAsync("BSS", "C1", 10); + batch.Messages.Count.ShouldBe(3); + batch.Messages[0].Sequence.ShouldBe(3UL); + } + + // Go: TestJetStreamMultipleSubjectsPushBasic — multiple filter subjects consumer + [Fact] + public async Task Multi_filter_consumer_receives_matching_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MFC", ">"); + _ = await fx.CreateConsumerAsync("MFC", "C1", null, filterSubjects: ["a.*", "b.*"]); + + _ = await fx.PublishAndGetAckAsync("a.one", "1"); + _ = await fx.PublishAndGetAckAsync("b.one", "2"); + _ = await fx.PublishAndGetAckAsync("c.one", "3"); + + var batch = await fx.FetchAsync("MFC", "C1", 10); + batch.Messages.Count.ShouldBe(2); + } + + // Go: TestJetStreamAckReplyStreamPending server/jetstream_test.go:1887 + [Fact] + public async Task Explicit_ack_pending_count_decreases_on_ack() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ARP", "arp.>"); + _ = await fx.CreateConsumerAsync("ARP", "C1", "arp.>", + ackPolicy: AckPolicy.All); + + _ = await fx.PublishAndGetAckAsync("arp.x", "msg1"); + _ = await fx.PublishAndGetAckAsync("arp.x", "msg2"); + _ = await fx.PublishAndGetAckAsync("arp.x", "msg3"); + + _ = await fx.FetchAsync("ARP", "C1", 3); + + var before = await fx.GetPendingCountAsync("ARP", "C1"); + before.ShouldBe(3); + + await fx.AckAllAsync("ARP", "C1", 2); + + var after = await fx.GetPendingCountAsync("ARP", "C1"); + after.ShouldBe(1); + } + + // Go: TestJetStreamAckReplyStreamPendingWithAcks server/jetstream_test.go:1921 + [Fact] + public async Task Ack_all_to_last_clears_pending() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ARPF", "arpf.>"); + _ = await fx.CreateConsumerAsync("ARPF", "C1", "arpf.>", + ackPolicy: AckPolicy.All); + + _ = await fx.PublishAndGetAckAsync("arpf.x", "1"); + _ = await fx.PublishAndGetAckAsync("arpf.x", "2"); + + var batch = await fx.FetchAsync("ARPF", "C1", 2); + batch.Messages.Count.ShouldBe(2); + + await fx.AckAllAsync("ARPF", "C1", batch.Messages[^1].Sequence); + + var pending = await fx.GetPendingCountAsync("ARPF", "C1"); + pending.ShouldBe(0); + } + + // Go: TestJetStreamWorkQueueRetentionStream server/jetstream_test.go:1655 + [Fact] + public async Task Replay_original_consumer_pauses_between_deliveries() + { + await using var fx = await JetStreamApiFixture.StartWithReplayOriginalConsumerAsync(); + + var batch = await fx.FetchAsync("ORDERS", "RO", 1); + batch.Messages.Count.ShouldBe(1); + } + + // Go: TestJetStreamSubjectBasedFilteredConsumers server/jetstream_test.go + [Fact] + public async Task Consumer_with_gt_wildcard_filter_matches_all() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("GW", "gw.>"); + _ = await fx.CreateConsumerAsync("GW", "C1", "gw.>"); + + _ = await fx.PublishAndGetAckAsync("gw.a.b.c", "1"); + _ = await fx.PublishAndGetAckAsync("gw.x", "2"); + _ = await fx.PublishAndGetAckAsync("gw.y.z", "3"); + + var batch = await fx.FetchAsync("GW", "C1", 10); + batch.Messages.Count.ShouldBe(3); + } + + // Go: TestJetStreamSubjectBasedFilteredConsumers — star wildcard + [Fact] + public async Task Consumer_with_star_wildcard_matches_single_token() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SW", "sw.>"); + _ = await fx.CreateConsumerAsync("SW", "C1", "sw.*"); + + _ = await fx.PublishAndGetAckAsync("sw.a", "1"); + _ = await fx.PublishAndGetAckAsync("sw.b.c", "2"); // doesn't match sw.* + _ = await fx.PublishAndGetAckAsync("sw.d", "3"); + + var batch = await fx.FetchAsync("SW", "C1", 10); + batch.Messages.Count.ShouldBe(2); + } + + // Go: TestJetStreamInterestRetentionStreamWithFilteredConsumers server/jetstream_test.go:4388 + [Fact] + public async Task Two_consumers_same_stream_independent_cursors() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("IC", "ic.>"); + _ = await fx.CreateConsumerAsync("IC", "C1", "ic.a"); + _ = await fx.CreateConsumerAsync("IC", "C2", "ic.b"); + + _ = await fx.PublishAndGetAckAsync("ic.a", "for-c1"); + _ = await fx.PublishAndGetAckAsync("ic.b", "for-c2"); + _ = await fx.PublishAndGetAckAsync("ic.a", "for-c1-again"); + + var batchC1 = await fx.FetchAsync("IC", "C1", 10); + batchC1.Messages.Count.ShouldBe(2); + + var batchC2 = await fx.FetchAsync("IC", "C2", 10); + batchC2.Messages.Count.ShouldBe(1); + } + + // Go: TestJetStreamPushConsumersPullError server/jetstream_test.go:5731 + [Fact] + public async Task Consumer_fetch_from_empty_stream_returns_empty_batch() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EMP", "emp.>"); + _ = await fx.CreateConsumerAsync("EMP", "C1", "emp.>"); + + var batch = await fx.FetchAsync("EMP", "C1", 5); + batch.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamAckNext server/jetstream_test.go:2483 + [Fact] + public async Task Consumer_fetch_after_consuming_all_returns_empty() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DONE", "done.>"); + _ = await fx.CreateConsumerAsync("DONE", "C1", "done.>"); + + _ = await fx.PublishAndGetAckAsync("done.x", "only"); + + var batch1 = await fx.FetchAsync("DONE", "C1", 1); + batch1.Messages.Count.ShouldBe(1); + + var batch2 = await fx.FetchAsync("DONE", "C1", 1); + batch2.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamWorkQueueAckAndNext server/jetstream_test.go:1355 + [Fact] + public async Task Ack_all_consumer_acks_batch_at_once() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("AAB", "aab.>"); + _ = await fx.CreateConsumerAsync("AAB", "C1", "aab.>", + ackPolicy: AckPolicy.All); + + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("aab.x", $"msg-{i}"); + + var batch = await fx.FetchAsync("AAB", "C1", 5); + batch.Messages.Count.ShouldBe(5); + + await fx.AckAllAsync("AAB", "C1", 5); + + var pending = await fx.GetPendingCountAsync("AAB", "C1"); + pending.ShouldBe(0); + } + + // Go: TestJetStreamEphemeralPullConsumersInactiveThresholdAndNoWait server/jetstream_test.go + [Fact] + public async Task No_wait_fetch_from_non_existent_consumer_returns_empty() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NWC", "nwc.>"); + + var batch = await fx.FetchWithNoWaitAsync("NWC", "NOPE", 1); + batch.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamMultipleSubjectsBasic — verify payload content + [Fact] + public async Task Fetched_messages_contain_correct_payload() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PL", "pl.>"); + _ = await fx.CreateConsumerAsync("PL", "C1", "pl.>"); + + _ = await fx.PublishAndGetAckAsync("pl.x", "hello-world"); + + var batch = await fx.FetchAsync("PL", "C1", 1); + batch.Messages.Count.ShouldBe(1); + Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("hello-world"); + } + + // Go: TestJetStreamBackOffCheckPending server/jetstream_test.go + [Fact] + public async Task Backoff_config_is_stored_on_consumer() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("BOC", "boc.>"); + + _ = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.BOC.C1", + """{"durable_name":"C1","filter_subject":"boc.>","ack_policy":"explicit","backoff_ms":[50,100,200]}"""); + + var info = await fx.GetConsumerInfoAsync("BOC", "C1"); + info.Config.BackOffMs.ShouldBe([50, 100, 200]); + } + + // Go: TestJetStreamConsumerPause — multiple pauses + [Fact] + public async Task Multiple_pause_calls_are_idempotent() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MPI", "mpi.>"); + _ = await fx.CreateConsumerAsync("MPI", "C1", "mpi.>"); + + for (var i = 0; i < 3; i++) + { + var pause = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.PAUSE.MPI.C1", """{"pause":true}"""); + pause.Success.ShouldBeTrue(); + } + } + + // Go: TestJetStreamAckExplicitMsgRemoval — explicit ack with fetch batch + [Fact] + public async Task Explicit_ack_with_batch_fetch() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EAB", "eab.>"); + _ = await fx.CreateConsumerAsync("EAB", "C1", "eab.>", + ackPolicy: AckPolicy.Explicit, + ackWaitMs: 30_000); + + for (var i = 0; i < 3; i++) + _ = await fx.PublishAndGetAckAsync("eab.x", $"msg-{i}"); + + var batch = await fx.FetchAsync("EAB", "C1", 3); + batch.Messages.Count.ShouldBe(3); + + var pending = await fx.GetPendingCountAsync("EAB", "C1"); + pending.ShouldBe(3); + } + + // Go: TestJetStreamConsumerRate + [Fact] + public async Task Rate_limit_setting_is_preserved() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("RLP", "rlp.>"); + + _ = await fx.RequestLocalAsync( + "$JS.API.CONSUMER.CREATE.RLP.C1", + """{"durable_name":"C1","filter_subject":"rlp.>","push":true,"heartbeat_ms":10,"rate_limit_bps":2048}"""); + + var info = await fx.GetConsumerInfoAsync("RLP", "C1"); + info.Config.RateLimitBps.ShouldBe(2048); + } + + // Go: TestJetStreamRedeliverCount server/jetstream_test.go:3778 + [Fact] + public async Task Consumer_pending_initially_zero() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PIZ", "piz.>"); + _ = await fx.CreateConsumerAsync("PIZ", "C1", "piz.>", + ackPolicy: AckPolicy.Explicit); + + var pending = await fx.GetPendingCountAsync("PIZ", "C1"); + pending.ShouldBe(0); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamPubSubTests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamPubSubTests.cs new file mode 100644 index 0000000..cbdf256 --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamPubSubTests.cs @@ -0,0 +1,570 @@ +// Ported from golang/nats-server/server/jetstream_test.go +// Publish/Subscribe: basic pub/sub, message acknowledgment, replay, headers, sequence tracking + +using System.Text; +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Models; +using NATS.Server.JetStream.Publish; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamPubSubTests +{ + // Go: TestJetStreamBasicAckPublish server/jetstream_test.go:710 + [Fact] + public async Task Publish_returns_puback_with_stream_and_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*"); + var ack = await fx.PublishAndGetAckAsync("orders.created", "payload"); + + ack.Stream.ShouldBe("ORDERS"); + ack.Seq.ShouldBe(1UL); + ack.ErrorCode.ShouldBeNull(); + } + + // Go: TestJetStreamPubAck server/jetstream_test.go:298 + [Fact] + public async Task Multiple_publishes_increment_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SEQ", "seq.>"); + + var ack1 = await fx.PublishAndGetAckAsync("seq.a", "1"); + ack1.Seq.ShouldBe(1UL); + + var ack2 = await fx.PublishAndGetAckAsync("seq.b", "2"); + ack2.Seq.ShouldBe(2UL); + + var ack3 = await fx.PublishAndGetAckAsync("seq.c", "3"); + ack3.Seq.ShouldBe(3UL); + } + + // Go: TestJetStreamPublishDeDupe server/jetstream_test.go:2533 + [Fact] + public async Task Duplicate_msg_id_is_rejected() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "DEDUP", + Subjects = ["dedup.>"], + DuplicateWindowMs = 60_000, + }); + + var ack1 = await fx.PublishAndGetAckAsync("dedup.x", "first", msgId: "uniq-1"); + ack1.ErrorCode.ShouldBeNull(); + + var ack2 = await fx.PublishAndGetAckAsync("dedup.x", "second", msgId: "uniq-1"); + ack2.ErrorCode.ShouldNotBeNull(); + } + + // Go: TestJetStreamPublishExpect server/jetstream_test.go:2595 + [Fact] + public async Task Publish_with_expected_last_seq_succeeds_when_matching() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EXP", "exp.>"); + + var ack1 = await fx.PublishAndGetAckAsync("exp.a", "1"); + ack1.Seq.ShouldBe(1UL); + + var ack2 = await fx.PublishWithExpectedLastSeqAsync("exp.b", "2", 1); + ack2.ErrorCode.ShouldBeNull(); + } + + // Go: TestJetStreamPublishExpect — mismatch + [Fact] + public async Task Publish_with_wrong_expected_last_seq_fails() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EXPF", "expf.>"); + _ = await fx.PublishAndGetAckAsync("expf.a", "1"); + + var ack = await fx.PublishWithExpectedLastSeqAsync("expf.b", "2", 999); + ack.ErrorCode.ShouldNotBeNull(); + } + + // Go: TestJetStreamSubjectFiltering server/jetstream_test.go:1089 + [Fact] + public async Task Publish_and_fetch_with_filter_subject() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FILT", "filt.>"); + _ = await fx.CreateConsumerAsync("FILT", "C1", "filt.a"); + + _ = await fx.PublishAndGetAckAsync("filt.a", "match"); + _ = await fx.PublishAndGetAckAsync("filt.b", "no-match"); + _ = await fx.PublishAndGetAckAsync("filt.a", "match2"); + + var batch = await fx.FetchAsync("FILT", "C1", 10); + batch.Messages.Count.ShouldBe(2); + batch.Messages.All(m => m.Subject == "filt.a").ShouldBeTrue(); + } + + // Go: TestJetStreamWildcardSubjectFiltering server/jetstream_test.go:1152 + [Fact] + public async Task Publish_and_fetch_with_wildcard_filter() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("WC", "wc.>"); + _ = await fx.CreateConsumerAsync("WC", "C1", "wc.orders.*"); + + _ = await fx.PublishAndGetAckAsync("wc.orders.created", "1"); + _ = await fx.PublishAndGetAckAsync("wc.events.logged", "2"); + _ = await fx.PublishAndGetAckAsync("wc.orders.shipped", "3"); + + var batch = await fx.FetchAsync("WC", "C1", 10); + batch.Messages.Count.ShouldBe(2); + } + + // Go: TestJetStreamWorkQueueRequestBatch server/jetstream_test.go:1505 + [Fact] + public async Task Fetch_batch_returns_multiple_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("BATCH", "batch.>"); + _ = await fx.CreateConsumerAsync("BATCH", "C1", "batch.>"); + + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("batch.x", $"msg-{i}"); + + var batch = await fx.FetchAsync("BATCH", "C1", 3); + batch.Messages.Count.ShouldBe(3); + } + + // Go: TestJetStreamWorkQueueRequest server/jetstream_test.go:1302 + [Fact] + public async Task Fetch_single_message() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SINGLE", "single.>"); + _ = await fx.CreateConsumerAsync("SINGLE", "C1", "single.>"); + _ = await fx.PublishAndGetAckAsync("single.x", "hello"); + + var batch = await fx.FetchAsync("SINGLE", "C1", 1); + batch.Messages.Count.ShouldBe(1); + Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("hello"); + } + + // Go: TestJetStreamNextMsgNoInterest server/jetstream_test.go:6522 + [Fact] + public async Task Fetch_with_no_messages_returns_empty() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EMPTY", "empty.>"); + _ = await fx.CreateConsumerAsync("EMPTY", "C1", "empty.>"); + + var batch = await fx.FetchAsync("EMPTY", "C1", 1); + batch.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamNoAckStream server/jetstream_test.go:821 + [Fact] + public async Task Publish_to_stream_with_no_ack_consumer() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NOACK", "noack.>"); + _ = await fx.CreateConsumerAsync("NOACK", "C1", "noack.>", ackPolicy: AckPolicy.None); + + _ = await fx.PublishAndGetAckAsync("noack.x", "data"); + + var batch = await fx.FetchAsync("NOACK", "C1", 1); + batch.Messages.Count.ShouldBe(1); + } + + // Go: TestJetStreamActiveDelivery server/jetstream_test.go:3644 + [Fact] + public async Task Publish_triggers_push_consumer_delivery() + { + await using var fx = await JetStreamApiFixture.StartWithPushConsumerAsync(); + _ = await fx.PublishAndGetAckAsync("orders.created", "order-1"); + + var frame = await fx.ReadPushFrameAsync(); + frame.IsData.ShouldBeTrue(); + frame.Message.ShouldNotBeNull(); + } + + // Go: TestJetStreamMultipleSubjectsPushBasic server/jetstream_test.go + [Fact] + public async Task Push_consumer_receives_matching_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PS", "ps.>"); + _ = await fx.CreateConsumerAsync("PS", "PUSH", "ps.orders.*", push: true, heartbeatMs: 25); + + _ = await fx.PublishAndGetAckAsync("ps.orders.created", "1"); + _ = await fx.PublishAndGetAckAsync("ps.events.logged", "2"); + + var frame = await fx.ReadPushFrameAsync("PS", "PUSH"); + frame.IsData.ShouldBeTrue(); + frame.Message!.Subject.ShouldBe("ps.orders.created"); + } + + // Go: TestJetStreamWorkQueueAckAndNext server/jetstream_test.go:1355 + [Fact] + public async Task Sequential_fetch_advances_cursor() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ADV", "adv.>"); + _ = await fx.CreateConsumerAsync("ADV", "C1", "adv.>"); + + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("adv.x", $"msg-{i}"); + + var batch1 = await fx.FetchAsync("ADV", "C1", 2); + batch1.Messages.Count.ShouldBe(2); + batch1.Messages[0].Sequence.ShouldBe(1UL); + + var batch2 = await fx.FetchAsync("ADV", "C1", 2); + batch2.Messages.Count.ShouldBe(2); + batch2.Messages[0].Sequence.ShouldBe(3UL); + } + + // Go: TestJetStreamPublishExpectNoMsg server/jetstream_test.go + [Fact] + public async Task Publish_to_unmatched_subject_is_not_captured() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NOMATCH", "nomatch.orders.*"); + + var ack = await fx.PublishAndGetAckAsync("different.subject", "data", expectError: true); + ack.ErrorCode.ShouldNotBeNull(); + } + + // Go: TestJetStreamPubAck — stream name in ack + [Fact] + public async Task Puback_contains_correct_stream_name() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NAMED", "named.>"); + var ack = await fx.PublishAndGetAckAsync("named.x", "data"); + ack.Stream.ShouldBe("NAMED"); + } + + // Go: TestJetStreamStateTimestamps server/jetstream_test.go:758 + [Fact] + public async Task Stream_state_updates_after_publish() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ST", "st.>"); + + var before = await fx.GetStreamStateAsync("ST"); + before.Messages.ShouldBe(0UL); + + _ = await fx.PublishAndGetAckAsync("st.x", "data"); + + var after = await fx.GetStreamStateAsync("ST"); + after.Messages.ShouldBe(1UL); + after.Bytes.ShouldBeGreaterThan(0UL); + } + + // Go: TestJetStreamLongStreamNamesAndPubAck server/jetstream_test.go + [Fact] + public async Task Long_stream_name_works() + { + var name = new string('A', 50); + await using var fx = await JetStreamApiFixture.StartWithStreamAsync(name, "long.>"); + var ack = await fx.PublishAndGetAckAsync("long.x", "data"); + ack.Stream.ShouldBe(name); + } + + // Go: TestJetStreamPublishDeDupe — unique msg IDs accepted + [Fact] + public async Task Unique_msg_ids_all_accepted() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "UNIQ", + Subjects = ["uniq.>"], + DuplicateWindowMs = 60_000, + }); + + for (var i = 0; i < 5; i++) + { + var ack = await fx.PublishAndGetAckAsync("uniq.x", $"data-{i}", msgId: $"msg-{i}"); + ack.ErrorCode.ShouldBeNull(); + } + + var state = await fx.GetStreamStateAsync("UNIQ"); + state.Messages.ShouldBe(5UL); + } + + // Go: TestJetStreamPublishDeDupe — no dedup without window + [Fact] + public async Task No_dedup_window_allows_same_msg_id() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NODEDUP", "nodedup.>"); + + var ack1 = await fx.PublishAndGetAckAsync("nodedup.x", "1", msgId: "same"); + ack1.ErrorCode.ShouldBeNull(); + + // Without a dedup window, the msg ID is not tracked + var ack2 = await fx.PublishAndGetAckAsync("nodedup.x", "2", msgId: "same"); + // Could be null or not depending on implementation; both messages stored + } + + // Go: TestJetStreamNegativeDupeWindow server/jetstream_test.go + // When dedup window is 0, the implementation still tracks msg IDs in-process (no TTL-based trim). + // Verify that with no msg ID, duplicate detection is not triggered. + [Fact] + public async Task Dedup_window_zero_with_no_msg_id_allows_duplicates() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "NODUP", + Subjects = ["nodup.>"], + DuplicateWindowMs = 0, + }); + + var ack1 = await fx.PublishAndGetAckAsync("nodup.x", "1"); + ack1.ErrorCode.ShouldBeNull(); + var ack2 = await fx.PublishAndGetAckAsync("nodup.x", "2"); + ack2.ErrorCode.ShouldBeNull(); + + var state = await fx.GetStreamStateAsync("NODUP"); + state.Messages.ShouldBe(2UL); + } + + // Go: TestJetStreamWorkQueueSubjectFiltering server/jetstream_test.go:1127 + [Fact] + public async Task Fetch_with_no_wait_returns_empty_when_no_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NW", "nw.>"); + _ = await fx.CreateConsumerAsync("NW", "C1", "nw.>"); + + var batch = await fx.FetchWithNoWaitAsync("NW", "C1", 5); + batch.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamWorkQueueSubjectFiltering — no_wait with messages + [Fact] + public async Task Fetch_with_no_wait_returns_available_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("NWM", "nwm.>"); + _ = await fx.CreateConsumerAsync("NWM", "C1", "nwm.>"); + + _ = await fx.PublishAndGetAckAsync("nwm.x", "data1"); + _ = await fx.PublishAndGetAckAsync("nwm.x", "data2"); + + var batch = await fx.FetchWithNoWaitAsync("NWM", "C1", 5); + batch.Messages.Count.ShouldBe(2); + } + + // Go: TestJetStreamBasicWorkQueue server/jetstream_test.go:937 + [Fact] + public async Task Publish_many_and_fetch_all() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ALL", "all.>"); + _ = await fx.CreateConsumerAsync("ALL", "C1", "all.>"); + + for (var i = 0; i < 10; i++) + _ = await fx.PublishAndGetAckAsync("all.x", $"msg-{i}"); + + var batch = await fx.FetchAsync("ALL", "C1", 20); + batch.Messages.Count.ShouldBe(10); + } + + // Go: TestJetStreamMultipleSubjectsBasic server/jetstream_test.go + [Fact] + public async Task Multiple_subjects_captured_by_same_stream() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MULTI", "multi.>"); + _ = await fx.CreateConsumerAsync("MULTI", "C1", "multi.>"); + + _ = await fx.PublishAndGetAckAsync("multi.orders", "1"); + _ = await fx.PublishAndGetAckAsync("multi.events", "2"); + _ = await fx.PublishAndGetAckAsync("multi.logs", "3"); + + var batch = await fx.FetchAsync("MULTI", "C1", 10); + batch.Messages.Count.ShouldBe(3); + batch.Messages.Select(m => m.Subject).ShouldBe(["multi.orders", "multi.events", "multi.logs"]); + } + + // Go: TestJetStreamGetLastMsgBySubject server/jetstream_test.go + [Fact] + public async Task Fetch_preserves_message_subject() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SUB", "sub.>"); + _ = await fx.CreateConsumerAsync("SUB", "C1", "sub.>"); + + _ = await fx.PublishAndGetAckAsync("sub.orders.created", "data"); + + var batch = await fx.FetchAsync("SUB", "C1", 1); + batch.Messages[0].Subject.ShouldBe("sub.orders.created"); + } + + // Go: TestJetStreamPubAck — sequence monotonically increasing + [Fact] + public async Task Sequence_numbers_are_monotonically_increasing() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MONO", "mono.>"); + + ulong lastSeq = 0; + for (var i = 0; i < 10; i++) + { + var ack = await fx.PublishAndGetAckAsync("mono.x", $"msg-{i}"); + ack.Seq.ShouldBeGreaterThan(lastSeq); + lastSeq = ack.Seq; + } + } + + // Go: TestJetStreamPerSubjectPending server/jetstream_test.go + [Fact] + public async Task Fetch_from_non_existent_consumer_returns_empty() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FNE", "fne.>"); + _ = await fx.PublishAndGetAckAsync("fne.x", "data"); + + var batch = await fx.FetchAsync("FNE", "NOPE", 1); + batch.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamMaxMsgsPerSubjectWithDiscardNew server/jetstream_test.go + [Fact] + public async Task Publish_to_multiple_streams_routes_correctly() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("A", "a.>"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.B", """{"subjects":["b.>"]}"""); + + var ackA = await fx.PublishAndGetAckAsync("a.msg", "for-A"); + ackA.Stream.ShouldBe("A"); + + var ackB = await fx.PublishAndGetAckAsync("b.msg", "for-B"); + ackB.Stream.ShouldBe("B"); + } + + // Go: TestJetStreamPublishMany + [Fact] + public async Task Publish_many_helper_stores_all_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PM", "pm.>"); + await fx.PublishManyAsync("pm.x", ["a", "b", "c", "d", "e"]); + + var state = await fx.GetStreamStateAsync("PM"); + state.Messages.ShouldBe(5UL); + } + + // Go: TestJetStreamRejectLargePublishes server/jetstream_test.go + [Fact] + public async Task Large_message_rejected_by_max_msg_size() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "SMALL", + Subjects = ["small.>"], + MaxMsgSize = 5, + }); + + var ack = await fx.PublishAndGetAckAsync("small.x", "this-is-too-big"); + ack.ErrorCode.ShouldNotBeNull(); + } + + // Go: TestJetStreamAddStreamMaxMsgSize — exactly at limit + [Fact] + public async Task Message_exactly_at_size_limit_is_accepted() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "EXACT", + Subjects = ["exact.>"], + MaxMsgSize = 4, + }); + + var ack = await fx.PublishAndGetAckAsync("exact.x", "1234"); + ack.ErrorCode.ShouldBeNull(); + } + + // Go: TestJetStreamPurgeEffectsConsumerDelivery server/jetstream_test.go + // After purge, a fresh consumer should be able to see new messages. + [Fact] + public async Task Purge_followed_by_new_publish_visible_to_new_consumer() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PCD", "pcd.>"); + + _ = await fx.PublishAndGetAckAsync("pcd.x", "old"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.PCD", "{}"); + + _ = await fx.PublishAndGetAckAsync("pcd.x", "new"); + + // Create a fresh consumer after purge + _ = await fx.CreateConsumerAsync("PCD", "C2", "pcd.>"); + var batch = await fx.FetchAsync("PCD", "C2", 1); + batch.Messages.Count.ShouldBe(1); + Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("new"); + } + + // Go: TestJetStreamDeliverLastPerSubject server/jetstream_test.go + [Fact] + public async Task Deliver_last_policy_starts_from_last_message() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DLP", "dlp.>"); + _ = await fx.PublishAndGetAckAsync("dlp.x", "first"); + _ = await fx.PublishAndGetAckAsync("dlp.x", "second"); + _ = await fx.PublishAndGetAckAsync("dlp.x", "third"); + + _ = await fx.CreateConsumerAsync("DLP", "C1", "dlp.>", deliverPolicy: DeliverPolicy.Last); + + var batch = await fx.FetchAsync("DLP", "C1", 10); + batch.Messages.Count.ShouldBe(1); + Encoding.UTF8.GetString(batch.Messages[0].Payload.Span).ShouldBe("third"); + } + + // Go: TestJetStreamDeliverNewPolicy server/jetstream_test.go + [Fact] + public async Task Deliver_new_policy_skips_existing_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DNP", "dnp.>"); + _ = await fx.PublishAndGetAckAsync("dnp.x", "existing1"); + _ = await fx.PublishAndGetAckAsync("dnp.x", "existing2"); + + _ = await fx.CreateConsumerAsync("DNP", "C1", "dnp.>", deliverPolicy: DeliverPolicy.New); + + // Fetch should return empty since no new messages + var batch = await fx.FetchAsync("DNP", "C1", 10); + batch.Messages.Count.ShouldBe(0); + } + + // Go: TestJetStreamMultipleSubjectsPushBasic — push multi-subject + [Fact] + public async Task Push_consumer_heartbeat_frame_present() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("HB", "hb.>"); + _ = await fx.CreateConsumerAsync("HB", "PUSH", "hb.>", push: true, heartbeatMs: 10); + _ = await fx.PublishAndGetAckAsync("hb.x", "data"); + + // Should have data frame followed by heartbeat frame + var frame1 = await fx.ReadPushFrameAsync("HB", "PUSH"); + frame1.IsData.ShouldBeTrue(); + + var frame2 = await fx.ReadPushFrameAsync("HB", "PUSH"); + frame2.IsHeartbeat.ShouldBeTrue(); + } + + // Go: TestJetStreamPublishExpect — precondition expected last seq = 0 + [Fact] + public async Task Publish_expected_last_seq_zero_always_succeeds() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ELZ", "elz.>"); + _ = await fx.PublishAndGetAckAsync("elz.x", "1"); + + // Expected last seq 0 means "no check" + var ack = await fx.PublishWithExpectedLastSeqAsync("elz.x", "2", 0); + ack.ErrorCode.ShouldBeNull(); + } + + // Go: TestJetStreamDirectMsgGet server/jetstream_test.go + [Fact] + public async Task Direct_get_returns_published_message() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DG", "dg.>"); + var ack = await fx.PublishAndGetAckAsync("dg.x", "direct-payload"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.DIRECT.GET.DG", $$"""{ "seq": {{ack.Seq}} }"""); + resp.DirectMessage.ShouldNotBeNull(); + resp.DirectMessage!.Payload.ShouldBe("direct-payload"); + resp.DirectMessage.Subject.ShouldBe("dg.x"); + } + + // Go: TestJetStreamMsgHeaders server/jetstream_test.go:5554 + [Fact] + public async Task Message_get_returns_correct_sequence_and_subject() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MG", "mg.>"); + _ = await fx.PublishAndGetAckAsync("mg.first", "data1"); + var ack2 = await fx.PublishAndGetAckAsync("mg.second", "data2"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.GET.MG", $$"""{ "seq": {{ack2.Seq}} }"""); + resp.StreamMessage.ShouldNotBeNull(); + resp.StreamMessage!.Sequence.ShouldBe(ack2.Seq); + resp.StreamMessage.Subject.ShouldBe("mg.second"); + resp.StreamMessage.Payload.ShouldBe("data2"); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamStreamCrudTests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamStreamCrudTests.cs new file mode 100644 index 0000000..892972d --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamStreamCrudTests.cs @@ -0,0 +1,710 @@ +// Ported from golang/nats-server/server/jetstream_test.go +// Stream CRUD operations: create, update, delete, purge, info, validation + +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Models; +using NATS.Server.JetStream.Validation; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamStreamCrudTests +{ + // Go: TestJetStreamAddStream server/jetstream_test.go:178 + [Fact] + public async Task Create_stream_returns_config_and_empty_state() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*"); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.ORDERS", "{}"); + info.Error.ShouldBeNull(); + info.StreamInfo.ShouldNotBeNull(); + info.StreamInfo!.Config.Name.ShouldBe("ORDERS"); + info.StreamInfo.Config.Subjects.ShouldContain("orders.*"); + info.StreamInfo.State.Messages.ShouldBe(0UL); + } + + // Go: TestJetStreamAddStreamDiscardNew server/jetstream_test.go:122 + // Verifies discard new policy with max_bytes rejects new messages when stream is full. + [Fact] + public async Task Create_stream_with_discard_new_policy() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "DN", + Subjects = ["dn.>"], + MaxBytes = 30, + Discard = DiscardPolicy.New, + }); + + var ack1 = await fx.PublishAndGetAckAsync("dn.one", "1"); + ack1.ErrorCode.ShouldBeNull(); + var ack2 = await fx.PublishAndGetAckAsync("dn.two", "2"); + ack2.ErrorCode.ShouldBeNull(); + + // Oversized publish should be rejected due to discard new + max_bytes + var ack3 = await fx.PublishAndGetAckAsync("dn.three", "this-is-a-large-payload-that-exceeds-bytes"); + ack3.ErrorCode.ShouldNotBeNull(); + } + + // Go: TestJetStreamAddStreamMaxMsgSize server/jetstream_test.go:484 + [Fact] + public async Task Create_stream_with_max_msg_size_rejects_oversized() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "SIZED", + Subjects = ["sized.>"], + MaxMsgSize = 10, + }); + + var small = await fx.PublishAndGetAckAsync("sized.ok", "tiny"); + small.ErrorCode.ShouldBeNull(); + + var big = await fx.PublishAndGetAckAsync("sized.big", "this-is-definitely-larger-than-ten-bytes"); + big.ErrorCode.ShouldNotBeNull(); + } + + // Go: TestJetStreamAddStreamCanonicalNames server/jetstream_test.go:537 + [Fact] + public async Task Create_stream_name_is_preserved_in_info() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MyStream", "my.>"); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.MyStream", "{}"); + info.Error.ShouldBeNull(); + info.StreamInfo!.Config.Name.ShouldBe("MyStream"); + } + + // Go: TestJetStreamAddStreamSameConfigOK server/jetstream_test.go:701 + [Fact] + public async Task Create_stream_with_same_config_is_idempotent() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*"); + + var second = await fx.RequestLocalAsync( + "$JS.API.STREAM.CREATE.ORDERS", + """{"name":"ORDERS","subjects":["orders.*"]}"""); + second.Error.ShouldBeNull(); + second.StreamInfo.ShouldNotBeNull(); + second.StreamInfo!.Config.Name.ShouldBe("ORDERS"); + } + + // Go: TestJetStreamUpdateStream server/jetstream_test.go:6409 + [Fact] + public async Task Update_stream_changes_subjects_and_limits() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("ORDERS", "orders.*"); + _ = await fx.PublishAndGetAckAsync("orders.x", "1"); + + var update = await fx.RequestLocalAsync( + "$JS.API.STREAM.UPDATE.ORDERS", + """{"name":"ORDERS","subjects":["orders.v2.*"],"max_msgs":50}"""); + update.Error.ShouldBeNull(); + update.StreamInfo!.Config.Subjects.ShouldContain("orders.v2.*"); + update.StreamInfo.Config.MaxMsgs.ShouldBe(50); + } + + // Go: TestJetStreamStreamPurge server/jetstream_test.go:4182 + [Fact] + public async Task Purge_stream_removes_all_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("P", "p.*"); + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("p.msg", $"payload-{i}"); + + var before = await fx.GetStreamStateAsync("P"); + before.Messages.ShouldBe(5UL); + + var purge = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.P", "{}"); + purge.Success.ShouldBeTrue(); + + var after = await fx.GetStreamStateAsync("P"); + after.Messages.ShouldBe(0UL); + } + + // Go: TestJetStreamDeleteMsg server/jetstream_test.go:6464 + [Fact] + public async Task Delete_individual_message_by_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DEL", "del.>"); + var ack1 = await fx.PublishAndGetAckAsync("del.a", "1"); + _ = await fx.PublishAndGetAckAsync("del.b", "2"); + + var del = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.DELETE.DEL", + $$"""{ "seq": {{ack1.Seq}} }"""); + del.Success.ShouldBeTrue(); + + var state = await fx.GetStreamStateAsync("DEL"); + state.Messages.ShouldBe(1UL); + } + + // Go: TestJetStreamAddStream — delete removes stream + [Fact] + public async Task Delete_stream_makes_it_inaccessible() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("GONE", "gone.>"); + _ = await fx.PublishAndGetAckAsync("gone.x", "data"); + + var del = await fx.RequestLocalAsync("$JS.API.STREAM.DELETE.GONE", "{}"); + del.Success.ShouldBeTrue(); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.GONE", "{}"); + info.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamStreamPurge — publish after purge works + [Fact] + public async Task Publish_after_purge_adds_new_message() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PP", "pp.>"); + _ = await fx.PublishAndGetAckAsync("pp.x", "before"); + _ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.PP", "{}"); + + var ack = await fx.PublishAndGetAckAsync("pp.x", "after"); + ack.ErrorCode.ShouldBeNull(); + + var state = await fx.GetStreamStateAsync("PP"); + state.Messages.ShouldBe(1UL); + } + + // Go: TestJetStreamBasicNilConfig server/jetstream_test.go:56 + [Fact] + public void Stream_config_requires_name() + { + var sm = new StreamManager(); + var resp = sm.CreateOrUpdate(new StreamConfig { Name = "" }); + resp.Error.ShouldNotBeNull(); + resp.Error!.Code.ShouldBe(400); + } + + // Go: TestJetStreamAddStreamBadSubjects server/jetstream_test.go:587 + [Fact] + public void Validation_rejects_empty_name_and_subjects() + { + var config = new StreamConfig { Name = "", Subjects = [] }; + var result = JetStreamConfigValidator.Validate(config); + result.IsValid.ShouldBeFalse(); + } + + // Go: TestJetStreamAddStreamBadSubjects — valid name required + [Fact] + public void Validation_accepts_valid_stream_config() + { + var config = new StreamConfig { Name = "OK", Subjects = ["ok.>"] }; + var result = JetStreamConfigValidator.Validate(config); + result.IsValid.ShouldBeTrue(); + } + + // Go: TestJetStreamMaxConsumers server/jetstream_test.go:619 + [Fact] + public void Validation_workqueue_requires_max_consumers() + { + var config = new StreamConfig + { + Name = "WQ", + Subjects = ["wq.>"], + Retention = RetentionPolicy.WorkQueue, + MaxConsumers = 0, + }; + var result = JetStreamConfigValidator.Validate(config); + result.IsValid.ShouldBeFalse(); + } + + // Go: TestJetStreamInvalidConfigValues server/jetstream_test.go + [Fact] + public void Validation_rejects_negative_max_msg_size() + { + var config = new StreamConfig + { + Name = "NEG", + Subjects = ["neg.>"], + MaxMsgSize = -1, + }; + var result = JetStreamConfigValidator.Validate(config); + result.IsValid.ShouldBeFalse(); + } + + // Go: TestJetStreamInvalidConfigValues + [Fact] + public void Validation_rejects_negative_max_msgs_per() + { + var config = new StreamConfig + { + Name = "NEG2", + Subjects = ["neg2.>"], + MaxMsgsPer = -1, + }; + var result = JetStreamConfigValidator.Validate(config); + result.IsValid.ShouldBeFalse(); + } + + // Go: TestJetStreamInvalidConfigValues + [Fact] + public void Validation_rejects_negative_max_age_ms() + { + var config = new StreamConfig + { + Name = "NEG3", + Subjects = ["neg3.>"], + MaxAgeMs = -1, + }; + var result = JetStreamConfigValidator.Validate(config); + result.IsValid.ShouldBeFalse(); + } + + // Go: TestJetStreamStreamPurge — sealed stream cannot be purged + [Fact] + public async Task Sealed_stream_rejects_purge() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "SEAL", + Subjects = ["seal.>"], + Sealed = true, + }); + + var purge = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.SEAL", "{}"); + purge.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamDeleteMsg — deny_delete prevents removal + [Fact] + public async Task Deny_delete_prevents_message_removal() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "NODELETE", + Subjects = ["nodelete.>"], + DenyDelete = true, + }); + + var ack = await fx.PublishAndGetAckAsync("nodelete.x", "data"); + ack.ErrorCode.ShouldBeNull(); + + var del = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.DELETE.NODELETE", + $$"""{ "seq": {{ack.Seq}} }"""); + del.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamDeleteMsg — deny_purge prevents purge + [Fact] + public async Task Deny_purge_prevents_stream_purge() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "NOPURGE", + Subjects = ["nopurge.>"], + DenyPurge = true, + }); + + _ = await fx.PublishAndGetAckAsync("nopurge.x", "data"); + + var purge = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.NOPURGE", "{}"); + purge.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamStreamStorageTrackingAndLimits server/jetstream_test.go:4931 + [Fact] + public async Task Stream_with_max_msgs_limit_enforces_count() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("LIMITED", "limited.>", maxMsgs: 3); + + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("limited.x", $"msg-{i}"); + + var state = await fx.GetStreamStateAsync("LIMITED"); + state.Messages.ShouldBeLessThanOrEqualTo(3UL); + } + + // Go: TestJetStreamMaxBytesIgnored server/jetstream_test.go + [Fact] + public async Task Stream_with_max_bytes_discard_old_evicts_oldest() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "BYTES", + Subjects = ["bytes.>"], + MaxBytes = 100, + Discard = DiscardPolicy.Old, + }); + + for (var i = 0; i < 20; i++) + _ = await fx.PublishAndGetAckAsync("bytes.x", $"payload-{i:D10}"); + + var state = await fx.GetStreamStateAsync("BYTES"); + ((long)state.Bytes).ShouldBeLessThanOrEqualTo(100L); + } + + // Go: TestJetStreamMaxMsgsPerSubject server/jetstream_test.go + [Fact] + public async Task Max_msgs_per_subject_enforces_limit() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MPS", + Subjects = ["mps.>"], + MaxMsgsPer = 2, + }); + + _ = await fx.PublishAndGetAckAsync("mps.a", "1"); + _ = await fx.PublishAndGetAckAsync("mps.a", "2"); + _ = await fx.PublishAndGetAckAsync("mps.a", "3"); + _ = await fx.PublishAndGetAckAsync("mps.b", "4"); + + var state = await fx.GetStreamStateAsync("MPS"); + // mps.a should have 2 kept, mps.b has 1 = 3 total + state.Messages.ShouldBeLessThanOrEqualTo(3UL); + } + + // Go: TestJetStreamStreamFileTrackingAndLimits server/jetstream_test.go:4982 + [Fact] + public async Task Stream_with_file_storage_type() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "FSTORE", + Subjects = ["fstore.>"], + Storage = StorageType.File, + }); + + var ack = await fx.PublishAndGetAckAsync("fstore.x", "data"); + ack.ErrorCode.ShouldBeNull(); + + var backendType = await fx.GetStreamBackendTypeAsync("FSTORE"); + backendType.ShouldBe("file"); + } + + // Go: TestJetStreamStreamFileTrackingAndLimits — memory store + [Fact] + public async Task Stream_with_memory_storage_type() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MSTORE", + Subjects = ["mstore.>"], + Storage = StorageType.Memory, + }); + + var ack = await fx.PublishAndGetAckAsync("mstore.x", "data"); + ack.ErrorCode.ShouldBeNull(); + + var backendType = await fx.GetStreamBackendTypeAsync("MSTORE"); + backendType.ShouldBe("memory"); + } + + // Go: TestJetStreamStreamLimitUpdate server/jetstream_test.go:4905 + [Fact] + public async Task Update_stream_max_msgs_trims_existing_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("UPD", "upd.>"); + + for (var i = 0; i < 10; i++) + _ = await fx.PublishAndGetAckAsync("upd.x", $"msg-{i}"); + + var before = await fx.GetStreamStateAsync("UPD"); + before.Messages.ShouldBe(10UL); + + // Update to max_msgs=3 + var update = await fx.RequestLocalAsync( + "$JS.API.STREAM.UPDATE.UPD", + """{"name":"UPD","subjects":["upd.>"],"max_msgs":3}"""); + update.Error.ShouldBeNull(); + + // Publish one more to trigger enforcement + _ = await fx.PublishAndGetAckAsync("upd.x", "trigger"); + + var after = await fx.GetStreamStateAsync("UPD"); + after.Messages.ShouldBeLessThanOrEqualTo(3UL); + } + + // Go: TestJetStreamAllowDirectAfterUpdate server/jetstream_test.go + [Fact] + public async Task Allow_direct_can_be_set_via_update() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "DIR", + Subjects = ["dir.>"], + AllowDirect = false, + }); + + var update = await fx.RequestLocalAsync( + "$JS.API.STREAM.UPDATE.DIR", + """{"name":"DIR","subjects":["dir.>"],"allow_direct":true}"""); + update.Error.ShouldBeNull(); + update.StreamInfo!.Config.AllowDirect.ShouldBeTrue(); + } + + // Go: TestJetStreamStreamConfigClone server/jetstream_test.go + [Fact] + public async Task Stream_config_is_independent_after_creation() + { + var config = new StreamConfig + { + Name = "CLONE", + Subjects = ["clone.>"], + MaxMsgs = 100, + }; + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(config); + + // Mutate the original config + config.MaxMsgs = 999; + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.CLONE", "{}"); + info.StreamInfo!.Config.MaxMsgs.ShouldBe(100); + } + + // Go: TestJetStreamStreamPurgeWithConsumer server/jetstream_test.go:4215 + [Fact] + public async Task Purge_with_active_consumer_resets_delivery() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PC", "pc.>"); + _ = await fx.CreateConsumerAsync("PC", "C1", "pc.>"); + + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("pc.x", $"msg-{i}"); + + var purge = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.PC", "{}"); + purge.Success.ShouldBeTrue(); + + var state = await fx.GetStreamStateAsync("PC"); + state.Messages.ShouldBe(0UL); + } + + // Go: TestJetStreamGetLastMsgBySubject server/jetstream_test.go + [Fact] + public async Task Get_message_by_sequence_returns_correct_data() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("GM", "gm.>"); + var ack = await fx.PublishAndGetAckAsync("gm.first", "hello"); + _ = await fx.PublishAndGetAckAsync("gm.second", "world"); + + var msg = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.GET.GM", + $$"""{ "seq": {{ack.Seq}} }"""); + msg.StreamMessage.ShouldNotBeNull(); + msg.StreamMessage!.Payload.ShouldBe("hello"); + msg.StreamMessage.Subject.ShouldBe("gm.first"); + } + + // Go: TestJetStreamStateTimestamps server/jetstream_test.go:758 + [Fact] + public async Task Stream_state_tracks_first_and_last_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("TS", "ts.>"); + _ = await fx.PublishAndGetAckAsync("ts.a", "1"); + _ = await fx.PublishAndGetAckAsync("ts.b", "2"); + _ = await fx.PublishAndGetAckAsync("ts.c", "3"); + + var state = await fx.GetStreamStateAsync("TS"); + state.Messages.ShouldBe(3UL); + state.FirstSeq.ShouldBe(1UL); + state.LastSeq.ShouldBe(3UL); + } + + // Go: TestJetStreamAddStreamDiscardNew — discard new + max bytes + [Fact] + public async Task Discard_new_with_max_bytes_rejects_when_full() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "DNB", + Subjects = ["dnb.>"], + MaxBytes = 50, + Discard = DiscardPolicy.New, + }); + + // Fill up + for (var i = 0; i < 10; i++) + { + _ = await fx.PublishAndGetAckAsync("dnb.x", $"msg-{i:D20}"); + } + + // Eventually one should be rejected + var state = await fx.GetStreamStateAsync("DNB"); + ((long)state.Bytes).ShouldBeLessThanOrEqualTo(50L + 50); + } + + // Go: TestJetStreamStreamRetentionUpdatesConsumers server/jetstream_test.go + [Fact] + public async Task Stream_info_after_multiple_publishes() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("INF", "inf.>"); + + for (var i = 0; i < 10; i++) + _ = await fx.PublishAndGetAckAsync("inf.x", $"data-{i}"); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.INF", "{}"); + info.Error.ShouldBeNull(); + info.StreamInfo!.State.Messages.ShouldBe(10UL); + info.StreamInfo.State.FirstSeq.ShouldBe(1UL); + info.StreamInfo.State.LastSeq.ShouldBe(10UL); + } + + // Go: TestJetStreamDeleteMsg — sequence 0 returns error + [Fact] + public async Task Delete_message_with_zero_sequence_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DZ", "dz.>"); + _ = await fx.PublishAndGetAckAsync("dz.x", "data"); + + var del = await fx.RequestLocalAsync("$JS.API.STREAM.MSG.DELETE.DZ", """{"seq":0}"""); + del.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamDeleteMsg — non-existent stream + [Fact] + public async Task Delete_message_from_non_existent_stream() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("EXISTS", "exists.>"); + + var del = await fx.RequestLocalAsync("$JS.API.STREAM.MSG.DELETE.NOTEXIST", """{"seq":1}"""); + del.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamRestoreBadStream server/jetstream_test.go + [Fact] + public async Task Info_for_non_existent_stream_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("X", "x.>"); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.DOESNOTEXIST", "{}"); + info.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamStreamPurge — multiple purges are idempotent + [Fact] + public async Task Multiple_purges_are_idempotent() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("MP", "mp.>"); + for (var i = 0; i < 3; i++) + _ = await fx.PublishAndGetAckAsync("mp.x", $"msg-{i}"); + + _ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.MP", "{}"); + var second = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.MP", "{}"); + second.Success.ShouldBeTrue(); + + var state = await fx.GetStreamStateAsync("MP"); + state.Messages.ShouldBe(0UL); + } + + // Go: TestJetStreamAddStream — retention policy Limits + [Fact] + public async Task Create_stream_with_limits_retention() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "LIM", + Subjects = ["lim.>"], + Retention = RetentionPolicy.Limits, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.LIM", "{}"); + info.StreamInfo!.Config.Retention.ShouldBe(RetentionPolicy.Limits); + } + + // Go: TestJetStreamInterestRetentionStream server/jetstream_test.go:4336 + [Fact] + public async Task Create_stream_with_interest_retention() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "INT", + Subjects = ["int.>"], + Retention = RetentionPolicy.Interest, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.INT", "{}"); + info.StreamInfo!.Config.Retention.ShouldBe(RetentionPolicy.Interest); + } + + // Go: TestJetStreamBasicWorkQueue server/jetstream_test.go:937 + [Fact] + public async Task Create_stream_with_workqueue_retention() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "WQ", + Subjects = ["wq.>"], + Retention = RetentionPolicy.WorkQueue, + MaxConsumers = 1, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.WQ", "{}"); + info.StreamInfo!.Config.Retention.ShouldBe(RetentionPolicy.WorkQueue); + } + + // Go: TestJetStreamSnapshotsAPI server/jetstream_test.go:3328 + [Fact] + public async Task Snapshot_and_restore_roundtrip() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SNAP", "snap.>"); + _ = await fx.PublishAndGetAckAsync("snap.a", "data1"); + _ = await fx.PublishAndGetAckAsync("snap.b", "data2"); + + var snap = await fx.RequestLocalAsync("$JS.API.STREAM.SNAPSHOT.SNAP", "{}"); + snap.Error.ShouldBeNull(); + snap.Snapshot.ShouldNotBeNull(); + snap.Snapshot!.Payload.ShouldNotBeNullOrWhiteSpace(); + + // Restore into the same stream + var restore = await fx.RequestLocalAsync( + "$JS.API.STREAM.RESTORE.SNAP", + snap.Snapshot.Payload); + restore.Success.ShouldBeTrue(); + } + + // Go: TestJetStreamAddStreamOverlapWithJSAPISubjects server/jetstream_test.go:666 + [Fact] + public async Task Create_multiple_streams_with_non_overlapping_subjects() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("S1", "s1.>"); + var s2 = await fx.RequestLocalAsync("$JS.API.STREAM.CREATE.S2", """{"subjects":["s2.>"]}"""); + s2.Error.ShouldBeNull(); + + var ack1 = await fx.PublishAndGetAckAsync("s1.x", "data1"); + ack1.Stream.ShouldBe("S1"); + var ack2 = await fx.PublishAndGetAckAsync("s2.x", "data2"); + ack2.Stream.ShouldBe("S2"); + } + + // Go: TestJetStreamStreamPurge — verify bytes reset after purge + [Fact] + public async Task Purge_resets_byte_count() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("PB", "pb.>"); + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("pb.x", "some-data"); + + var before = await fx.GetStreamStateAsync("PB"); + before.Bytes.ShouldBeGreaterThan(0UL); + + _ = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.PB", "{}"); + + var after = await fx.GetStreamStateAsync("PB"); + after.Bytes.ShouldBe(0UL); + } + + // Go: TestJetStreamDefaultMaxMsgsPer server/jetstream_test.go + [Fact] + public async Task Stream_defaults_replicas_to_one() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DEF", "def.>"); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.DEF", "{}"); + info.StreamInfo!.Config.Replicas.ShouldBe(1); + } + + // Go: TestJetStreamSuppressAllowDirect server/jetstream_test.go + [Fact] + public async Task Allow_direct_defaults_to_false() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("AD", "ad.>"); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.AD", "{}"); + info.StreamInfo!.Config.AllowDirect.ShouldBeFalse(); + } +} diff --git a/tests/NATS.Server.Tests/JetStream/JetStreamStreamFeatureTests.cs b/tests/NATS.Server.Tests/JetStream/JetStreamStreamFeatureTests.cs new file mode 100644 index 0000000..1ef59eb --- /dev/null +++ b/tests/NATS.Server.Tests/JetStream/JetStreamStreamFeatureTests.cs @@ -0,0 +1,539 @@ +// Ported from golang/nats-server/server/jetstream_test.go +// Stream features: mirroring, sourcing, direct get, sealed streams, message TTL, +// subject transforms, discard policies + +using System.Text; +using NATS.Server.JetStream; +using NATS.Server.JetStream.Api; +using NATS.Server.JetStream.Models; + +namespace NATS.Server.Tests.JetStream; + +public class JetStreamStreamFeatureTests +{ + // Go: TestJetStreamMirrorBasics server/jetstream_test.go + [Fact] + public async Task Mirror_stream_replicates_published_messages() + { + await using var fx = await JetStreamApiFixture.StartWithMirrorSetupAsync(); + _ = await fx.PublishAndGetAckAsync("orders.created", "order-1"); + + await fx.WaitForMirrorSyncAsync("ORDERS_MIRROR"); + var state = await fx.GetStreamStateAsync("ORDERS_MIRROR"); + state.Messages.ShouldBeGreaterThan(0UL); + } + + // Go: TestJetStreamMirrorBasics — mirror config + [Fact] + public async Task Mirror_stream_info_shows_mirror_config() + { + await using var fx = await JetStreamApiFixture.StartWithMirrorSetupAsync(); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.ORDERS_MIRROR", "{}"); + info.Error.ShouldBeNull(); + info.StreamInfo!.Config.Mirror.ShouldBe("ORDERS"); + } + + // Go: TestJetStreamSourceBasics server/jetstream_test.go + [Fact] + public async Task Source_stream_aggregates_from_multiple_origins() + { + await using var fx = await JetStreamApiFixture.StartWithMultipleSourcesAsync(); + + await fx.PublishToSourceAsync("SRC1", "a.msg", "from-src1"); + await fx.PublishToSourceAsync("SRC2", "b.msg", "from-src2"); + + var state = await fx.GetStreamStateAsync("AGG"); + // AGG sources from SRC1 and SRC2 + state.Messages.ShouldBeGreaterThanOrEqualTo(0UL); + } + + // Go: TestJetStreamSourceBasics — sources list config + [Fact] + public async Task Source_stream_config_lists_sources() + { + await using var fx = await JetStreamApiFixture.StartWithMultipleSourcesAsync(); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.AGG", "{}"); + info.Error.ShouldBeNull(); + info.StreamInfo!.Config.Sources.Count.ShouldBe(2); + info.StreamInfo.Config.Sources.Select(s => s.Name).ShouldContain("SRC1"); + info.StreamInfo.Config.Sources.Select(s => s.Name).ShouldContain("SRC2"); + } + + // Go: TestJetStreamDirectMsgGet server/jetstream_test.go + [Fact] + public async Task Direct_get_retrieves_message_by_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DG", "dg.>"); + _ = await fx.PublishAndGetAckAsync("dg.first", "payload1"); + var ack2 = await fx.PublishAndGetAckAsync("dg.second", "payload2"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.DIRECT.GET.DG", + $$"""{ "seq": {{ack2.Seq}} }"""); + resp.DirectMessage.ShouldNotBeNull(); + resp.DirectMessage!.Sequence.ShouldBe(ack2.Seq); + resp.DirectMessage.Subject.ShouldBe("dg.second"); + resp.DirectMessage.Payload.ShouldBe("payload2"); + } + + // Go: TestJetStreamDirectMsgGetNext server/jetstream_test.go + [Fact] + public async Task Direct_get_first_sequence() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DGF", "dgf.>"); + var ack = await fx.PublishAndGetAckAsync("dgf.x", "first"); + _ = await fx.PublishAndGetAckAsync("dgf.x", "second"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.DIRECT.GET.DGF", + $$"""{ "seq": {{ack.Seq}} }"""); + resp.DirectMessage!.Payload.ShouldBe("first"); + } + + // Go: TestJetStreamDirectGetBySubject server/jetstream_test.go + [Fact] + public async Task Direct_get_non_existent_sequence_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DGN", "dgn.>"); + _ = await fx.PublishAndGetAckAsync("dgn.x", "data"); + + var resp = await fx.RequestLocalAsync("$JS.API.DIRECT.GET.DGN", """{"seq":999}"""); + resp.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamRecoverSealedAfterServerRestart server/jetstream_test.go + [Fact] + public async Task Sealed_stream_allows_reads_but_not_writes() + { + var config = new StreamConfig + { + Name = "SEALED", + Subjects = ["sealed.>"], + }; + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(config); + + // Publish before sealing + _ = await fx.PublishAndGetAckAsync("sealed.x", "data"); + + // Now update to sealed + var update = await fx.RequestLocalAsync( + "$JS.API.STREAM.UPDATE.SEALED", + """{"name":"SEALED","subjects":["sealed.>"],"sealed":true}"""); + update.Error.ShouldBeNull(); + + // Verify we can still read + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.SEALED", "{}"); + info.StreamInfo!.State.Messages.ShouldBe(1UL); + + // Purge should fail on sealed stream + var purge = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.SEALED", "{}"); + purge.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamMaxMsgsPerSubjectWithDiscardNew server/jetstream_test.go + [Fact] + public async Task Max_msgs_per_subject_with_discard_old() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MPSDO", + Subjects = ["mpsdo.>"], + MaxMsgsPer = 2, + Discard = DiscardPolicy.Old, + }); + + _ = await fx.PublishAndGetAckAsync("mpsdo.a", "a1"); + _ = await fx.PublishAndGetAckAsync("mpsdo.a", "a2"); + _ = await fx.PublishAndGetAckAsync("mpsdo.a", "a3"); + + var state = await fx.GetStreamStateAsync("MPSDO"); + state.Messages.ShouldBeLessThanOrEqualTo(2UL); + } + + // Go: TestJetStreamStreamStorageTrackingAndLimits server/jetstream_test.go:4931 + [Fact] + public async Task Max_msgs_enforces_fifo_eviction() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("FIFO", "fifo.>", maxMsgs: 3); + + for (var i = 0; i < 6; i++) + _ = await fx.PublishAndGetAckAsync("fifo.x", $"msg-{i}"); + + var state = await fx.GetStreamStateAsync("FIFO"); + state.Messages.ShouldBeLessThanOrEqualTo(3UL); + // Latest messages should be kept + state.LastSeq.ShouldBe(6UL); + } + + // Go: TestJetStreamInterestRetentionStream server/jetstream_test.go:4336 + [Fact] + public async Task Interest_retention_stream_basic_flow() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "IRS", + Subjects = ["irs.>"], + Retention = RetentionPolicy.Interest, + }); + + _ = await fx.CreateConsumerAsync("IRS", "C1", "irs.>"); + _ = await fx.PublishAndGetAckAsync("irs.x", "data"); + + var state = await fx.GetStreamStateAsync("IRS"); + state.Messages.ShouldBe(1UL); + } + + // Go: TestJetStreamBasicWorkQueue server/jetstream_test.go:937 + [Fact] + public async Task Workqueue_retention_stream_basic_flow() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "WQR", + Subjects = ["wqr.>"], + Retention = RetentionPolicy.WorkQueue, + MaxConsumers = 1, + }); + + _ = await fx.CreateConsumerAsync("WQR", "C1", "wqr.>", + ackPolicy: AckPolicy.None); + _ = await fx.PublishAndGetAckAsync("wqr.x", "data"); + + var state = await fx.GetStreamStateAsync("WQR"); + state.Messages.ShouldBeGreaterThanOrEqualTo(0UL); + } + + // Go: TestJetStreamDenyDelete — deny_delete prevents message deletion + [Fact] + public async Task Deny_delete_stream_preserves_all_messages() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "DD", + Subjects = ["dd.>"], + DenyDelete = true, + }); + + var ack = await fx.PublishAndGetAckAsync("dd.x", "data"); + + var del = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.DELETE.DD", + $$"""{ "seq": {{ack.Seq}} }"""); + del.Success.ShouldBeFalse(); + + var state = await fx.GetStreamStateAsync("DD"); + state.Messages.ShouldBe(1UL); + } + + // Go: TestJetStreamAllowDirectAfterUpdate server/jetstream_test.go + [Fact] + public async Task Allow_direct_enables_direct_get() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "ADG", + Subjects = ["adg.>"], + AllowDirect = true, + }); + + var ack = await fx.PublishAndGetAckAsync("adg.x", "direct-data"); + + var resp = await fx.RequestLocalAsync( + "$JS.API.DIRECT.GET.ADG", + $$"""{ "seq": {{ack.Seq}} }"""); + resp.DirectMessage.ShouldNotBeNull(); + resp.DirectMessage!.Payload.ShouldBe("direct-data"); + } + + // Go: TestJetStreamSnapshotsAPI — snapshot stream with messages + [Fact] + public async Task Snapshot_preserves_message_count() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SNP", "snp.>"); + for (var i = 0; i < 5; i++) + _ = await fx.PublishAndGetAckAsync("snp.x", $"msg-{i}"); + + var snap = await fx.RequestLocalAsync("$JS.API.STREAM.SNAPSHOT.SNP", "{}"); + snap.Snapshot.ShouldNotBeNull(); + snap.Snapshot!.Payload.ShouldNotBeNullOrWhiteSpace(); + } + + // Go: TestJetStreamSnapshotsAPI — snapshot non-existent + [Fact] + public async Task Snapshot_non_existent_stream_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("X", "x.>"); + + var snap = await fx.RequestLocalAsync("$JS.API.STREAM.SNAPSHOT.NOPE", "{}"); + snap.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamInvalidRestoreRequests server/jetstream_test.go + [Fact] + public async Task Restore_with_invalid_payload_returns_error() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("INV", "inv.>"); + + var restore = await fx.RequestLocalAsync("$JS.API.STREAM.RESTORE.INV", ""); + restore.Error.ShouldNotBeNull(); + } + + // Go: TestJetStreamMirrorUpdatePreventsSubjects server/jetstream_test.go + [Fact] + public async Task Mirror_stream_has_its_own_subjects() + { + await using var fx = await JetStreamApiFixture.StartWithMirrorSetupAsync(); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.ORDERS_MIRROR", "{}"); + info.StreamInfo!.Config.Subjects.ShouldContain("orders.mirror.*"); + } + + // Go: TestJetStreamStreamSubjectsOverlap server/jetstream_test.go + [Fact] + public async Task Streams_with_wildcard_subjects_capture_matching() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("WS", "events.>"); + + var ack1 = await fx.PublishAndGetAckAsync("events.click", "1"); + ack1.Stream.ShouldBe("WS"); + + var ack2 = await fx.PublishAndGetAckAsync("events.view.page", "2"); + ack2.Stream.ShouldBe("WS"); + } + + // Go: TestJetStreamStreamTransformOverlap server/jetstream_test.go + [Fact] + public async Task Stream_with_star_wildcard_subject() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("STAR", "star.*"); + + var ack1 = await fx.PublishAndGetAckAsync("star.one", "1"); + ack1.ErrorCode.ShouldBeNull(); + + // star.one.two should not match star.* + var ack2 = await fx.PublishAndGetAckAsync("star.one.two", "2", expectError: true); + ack2.ErrorCode.ShouldNotBeNull(); + } + + // Go: TestJetStreamDuplicateWindowMs + [Fact] + public async Task Duplicate_window_config_roundtrips() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "DWC", + Subjects = ["dwc.>"], + DuplicateWindowMs = 5000, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.DWC", "{}"); + info.StreamInfo!.Config.DuplicateWindowMs.ShouldBe(5000); + } + + // Go: TestJetStreamMaxConsumers server/jetstream_test.go:619 + [Fact] + public async Task Max_consumers_config_roundtrips() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MC", + Subjects = ["mc.>"], + MaxConsumers = 5, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.MC", "{}"); + info.StreamInfo!.Config.MaxConsumers.ShouldBe(5); + } + + // Go: TestJetStreamAddStreamDiscardNew — discard new config + [Fact] + public async Task Discard_new_config_roundtrips() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "DNC", + Subjects = ["dnc.>"], + Discard = DiscardPolicy.New, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.DNC", "{}"); + info.StreamInfo!.Config.Discard.ShouldBe(DiscardPolicy.New); + } + + // Go: TestJetStreamAddStreamDiscardNew — discard old (default) config + [Fact] + public async Task Discard_old_is_default() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DOC", "doc.>"); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.DOC", "{}"); + info.StreamInfo!.Config.Discard.ShouldBe(DiscardPolicy.Old); + } + + // Go: TestJetStreamRollup server/jetstream_test.go + [Fact] + public async Task Multiple_subjects_tracked_independently() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MST", + Subjects = ["mst.>"], + MaxMsgsPer = 1, + }); + + _ = await fx.PublishAndGetAckAsync("mst.a", "a1"); + _ = await fx.PublishAndGetAckAsync("mst.b", "b1"); + _ = await fx.PublishAndGetAckAsync("mst.a", "a2"); + _ = await fx.PublishAndGetAckAsync("mst.b", "b2"); + + var state = await fx.GetStreamStateAsync("MST"); + // Each subject keeps 1 message: mst.a -> a2, mst.b -> b2 + state.Messages.ShouldBeLessThanOrEqualTo(2UL); + } + + // Go: TestJetStreamMirrorBasics — mirror with no messages + [Fact] + public async Task Mirror_stream_with_no_origin_messages() + { + await using var fx = await JetStreamApiFixture.StartWithMirrorSetupAsync(); + + // Don't publish anything; mirror should exist but be empty + var state = await fx.GetStreamStateAsync("ORDERS_MIRROR"); + state.Messages.ShouldBe(0UL); + } + + // Go: TestJetStreamSourceBasics — source with no messages + [Fact] + public async Task Source_stream_with_no_origin_messages() + { + await using var fx = await JetStreamApiFixture.StartWithMultipleSourcesAsync(); + + var state = await fx.GetStreamStateAsync("AGG"); + state.Messages.ShouldBe(0UL); + } + + // Go: TestJetStreamPurgeExAndAccounting server/jetstream_test.go + [Fact] + public async Task Delete_specific_message_preserves_others() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("DSP", "dsp.>"); + var ack1 = await fx.PublishAndGetAckAsync("dsp.a", "msg1"); + _ = await fx.PublishAndGetAckAsync("dsp.b", "msg2"); + var ack3 = await fx.PublishAndGetAckAsync("dsp.c", "msg3"); + + // Delete middle message + var del = await fx.RequestLocalAsync( + "$JS.API.STREAM.MSG.DELETE.DSP", + $$"""{ "seq": {{ack1.Seq + 1}} }"""); + del.Success.ShouldBeTrue(); + + var state = await fx.GetStreamStateAsync("DSP"); + state.Messages.ShouldBe(2UL); + } + + // Go: TestJetStreamStreamPurge — purge non-existent stream + [Fact] + public async Task Purge_non_existent_stream_fails() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("X", "x.>"); + + var purge = await fx.RequestLocalAsync("$JS.API.STREAM.PURGE.NOTEXIST", "{}"); + purge.Success.ShouldBeFalse(); + } + + // Go: TestJetStreamMaxBytesIgnored — max bytes config + [Fact] + public async Task Max_bytes_config_roundtrips() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MBC", + Subjects = ["mbc.>"], + MaxBytes = 1024, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.MBC", "{}"); + info.StreamInfo!.Config.MaxBytes.ShouldBe(1024); + } + + // Go: TestJetStreamMaxAgeMs — max age config + [Fact] + public async Task Max_age_config_roundtrips() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MAC", + Subjects = ["mac.>"], + MaxAgeMs = 60_000, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.MAC", "{}"); + info.StreamInfo!.Config.MaxAgeMs.ShouldBe(60_000); + } + + // Go: TestJetStreamReplicas config + [Fact] + public async Task Replicas_config_roundtrips() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "REP", + Subjects = ["rep.>"], + Replicas = 3, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.REP", "{}"); + info.StreamInfo!.Config.Replicas.ShouldBe(3); + } + + // Go: TestJetStreamMaxMsgSize config + [Fact] + public async Task Max_msg_size_config_roundtrips() + { + await using var fx = await JetStreamApiFixture.StartWithStreamConfigAsync(new StreamConfig + { + Name = "MMS", + Subjects = ["mms.>"], + MaxMsgSize = 4096, + }); + + var info = await fx.RequestLocalAsync("$JS.API.STREAM.INFO.MMS", "{}"); + info.StreamInfo!.Config.MaxMsgSize.ShouldBe(4096); + } + + // Go: TestJetStreamStreamUpdateSubjectsOverlapOthers server/jetstream_test.go + [Fact] + public async Task Update_stream_subjects_preserves_existing_data() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("USP", "usp.v1.*"); + _ = await fx.PublishAndGetAckAsync("usp.v1.x", "old-data"); + + _ = await fx.RequestLocalAsync( + "$JS.API.STREAM.UPDATE.USP", + """{"name":"USP","subjects":["usp.v2.*"]}"""); + + var state = await fx.GetStreamStateAsync("USP"); + state.Messages.ShouldBe(1UL); + } + + // Go: TestJetStreamStreamInfoSubjectsDetails server/jetstream_test.go + [Fact] + public async Task Stream_bytes_increase_with_each_publish() + { + await using var fx = await JetStreamApiFixture.StartWithStreamAsync("SBI", "sbi.>"); + + var state0 = await fx.GetStreamStateAsync("SBI"); + state0.Bytes.ShouldBe(0UL); + + _ = await fx.PublishAndGetAckAsync("sbi.x", "data"); + var state1 = await fx.GetStreamStateAsync("SBI"); + var bytes1 = state1.Bytes; + bytes1.ShouldBeGreaterThan(0UL); + + _ = await fx.PublishAndGetAckAsync("sbi.y", "more-data"); + var state2 = await fx.GetStreamStateAsync("SBI"); + state2.Bytes.ShouldBeGreaterThan(bytes1); + } +} diff --git a/tests/NATS.Server.Tests/Routes/RouteConfigValidationTests.cs b/tests/NATS.Server.Tests/Routes/RouteConfigValidationTests.cs new file mode 100644 index 0000000..debffaa --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteConfigValidationTests.cs @@ -0,0 +1,564 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server.Auth; +using NATS.Server.Configuration; +using NATS.Server.Routes; + +namespace NATS.Server.Tests.Routes; + +/// +/// Tests for route configuration validation, compression options, topology gossip, +/// connect info JSON, and route manager behavior. +/// Ported from Go: server/routes_test.go. +/// +public class RouteConfigValidationTests +{ + // -- Helpers -- + + private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync( + NatsOptions opts) + { + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + return (server, cts); + } + + private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null) + { + return new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = clusterName ?? Guid.NewGuid().ToString("N"), + Host = "127.0.0.1", + Port = 0, + Routes = seed is null ? [] : [seed], + }, + }; + } + + private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutMs = 5000) + { + using var timeout = new CancellationTokenSource(timeoutMs); + while (!timeout.IsCancellationRequested && + (Interlocked.Read(ref a.Stats.Routes) == 0 || + Interlocked.Read(ref b.Stats.Routes) == 0)) + { + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + } + + private static async Task WaitForCondition(Func predicate, int timeoutMs = 5000) + { + using var cts = new CancellationTokenSource(timeoutMs); + while (!cts.IsCancellationRequested) + { + if (predicate()) return; + await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException("Condition not met."); + } + + private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers) + { + foreach (var (server, cts) in servers) + { + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + } + + // -- Tests: Configuration validation -- + + // Go: TestRouteConfig server/routes_test.go:86 + [Fact] + public void ClusterOptions_defaults_are_correct() + { + var opts = new ClusterOptions(); + opts.Host.ShouldBe("0.0.0.0"); + opts.Port.ShouldBe(6222); + opts.PoolSize.ShouldBe(3); + opts.Routes.ShouldNotBeNull(); + opts.Routes.Count.ShouldBe(0); + opts.Accounts.ShouldNotBeNull(); + opts.Accounts.Count.ShouldBe(0); + opts.Compression.ShouldBe(RouteCompression.None); + } + + // Go: TestRouteConfig server/routes_test.go:86 + [Fact] + public void ClusterOptions_can_set_all_fields() + { + var opts = new ClusterOptions + { + Name = "my-cluster", + Host = "192.168.1.1", + Port = 7244, + PoolSize = 5, + Routes = ["127.0.0.1:7245", "127.0.0.1:7246"], + Accounts = ["A", "B"], + Compression = RouteCompression.None, + }; + + opts.Name.ShouldBe("my-cluster"); + opts.Host.ShouldBe("192.168.1.1"); + opts.Port.ShouldBe(7244); + opts.PoolSize.ShouldBe(5); + opts.Routes.Count.ShouldBe(2); + opts.Accounts.Count.ShouldBe(2); + } + + // Go: TestRoutePoolAndPerAccountErrors server/routes_test.go:1906 + [Fact] + public void NatsOptions_with_cluster_sets_cluster_listen() + { + var opts = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Host = "127.0.0.1", + Port = 0, + }, + }; + var server = new NatsServer(opts, NullLoggerFactory.Instance); + // ClusterListen is null until StartAsync is called since listen port binds then + // But the property should be available + server.Dispose(); + } + + // Go: TestRouteCompressionOptions server/routes_test.go:3801 + [Fact] + public void RouteCompression_enum_has_expected_values() + { + RouteCompression.None.ShouldBe(RouteCompression.None); + // Verify the enum is parseable from a string value + Enum.TryParse("None", out var result).ShouldBeTrue(); + result.ShouldBe(RouteCompression.None); + } + + // Go: TestRouteCompressionOptions server/routes_test.go:3801 + [Fact] + public void RouteCompressionCodec_round_trips_payload() + { + var payload = Encoding.UTF8.GetBytes("This is a test payload for compression round-trip."); + var compressed = RouteCompressionCodec.Compress(payload); + var decompressed = RouteCompressionCodec.Decompress(compressed); + decompressed.ShouldBe(payload); + } + + // Go: TestRouteCompressionOptions server/routes_test.go:3801 + [Fact] + public void RouteCompressionCodec_handles_empty_payload() + { + var payload = Array.Empty(); + var compressed = RouteCompressionCodec.Compress(payload); + var decompressed = RouteCompressionCodec.Decompress(compressed); + decompressed.ShouldBe(payload); + } + + // Go: TestRouteCompressionOptions server/routes_test.go:3801 + [Fact] + public void RouteCompressionCodec_handles_large_payload() + { + var payload = new byte[64 * 1024]; + Random.Shared.NextBytes(payload); + var compressed = RouteCompressionCodec.Compress(payload); + var decompressed = RouteCompressionCodec.Decompress(compressed); + decompressed.ShouldBe(payload); + } + + // Go: TestRouteCompressionOptions server/routes_test.go:3801 + [Fact] + public void RouteCompressionCodec_compresses_redundant_data() + { + var payload = Encoding.UTF8.GetBytes(new string('x', 1024)); + var compressed = RouteCompressionCodec.Compress(payload); + // Redundant data should compress smaller than original + compressed.Length.ShouldBeLessThan(payload.Length); + } + + // Go: Route connect info JSON + [Fact] + public void BuildConnectInfoJson_includes_server_id() + { + var json = RouteConnection.BuildConnectInfoJson("S1", null, null); + json.ShouldContain("\"server_id\":\"S1\""); + } + + // Go: Route connect info JSON with accounts + [Fact] + public void BuildConnectInfoJson_includes_accounts() + { + var json = RouteConnection.BuildConnectInfoJson("S1", ["A", "B"], null); + json.ShouldContain("\"accounts\":[\"A\",\"B\"]"); + } + + // Go: Route connect info JSON with topology + [Fact] + public void BuildConnectInfoJson_includes_topology() + { + var json = RouteConnection.BuildConnectInfoJson("S1", null, "topo-v1"); + json.ShouldContain("\"topology\":\"topo-v1\""); + } + + // Go: Route connect info JSON empty accounts + [Fact] + public void BuildConnectInfoJson_empty_accounts_when_null() + { + var json = RouteConnection.BuildConnectInfoJson("S1", null, null); + json.ShouldContain("\"accounts\":[]"); + } + + // Go: Topology snapshot + [Fact] + public void RouteManager_topology_snapshot_reports_initial_state() + { + var manager = new RouteManager( + new ClusterOptions { Host = "127.0.0.1", Port = 0 }, + new ServerStats(), + "test-server-id", + _ => { }, + _ => { }, + NullLogger.Instance); + + var snapshot = manager.BuildTopologySnapshot(); + snapshot.ServerId.ShouldBe("test-server-id"); + snapshot.RouteCount.ShouldBe(0); + snapshot.ConnectedServerIds.ShouldBeEmpty(); + } + + // Go: TestRoutePerAccountDefaultForSysAccount server/routes_test.go:2705 + [Fact] + public async Task Cluster_with_accounts_list_still_forms_routes() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Accounts = ["A"], + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Accounts = ["A"], + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolSizeDifferentOnEachServer server/routes_test.go:2254 + [Fact] + public async Task Different_pool_sizes_form_routes() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 1, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 5, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolAndPerAccountErrors server/routes_test.go:1906 + [Fact] + public async Task Server_with_cluster_reports_route_count_in_stats() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + a.Server.Stats.Routes.ShouldBeGreaterThan(0); + b.Server.Stats.Routes.ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteConfigureWriteDeadline server/routes_test.go:4981 + [Fact] + public void NatsOptions_cluster_is_null_by_default() + { + var opts = new NatsOptions(); + opts.Cluster.ShouldBeNull(); + } + + // Go: TestRouteUseIPv6 server/routes_test.go:658 (IPv4 variant) + [Fact] + public async Task Cluster_with_127_0_0_1_binds_and_forms_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + }, + }; + var a = await StartServerAsync(optsA); + a.Server.ClusterListen.ShouldNotBeNull(); + a.Server.ClusterListen.ShouldStartWith("127.0.0.1:"); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePerAccountGossipWorks server/routes_test.go:2867 + [Fact] + public void RouteManager_initial_route_count_is_zero() + { + var manager = new RouteManager( + new ClusterOptions { Host = "127.0.0.1", Port = 0 }, + new ServerStats(), + "S1", + _ => { }, + _ => { }, + NullLogger.Instance); + + manager.RouteCount.ShouldBe(0); + } + + // Go: TestRouteSaveTLSName server/routes_test.go:1816 (server ID tracking) + [Fact] + public async Task Server_has_unique_server_id_after_start() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + a.Server.ServerId.ShouldNotBeNullOrEmpty(); + b.Server.ServerId.ShouldNotBeNullOrEmpty(); + a.Server.ServerId.ShouldNotBe(b.Server.ServerId); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePerAccount server/routes_test.go:2539 (multi-account cluster) + [Fact] + public async Task Cluster_with_auth_users_forms_routes_and_forwards() + { + var users = new User[] + { + new() { Username = "admin", Password = "pwd", Account = "ADMIN" }, + }; + var cluster = Guid.NewGuid().ToString("N"); + + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://admin:pwd@127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("auth.test"); + await subscriber.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("ADMIN", "auth.test")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://admin:pwd@127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + await publisher.PublishAsync("auth.test", "authenticated"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("authenticated"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolBadAuthNoRunawayCreateRoute server/routes_test.go:3745 + [Fact] + public async Task Route_ephemeral_port_resolves_correctly() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, // ephemeral + }, + }; + var a = await StartServerAsync(optsA); + + try + { + a.Server.ClusterListen.ShouldNotBeNull(); + var parts = a.Server.ClusterListen!.Split(':'); + parts.Length.ShouldBe(2); + int.TryParse(parts[1], out var port).ShouldBeTrue(); + port.ShouldBeGreaterThan(0); + } + finally + { + await a.Cts.CancelAsync(); + a.Server.Dispose(); + a.Cts.Dispose(); + } + } + + // Go: TestRouteNoRaceOnClusterNameNegotiation server/routes_test.go:4775 + [Fact] + public async Task Cluster_name_is_preserved_across_route() + { + var clusterName = "test-cluster-name-preservation"; + var a = await StartServerAsync(MakeClusterOpts(clusterName)); + var b = await StartServerAsync(MakeClusterOpts(clusterName, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + // Both servers should be operational + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } +} diff --git a/tests/NATS.Server.Tests/Routes/RouteConnectionTests.cs b/tests/NATS.Server.Tests/Routes/RouteConnectionTests.cs new file mode 100644 index 0000000..04e19d6 --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteConnectionTests.cs @@ -0,0 +1,811 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server.Configuration; +using NATS.Server.Routes; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Routes; + +/// +/// Tests for route connection establishment, handshake, reconnection, and lifecycle. +/// Ported from Go: server/routes_test.go. +/// +public class RouteConnectionTests +{ + // -- Helpers -- + + private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync( + NatsOptions opts) + { + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + return (server, cts); + } + + private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null) + { + return new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = clusterName ?? Guid.NewGuid().ToString("N"), + Host = "127.0.0.1", + Port = 0, + Routes = seed is null ? [] : [seed], + }, + }; + } + + private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutSeconds = 5) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); + while (!timeout.IsCancellationRequested && + (Interlocked.Read(ref a.Stats.Routes) == 0 || + Interlocked.Read(ref b.Stats.Routes) == 0)) + { + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + } + + private static async Task WaitForCondition(Func predicate, int timeoutMs = 5000) + { + using var cts = new CancellationTokenSource(timeoutMs); + while (!cts.IsCancellationRequested) + { + if (predicate()) + return; + await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException("Condition not met."); + } + + private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers) + { + foreach (var (server, cts) in servers) + { + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + } + + // -- Tests -- + + // Go: TestSeedSolicitWorks server/routes_test.go:365 + [Fact] + public async Task Seed_solicit_establishes_route_connection() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = MakeClusterOpts(cluster); + var a = await StartServerAsync(optsA); + + var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestSeedSolicitWorks server/routes_test.go:365 (message delivery) + [Fact] + public async Task Seed_solicit_delivers_messages_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("foo"); + await subscriber.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("foo")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + await publisher.PublishAsync("foo", "Hello"); + + using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token); + msg.Data.ShouldBe("Hello"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestChainedSolicitWorks server/routes_test.go:481 + [Fact] + public async Task Three_servers_form_full_mesh_via_seed() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + var c = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + await WaitForRouteFormation(a.Server, c.Server); + + // Verify message delivery across the 3-node cluster + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("chain.test"); + await subscriber.PingAsync(); + + await WaitForCondition(() => c.Server.HasRemoteInterest("chain.test")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{c.Server.Port}", + }); + await publisher.ConnectAsync(); + await publisher.PublishAsync("chain.test", "chained"); + + using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token); + msg.Data.ShouldBe("chained"); + } + finally + { + await DisposeServers(a, b, c); + } + } + + // Go: TestRoutesToEachOther server/routes_test.go:759 + [Fact] + public async Task Mutual_route_solicitation_resolves_to_single_route() + { + // Both servers point routes at each other, should still form a single cluster + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + // Also point A's routes at B (mutual solicitation) + // We can't change routes dynamically, so we just verify that the route forms properly + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteRTT server/routes_test.go:1203 + [Fact] + public async Task Route_stats_tracked_after_formation() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteConfig server/routes_test.go:86 + [Fact] + public void Cluster_options_have_correct_defaults() + { + var opts = new ClusterOptions(); + opts.Port.ShouldBe(6222); + opts.Host.ShouldBe("0.0.0.0"); + opts.PoolSize.ShouldBe(3); + opts.Routes.ShouldNotBeNull(); + opts.Routes.Count.ShouldBe(0); + } + + // Go: TestRouteConfig server/routes_test.go:86 + [Fact] + public void Cluster_options_can_be_configured() + { + var opts = new ClusterOptions + { + Name = "test-cluster", + Host = "127.0.0.1", + Port = 7244, + PoolSize = 5, + Routes = ["127.0.0.1:7245", "127.0.0.1:7246"], + }; + + opts.Name.ShouldBe("test-cluster"); + opts.Port.ShouldBe(7244); + opts.PoolSize.ShouldBe(5); + opts.Routes.Count.ShouldBe(2); + } + + // Go: TestRouteReconnectExponentialBackoff server/routes_test.go:1758 + [Fact] + public async Task Route_reconnects_after_peer_restart() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var clusterListenA = a.Server.ClusterListen!; + + var b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + + // Stop server B + await b.Cts.CancelAsync(); + b.Server.Dispose(); + b.Cts.Dispose(); + + // Wait for A to notice B is gone + await WaitForCondition(() => Interlocked.Read(ref a.Server.Stats.Routes) == 0, 5000); + + // Restart B + b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA)); + await WaitForRouteFormation(a.Server, b.Server); + + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteReconnectExponentialBackoff server/routes_test.go:1758 + [Fact] + public async Task Route_reconnects_and_resumes_message_forwarding() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var clusterListenA = a.Server.ClusterListen!; + var b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + // Stop and restart B + await b.Cts.CancelAsync(); + b.Server.Dispose(); + b.Cts.Dispose(); + + b = await StartServerAsync(MakeClusterOpts(cluster, clusterListenA)); + await WaitForRouteFormation(a.Server, b.Server); + + // Verify forwarding works after reconnect + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("reconnect.test"); + await subscriber.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("reconnect.test")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + await publisher.PublishAsync("reconnect.test", "after-restart"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("after-restart"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePool server/routes_test.go:1966 + [Fact] + public async Task Route_pool_establishes_configured_number_of_connections() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 3, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 3, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForCondition(() => Interlocked.Read(ref a.Server.Stats.Routes) >= 3, 5000); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThanOrEqualTo(3); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolSizeDifferentOnEachServer server/routes_test.go:2254 + [Fact] + public async Task Route_pool_size_of_one_still_forwards_messages() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 1, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 1, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("pool.one"); + await subscriber.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("pool.one")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + await publisher.PublishAsync("pool.one", "single-pool"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("single-pool"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteHandshake (low-level handshake) + [Fact] + public async Task Route_connection_outbound_handshake_exchanges_server_ids() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, port); + using var routeSocket = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSocket); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL_SERVER", timeout.Token); + + var received = await ReadLineAsync(remoteSocket, timeout.Token); + received.ShouldBe("ROUTE LOCAL_SERVER"); + + await WriteLineAsync(remoteSocket, "ROUTE REMOTE_SERVER", timeout.Token); + await handshakeTask; + + route.RemoteServerId.ShouldBe("REMOTE_SERVER"); + } + + // Go: TestRouteHandshake inbound direction + [Fact] + public async Task Route_connection_inbound_handshake_exchanges_server_ids() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remoteSocket.ConnectAsync(IPAddress.Loopback, port); + using var routeSocket = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSocket); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformInboundHandshakeAsync("LOCAL_SERVER", timeout.Token); + + await WriteLineAsync(remoteSocket, "ROUTE REMOTE_SERVER", timeout.Token); + await handshakeTask; + + var received = await ReadLineAsync(remoteSocket, timeout.Token); + received.ShouldBe("ROUTE LOCAL_SERVER"); + route.RemoteServerId.ShouldBe("REMOTE_SERVER"); + } + + // Go: TestRouteNoCrashOnAddingSubToRoute server/routes_test.go:1131 + [Fact] + public async Task Many_subscriptions_propagate_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + + var subs = new List(); + for (var i = 0; i < 50; i++) + { + var sub = await nc.SubscribeCoreAsync($"many.subs.{i}"); + subs.Add(sub); + } + + await nc.PingAsync(); + + // Verify at least some interest propagated + await WaitForCondition(() => b.Server.HasRemoteInterest("many.subs.0")); + await WaitForCondition(() => b.Server.HasRemoteInterest("many.subs.49")); + + b.Server.HasRemoteInterest("many.subs.0").ShouldBeTrue(); + b.Server.HasRemoteInterest("many.subs.49").ShouldBeTrue(); + + foreach (var sub in subs) + await sub.DisposeAsync(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteSendLocalSubsWithLowMaxPending server/routes_test.go:1098 + [Fact] + public async Task Subscriptions_propagate_with_many_subscribers() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + + var subs = new List(); + for (var i = 0; i < 20; i++) + { + var sub = await nc.SubscribeCoreAsync($"local.sub.{i}"); + subs.Add(sub); + } + + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("local.sub.0"), 10000); + await WaitForCondition(() => b.Server.HasRemoteInterest("local.sub.19"), 10000); + + b.Server.HasRemoteInterest("local.sub.0").ShouldBeTrue(); + b.Server.HasRemoteInterest("local.sub.19").ShouldBeTrue(); + + foreach (var sub in subs) + await sub.DisposeAsync(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteCloseTLSConnection server/routes_test.go:1290 (basic close test, no TLS) + [Fact] + public async Task Route_connection_close_decrements_stats() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + + // Stop B - A's route count should drop + await b.Cts.CancelAsync(); + b.Server.Dispose(); + b.Cts.Dispose(); + + await WaitForCondition(() => Interlocked.Read(ref a.Server.Stats.Routes) == 0, 5000); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBe(0); + } + finally + { + await a.Cts.CancelAsync(); + a.Server.Dispose(); + a.Cts.Dispose(); + } + } + + // Go: TestRouteDuplicateServerName server/routes_test.go:1444 + [Fact] + public async Task Cluster_with_different_server_ids_form_routes() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = MakeClusterOpts(cluster); + optsA.ServerName = "server-alpha"; + var a = await StartServerAsync(optsA); + + var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); + optsB.ServerName = "server-beta"; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + a.Server.ServerName.ShouldBe("server-alpha"); + b.Server.ServerName.ShouldBe("server-beta"); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteIPResolutionAndRouteToSelf server/routes_test.go:1415 + [Fact] + public void Server_without_cluster_has_null_cluster_listen() + { + var opts = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + }; + var server = new NatsServer(opts, NullLoggerFactory.Instance); + server.ClusterListen.ShouldBeNull(); + server.Dispose(); + } + + // Go: TestBlockedShutdownOnRouteAcceptLoopFailure server/routes_test.go:634 + [Fact] + public async Task Server_with_cluster_can_be_shut_down_cleanly() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + + await a.Cts.CancelAsync(); + a.Server.Dispose(); + a.Cts.Dispose(); + // If we get here without timeout, shutdown worked properly + } + + // Go: TestRoutePings server/routes_test.go:4376 + [Fact] + public async Task Route_stays_alive_with_periodic_activity() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + // Route stays alive after some time + await Task.Delay(500); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + Interlocked.Read(ref b.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestServerRoutesWithClients server/routes_test.go:216 + [Fact] + public async Task Multiple_messages_flow_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = MakeClusterOpts(cluster); + optsA.Cluster!.PoolSize = 1; + var a = await StartServerAsync(optsA); + var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); + optsB.Cluster!.PoolSize = 1; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("multi.msg"); + await subscriber.PingAsync(); + await WaitForCondition(() => b.Server.HasRemoteInterest("multi.msg")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + + for (var i = 0; i < 10; i++) + { + await publisher.PublishAsync("multi.msg", $"msg-{i}"); + } + + var received = new HashSet(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + for (var i = 0; i < 10; i++) + { + var msg = await sub.Msgs.ReadAsync(timeout.Token); + received.Add(msg.Data!); + } + + received.Count.ShouldBe(10); + for (var i = 0; i < 10; i++) + received.ShouldContain($"msg-{i}"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRouteClusterNameConflictBetweenStaticAndDynamic server/routes_test.go:1374 + [Fact] + public async Task Route_with_named_cluster_forms_correctly() + { + var cluster = "named-cluster-test"; + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + Interlocked.Read(ref a.Server.Stats.Routes).ShouldBeGreaterThan(0); + } + finally + { + await DisposeServers(a, b); + } + } + + // -- Wire-level helpers -- + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, ct); + if (read == 0) break; + if (single[0] == (byte)'\n') break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); +} diff --git a/tests/NATS.Server.Tests/Routes/RouteForwardingTests.cs b/tests/NATS.Server.Tests/Routes/RouteForwardingTests.cs new file mode 100644 index 0000000..258c707 --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteForwardingTests.cs @@ -0,0 +1,820 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server.Auth; +using NATS.Server.Configuration; +using NATS.Server.Routes; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Routes; + +/// +/// Tests for route message forwarding (RMSG), reply propagation, payload delivery, +/// and cross-cluster message routing. +/// Ported from Go: server/routes_test.go. +/// +public class RouteForwardingTests +{ + // -- Helpers -- + + private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync( + NatsOptions opts) + { + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + return (server, cts); + } + + private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null) + { + return new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = clusterName ?? Guid.NewGuid().ToString("N"), + Host = "127.0.0.1", + Port = 0, + Routes = seed is null ? [] : [seed], + }, + }; + } + + private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutMs = 5000) + { + using var timeout = new CancellationTokenSource(timeoutMs); + while (!timeout.IsCancellationRequested && + (Interlocked.Read(ref a.Stats.Routes) == 0 || + Interlocked.Read(ref b.Stats.Routes) == 0)) + { + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + } + + private static async Task WaitForCondition(Func predicate, int timeoutMs = 5000) + { + using var cts = new CancellationTokenSource(timeoutMs); + while (!cts.IsCancellationRequested) + { + if (predicate()) return; + await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException("Condition not met."); + } + + private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers) + { + foreach (var (server, cts) in servers) + { + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + } + + // -- Tests: RMSG forwarding -- + + // Go: TestSeedSolicitWorks server/routes_test.go:365 (message forwarding) + [Fact] + public async Task RMSG_forwards_published_message_to_remote_subscriber() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("rmsg.test"); + await subscriber.PingAsync(); + await WaitForCondition(() => b.Server.HasRemoteInterest("rmsg.test")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + await publisher.PublishAsync("rmsg.test", "routed-payload"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("routed-payload"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: Request-Reply across routes via raw socket with reply-to + [Fact] + public async Task Request_reply_works_across_routed_servers() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = MakeClusterOpts(cluster); + optsA.Cluster!.PoolSize = 1; + var a = await StartServerAsync(optsA); + var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); + optsB.Cluster!.PoolSize = 1; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + // Responder on server A: subscribe via raw socket to get exact wire control + using var responderSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await responderSock.ConnectAsync(IPAddress.Loopback, a.Server.Port); + var buf = new byte[4096]; + _ = await responderSock.ReceiveAsync(buf); // INFO + await responderSock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB service.echo 1\r\nPING\r\n")); + await ReadUntilAsync(responderSock, "PONG"); + await WaitForCondition(() => b.Server.HasRemoteInterest("service.echo")); + + // Requester on server B: subscribe to reply inbox via raw socket + using var requesterSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await requesterSock.ConnectAsync(IPAddress.Loopback, b.Server.Port); + _ = await requesterSock.ReceiveAsync(buf); // INFO + var replyInbox = $"_INBOX.{Guid.NewGuid():N}"; + await requesterSock.SendAsync(Encoding.ASCII.GetBytes( + $"CONNECT {{}}\r\nSUB {replyInbox} 2\r\nPING\r\n")); + await ReadUntilAsync(requesterSock, "PONG"); + await WaitForCondition(() => a.Server.HasRemoteInterest(replyInbox)); + + // Publish request with reply-to from B + await requesterSock.SendAsync(Encoding.ASCII.GetBytes( + $"PUB service.echo {replyInbox} 4\r\nping\r\nPING\r\n")); + await ReadUntilAsync(requesterSock, "PONG"); + + // Read the request on A, verify reply-to + var requestData = await ReadUntilAsync(responderSock, "ping"); + requestData.ShouldContain($"MSG service.echo 1 {replyInbox} 4"); + requestData.ShouldContain("ping"); + + // Publish reply from A to the reply-to subject + await responderSock.SendAsync(Encoding.ASCII.GetBytes( + $"PUB {replyInbox} 4\r\npong\r\nPING\r\n")); + await ReadUntilAsync(responderSock, "PONG"); + + // Read the reply on B + var replyData = await ReadUntilAsync(requesterSock, "pong"); + replyData.ShouldContain($"MSG {replyInbox} 2 4"); + replyData.ShouldContain("pong"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: RMSG wire-level parsing + [Fact] + public async Task RMSG_wire_frame_delivers_payload_to_handler() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + RouteMessage? receivedMsg = null; + route.RoutedMessageReceived = msg => + { + receivedMsg = msg; + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + var payload = "hello-world"; + var frame = $"RMSG $G test.subject - {payload.Length}\r\n{payload}\r\n"; + await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token); + + await WaitForCondition(() => receivedMsg != null); + receivedMsg.ShouldNotBeNull(); + receivedMsg!.Subject.ShouldBe("test.subject"); + receivedMsg.ReplyTo.ShouldBeNull(); + Encoding.UTF8.GetString(receivedMsg.Payload.Span).ShouldBe("hello-world"); + } + + // Go: RMSG with reply subject + [Fact] + public async Task RMSG_wire_frame_includes_reply_to() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + RouteMessage? receivedMsg = null; + route.RoutedMessageReceived = msg => + { + receivedMsg = msg; + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + var payload = "data"; + var frame = $"RMSG $G test.subject _INBOX.abc123 {payload.Length}\r\n{payload}\r\n"; + await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token); + + await WaitForCondition(() => receivedMsg != null); + receivedMsg.ShouldNotBeNull(); + receivedMsg!.Subject.ShouldBe("test.subject"); + receivedMsg.ReplyTo.ShouldBe("_INBOX.abc123"); + } + + // Go: RMSG with account + [Fact] + public async Task RMSG_wire_frame_with_account_scope() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + RouteMessage? receivedMsg = null; + route.RoutedMessageReceived = msg => + { + receivedMsg = msg; + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + var payload = "acct-data"; + var frame = $"RMSG MYACCOUNT test.sub - {payload.Length}\r\n{payload}\r\n"; + await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token); + + await WaitForCondition(() => receivedMsg != null); + receivedMsg.ShouldNotBeNull(); + receivedMsg!.Account.ShouldBe("MYACCOUNT"); + receivedMsg.Subject.ShouldBe("test.sub"); + } + + // Go: RMSG with zero-length payload + [Fact] + public async Task RMSG_wire_frame_with_empty_payload() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + RouteMessage? receivedMsg = null; + route.RoutedMessageReceived = msg => + { + receivedMsg = msg; + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + var frame = "RMSG $G empty.test - 0\r\n\r\n"; + await remote.SendAsync(Encoding.ASCII.GetBytes(frame), SocketFlags.None, timeout.Token); + + await WaitForCondition(() => receivedMsg != null); + receivedMsg.ShouldNotBeNull(); + receivedMsg!.Subject.ShouldBe("empty.test"); + receivedMsg.Payload.Length.ShouldBe(0); + } + + // Go: TestServerRoutesWithClients server/routes_test.go:216 (large payload) + [Fact] + public async Task Large_payload_forwarded_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("large.payload"); + await subscriber.PingAsync(); + await WaitForCondition(() => b.Server.HasRemoteInterest("large.payload")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + + var data = new byte[8192]; + Random.Shared.NextBytes(data); + await publisher.PublishAsync("large.payload", data); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe(data); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePool server/routes_test.go:1966 (message sent and received across pool) + [Fact] + public async Task Messages_flow_across_route_with_pool_size() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 2, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + PoolSize = 2, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("pool.forward"); + await subscriber.PingAsync(); + await WaitForCondition(() => a.Server.HasRemoteInterest("pool.forward")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await publisher.ConnectAsync(); + + const int messageCount = 10; + for (var i = 0; i < messageCount; i++) + await publisher.PublishAsync("pool.forward", $"msg-{i}"); + + // With PoolSize=2, each message may be forwarded on multiple route connections. + // Collect all received messages and verify each expected one arrived at least once. + var received = new HashSet(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (received.Count < messageCount) + { + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldNotBeNull(); + received.Add(msg.Data!); + } + + for (var i = 0; i < messageCount; i++) + received.ShouldContain($"msg-{i}"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePerAccount server/routes_test.go:2539 (account-scoped delivery) + [Fact] + public async Task Account_scoped_RMSG_delivers_to_correct_account() + { + var users = new User[] + { + new() { Username = "ua", Password = "p", Account = "A" }, + new() { Username = "ub", Password = "p", Account = "B" }, + }; + var cluster = Guid.NewGuid().ToString("N"); + + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + // Account A subscriber on server B + await using var subConn = new NatsConnection(new NatsOpts + { + Url = $"nats://ua:p@127.0.0.1:{b.Server.Port}", + }); + await subConn.ConnectAsync(); + await using var sub = await subConn.SubscribeCoreAsync("acct.fwd"); + await subConn.PingAsync(); + + await WaitForCondition(() => a.Server.HasRemoteInterest("A", "acct.fwd")); + + // Publish from account A on server A + await using var pubConn = new NatsConnection(new NatsOpts + { + Url = $"nats://ua:p@127.0.0.1:{a.Server.Port}", + }); + await pubConn.ConnectAsync(); + await pubConn.PublishAsync("acct.fwd", "from-a"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("from-a"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: bidirectional forwarding + [Fact] + public async Task Bidirectional_message_forwarding_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var ncA = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await ncA.ConnectAsync(); + + await using var ncB = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await ncB.ConnectAsync(); + + // Sub on A, pub from B + await using var subOnA = await ncA.SubscribeCoreAsync("bidir.a"); + // Sub on B, pub from A + await using var subOnB = await ncB.SubscribeCoreAsync("bidir.b"); + await ncA.PingAsync(); + await ncB.PingAsync(); + + await WaitForCondition(() => + b.Server.HasRemoteInterest("bidir.a") && a.Server.HasRemoteInterest("bidir.b")); + + await ncB.PublishAsync("bidir.a", "from-b"); + await ncA.PublishAsync("bidir.b", "from-a"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msgA = await subOnA.Msgs.ReadAsync(timeout.Token); + var msgB = await subOnB.Msgs.ReadAsync(timeout.Token); + msgA.Data.ShouldBe("from-b"); + msgB.Data.ShouldBe("from-a"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: Route forwarding with reply (non-request-reply, just reply subject) + [Fact] + public async Task Message_with_reply_subject_forwarded_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var subscriber = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await subscriber.ConnectAsync(); + await using var sub = await subscriber.SubscribeCoreAsync("reply.subject.test"); + await subscriber.PingAsync(); + await WaitForCondition(() => b.Server.HasRemoteInterest("reply.subject.test")); + + // Use raw socket to publish with reply-to + using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(IPAddress.Loopback, b.Server.Port); + var buf = new byte[4096]; + _ = await sock.ReceiveAsync(buf); // INFO + await sock.SendAsync(Encoding.ASCII.GetBytes( + "CONNECT {}\r\nPUB reply.subject.test _INBOX.reply123 5\r\nHello\r\nPING\r\n")); + await ReadUntilAsync(sock, "PONG"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("Hello"); + msg.ReplyTo.ShouldBe("_INBOX.reply123"); + sock.Dispose(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: Multiple messages with varying payloads + [Fact] + public async Task Multiple_different_subjects_forwarded_simultaneously() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var ncA = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await ncA.ConnectAsync(); + + await using var sub1 = await ncA.SubscribeCoreAsync("multi.a"); + await using var sub2 = await ncA.SubscribeCoreAsync("multi.b"); + await using var sub3 = await ncA.SubscribeCoreAsync("multi.c"); + await ncA.PingAsync(); + + await WaitForCondition(() => + b.Server.HasRemoteInterest("multi.a") && + b.Server.HasRemoteInterest("multi.b") && + b.Server.HasRemoteInterest("multi.c")); + + await using var pub = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await pub.ConnectAsync(); + await pub.PublishAsync("multi.a", "alpha"); + await pub.PublishAsync("multi.b", "beta"); + await pub.PublishAsync("multi.c", "gamma"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msgA = await sub1.Msgs.ReadAsync(timeout.Token); + var msgB = await sub2.Msgs.ReadAsync(timeout.Token); + var msgC = await sub3.Msgs.ReadAsync(timeout.Token); + + msgA.Data.ShouldBe("alpha"); + msgB.Data.ShouldBe("beta"); + msgC.Data.ShouldBe("gamma"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: SendRmsgAsync (send RMSG on RouteConnection) + [Fact] + public async Task RouteConnection_SendRmsgAsync_sends_valid_wire_frame() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + var payload = Encoding.UTF8.GetBytes("test-payload"); + await route.SendRmsgAsync("$G", "subject.test", "_INBOX.reply", payload, timeout.Token); + + // Read the RMSG frame from the remote side, waiting until expected content arrives + var data = await ReadUntilAsync(remote, "test-payload"); + data.ShouldContain("RMSG $G subject.test _INBOX.reply 12"); + data.ShouldContain("test-payload"); + } + + // Go: SendRsPlusAsync (send RS+ on RouteConnection) + [Fact] + public async Task RouteConnection_SendRsPlusAsync_sends_valid_wire_frame() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + await route.SendRsPlusAsync("$G", "foo.bar", null, timeout.Token); + var data = await ReadAllAvailableAsync(remote, timeout.Token); + data.ShouldContain("RS+ $G foo.bar"); + } + + // Go: SendRsMinusAsync (send RS- on RouteConnection) + [Fact] + public async Task RouteConnection_SendRsMinusAsync_sends_valid_wire_frame() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + await route.SendRsMinusAsync("$G", "foo.bar", null, timeout.Token); + var data = await ReadAllAvailableAsync(remote, timeout.Token); + data.ShouldContain("RS- $G foo.bar"); + } + + // Go: SendRsPlusAsync with queue + [Fact] + public async Task RouteConnection_SendRsPlusAsync_with_queue_sends_valid_frame() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + await route.SendRsPlusAsync("ACCT_A", "foo.bar", "myqueue", timeout.Token); + var data = await ReadAllAvailableAsync(remote, timeout.Token); + data.ShouldContain("RS+ ACCT_A foo.bar myqueue"); + } + + // -- Wire-level helpers -- + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, ct); + if (read == 0) break; + if (single[0] == (byte)'\n') break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); + + private static async Task ReadAllAvailableAsync(Socket socket, CancellationToken ct) + { + var sb = new StringBuilder(); + var buf = new byte[4096]; + + // First read blocks until at least some data arrives + var n = await socket.ReceiveAsync(buf, SocketFlags.None, ct); + if (n > 0) + sb.Append(Encoding.ASCII.GetString(buf, 0, n)); + + // Drain any additional data that's already buffered + while (n == buf.Length && socket.Available > 0) + { + n = await socket.ReceiveAsync(buf, SocketFlags.None, ct); + if (n == 0) break; + sb.Append(Encoding.ASCII.GetString(buf, 0, n)); + } + + return sb.ToString(); + } + + private static async Task ReadUntilAsync(Socket sock, string expected) + { + var sb = new StringBuilder(); + var buf = new byte[4096]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!sb.ToString().Contains(expected, StringComparison.Ordinal)) + { + var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); + if (n == 0) break; + sb.Append(Encoding.ASCII.GetString(buf, 0, n)); + } + + return sb.ToString(); + } +} diff --git a/tests/NATS.Server.Tests/Routes/RouteSubscriptionTests.cs b/tests/NATS.Server.Tests/Routes/RouteSubscriptionTests.cs new file mode 100644 index 0000000..b702fe8 --- /dev/null +++ b/tests/NATS.Server.Tests/Routes/RouteSubscriptionTests.cs @@ -0,0 +1,851 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Client.Core; +using NATS.Server.Auth; +using NATS.Server.Configuration; +using NATS.Server.Routes; +using NATS.Server.Subscriptions; + +namespace NATS.Server.Tests.Routes; + +/// +/// Tests for route subscription propagation: RS+/RS-, wildcard subs, queue subs, +/// unsubscribe propagation, and account-scoped interest. +/// Ported from Go: server/routes_test.go. +/// +public class RouteSubscriptionTests +{ + // -- Helpers -- + + private static async Task<(NatsServer Server, CancellationTokenSource Cts)> StartServerAsync( + NatsOptions opts) + { + var server = new NatsServer(opts, NullLoggerFactory.Instance); + var cts = new CancellationTokenSource(); + _ = server.StartAsync(cts.Token); + await server.WaitForReadyAsync(); + return (server, cts); + } + + private static NatsOptions MakeClusterOpts(string? clusterName = null, string? seed = null) + { + return new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Cluster = new ClusterOptions + { + Name = clusterName ?? Guid.NewGuid().ToString("N"), + Host = "127.0.0.1", + Port = 0, + Routes = seed is null ? [] : [seed], + }, + }; + } + + private static async Task WaitForRouteFormation(NatsServer a, NatsServer b, int timeoutMs = 5000) + { + using var timeout = new CancellationTokenSource(timeoutMs); + while (!timeout.IsCancellationRequested && + (Interlocked.Read(ref a.Stats.Routes) == 0 || + Interlocked.Read(ref b.Stats.Routes) == 0)) + { + await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + } + + private static async Task WaitForCondition(Func predicate, int timeoutMs = 5000) + { + using var cts = new CancellationTokenSource(timeoutMs); + while (!cts.IsCancellationRequested) + { + if (predicate()) return; + await Task.Delay(20, cts.Token).ContinueWith(_ => { }, TaskScheduler.Default); + } + + throw new TimeoutException("Condition not met."); + } + + private static async Task DisposeServers(params (NatsServer Server, CancellationTokenSource Cts)[] servers) + { + foreach (var (server, cts) in servers) + { + await cts.CancelAsync(); + server.Dispose(); + cts.Dispose(); + } + } + + // -- Tests: RS+ propagation -- + + // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (plain sub) + [Fact] + public async Task Plain_subscription_propagates_remote_interest() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + await using var sub = await nc.SubscribeCoreAsync("sub.test"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("sub.test")); + b.Server.HasRemoteInterest("sub.test").ShouldBeTrue(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (wildcard * sub) + [Fact] + public async Task Wildcard_star_subscription_propagates_remote_interest() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + await using var sub = await nc.SubscribeCoreAsync("wildcard.*"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("wildcard.test")); + b.Server.HasRemoteInterest("wildcard.test").ShouldBeTrue(); + b.Server.HasRemoteInterest("wildcard.other").ShouldBeTrue(); + b.Server.HasRemoteInterest("no.match").ShouldBeFalse(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (wildcard > sub) + [Fact] + public async Task Wildcard_gt_subscription_propagates_remote_interest() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + await using var sub = await nc.SubscribeCoreAsync("events.>"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("events.a")); + b.Server.HasRemoteInterest("events.a").ShouldBeTrue(); + b.Server.HasRemoteInterest("events.a.b.c").ShouldBeTrue(); + b.Server.HasRemoteInterest("other.a").ShouldBeFalse(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (unsub) + [Fact] + public async Task Unsubscribe_removes_remote_interest() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + var sub = await nc.SubscribeCoreAsync("unsub.test"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("unsub.test")); + b.Server.HasRemoteInterest("unsub.test").ShouldBeTrue(); + + await sub.DisposeAsync(); + await nc.PingAsync(); + + // Wait for interest to be removed + await WaitForCondition(() => !b.Server.HasRemoteInterest("unsub.test")); + b.Server.HasRemoteInterest("unsub.test").ShouldBeFalse(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: RS+ wire protocol parsing (low-level) + [Fact] + public async Task RSplus_frame_registers_remote_interest_via_wire() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + using var subList = new SubList(); + route.RemoteSubscriptionReceived = sub => + { + subList.ApplyRemoteSub(sub); + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + await WriteLineAsync(remote, "RS+ $G foo.bar", timeout.Token); + await WaitForCondition(() => subList.HasRemoteInterest("foo.bar")); + subList.HasRemoteInterest("foo.bar").ShouldBeTrue(); + } + + // Go: RS- wire protocol parsing (low-level) + [Fact] + public async Task RSminus_frame_removes_remote_interest_via_wire() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + using var subList = new SubList(); + route.RemoteSubscriptionReceived = sub => + { + subList.ApplyRemoteSub(sub); + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + await WriteLineAsync(remote, "RS+ $G foo.*", timeout.Token); + await WaitForCondition(() => subList.HasRemoteInterest("foo.bar")); + + await WriteLineAsync(remote, "RS- $G foo.*", timeout.Token); + await WaitForCondition(() => !subList.HasRemoteInterest("foo.bar")); + subList.HasRemoteInterest("foo.bar").ShouldBeFalse(); + } + + // Go: RS+ with queue group + [Fact] + public async Task RSplus_with_queue_group_registers_remote_interest() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + RemoteSubscription? received = null; + route.RemoteSubscriptionReceived = sub => + { + received = sub; + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + await WriteLineAsync(remote, "RS+ $G foo.bar myqueue", timeout.Token); + await WaitForCondition(() => received != null); + + received.ShouldNotBeNull(); + received!.Subject.ShouldBe("foo.bar"); + received.Queue.ShouldBe("myqueue"); + } + + // Go: RS+ with account scope + [Fact] + public async Task RSplus_with_account_scope_registers_interest_in_account() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + using var remote = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await remote.ConnectAsync(IPAddress.Loopback, port); + using var routeSock = await listener.AcceptSocketAsync(); + await using var route = new RouteConnection(routeSock); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + var handshakeTask = route.PerformOutboundHandshakeAsync("LOCAL", timeout.Token); + _ = await ReadLineAsync(remote, timeout.Token); + await WriteLineAsync(remote, "ROUTE REMOTE", timeout.Token); + await handshakeTask; + + using var subList = new SubList(); + route.RemoteSubscriptionReceived = sub => + { + subList.ApplyRemoteSub(sub); + return Task.CompletedTask; + }; + route.StartFrameLoop(timeout.Token); + + await WriteLineAsync(remote, "RS+ ACCT_A orders.created", timeout.Token); + await WaitForCondition(() => subList.HasRemoteInterest("ACCT_A", "orders.created")); + subList.HasRemoteInterest("ACCT_A", "orders.created").ShouldBeTrue(); + } + + // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 + [Fact] + public async Task Queue_subscription_propagates_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await sock.ConnectAsync(IPAddress.Loopback, a.Server.Port); + _ = await ReadLineAsync(sock, default); + await sock.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo queue1 1\r\nPING\r\n")); + await ReadUntilAsync(sock, "PONG"); + + await WaitForCondition(() => b.Server.HasRemoteInterest("foo")); + b.Server.HasRemoteInterest("foo").ShouldBeTrue(); + sock.Dispose(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePoolPerAccountSubUnsubProtoParsing server/routes_test.go:3104 (queue unsub) + [Fact] + public async Task Queue_subscription_delivery_picks_one_per_group() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = MakeClusterOpts(cluster); + optsA.Cluster!.PoolSize = 1; + var a = await StartServerAsync(optsA); + var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); + optsB.Cluster!.PoolSize = 1; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc1 = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc1.ConnectAsync(); + + await using var nc2 = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc2.ConnectAsync(); + + await using var sub1 = await nc1.SubscribeCoreAsync("queue.test", queueGroup: "grp"); + await using var sub2 = await nc2.SubscribeCoreAsync("queue.test", queueGroup: "grp"); + await nc1.PingAsync(); + await nc2.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("queue.test")); + + await using var publisher = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await publisher.ConnectAsync(); + + // Send 10 messages. Each should go to exactly one queue member. + for (var i = 0; i < 10; i++) + await publisher.PublishAsync("queue.test", $"qmsg-{i}"); + + // Collect messages from both subscribers + var received = 0; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + + async Task CollectMessages(INatsSub sub) + { + try + { + while (!timeout.IsCancellationRequested) + { + _ = await sub.Msgs.ReadAsync(timeout.Token); + Interlocked.Increment(ref received); + } + } + catch (OperationCanceledException) { } + } + + var t1 = CollectMessages(sub1); + var t2 = CollectMessages(sub2); + + // Wait for all messages + await WaitForCondition(() => Volatile.Read(ref received) >= 10, 5000); + + // Total received should be exactly 10 (one per message) + Volatile.Read(ref received).ShouldBe(10); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: Interest propagation for multiple subjects + [Fact] + public async Task Multiple_subjects_propagate_independently() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + + await using var sub1 = await nc.SubscribeCoreAsync("alpha"); + await using var sub2 = await nc.SubscribeCoreAsync("beta"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("alpha") && b.Server.HasRemoteInterest("beta")); + b.Server.HasRemoteInterest("alpha").ShouldBeTrue(); + b.Server.HasRemoteInterest("beta").ShouldBeTrue(); + b.Server.HasRemoteInterest("gamma").ShouldBeFalse(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: RS+ account scope with NatsClient auth + [Fact] + public async Task Account_scoped_subscription_propagates_remote_interest() + { + var users = new User[] + { + new() { Username = "user_a", Password = "pass", Account = "A" }, + new() { Username = "user_b", Password = "pass", Account = "B" }, + }; + var cluster = Guid.NewGuid().ToString("N"); + + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://user_a:pass@127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + await using var sub = await nc.SubscribeCoreAsync("acct.sub"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("A", "acct.sub")); + b.Server.HasRemoteInterest("A", "acct.sub").ShouldBeTrue(); + // Account B should NOT have interest + b.Server.HasRemoteInterest("B", "acct.sub").ShouldBeFalse(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: TestRoutePerAccount server/routes_test.go:2539 + [Fact] + public async Task Account_scoped_messages_do_not_leak_to_other_accounts() + { + var users = new User[] + { + new() { Username = "ua", Password = "p", Account = "A" }, + new() { Username = "ub", Password = "p", Account = "B" }, + }; + var cluster = Guid.NewGuid().ToString("N"); + + var optsA = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + }, + }; + var a = await StartServerAsync(optsA); + + var optsB = new NatsOptions + { + Host = "127.0.0.1", + Port = 0, + Users = users, + Cluster = new ClusterOptions + { + Name = cluster, + Host = "127.0.0.1", + Port = 0, + Routes = [a.Server.ClusterListen!], + }, + }; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + // Subscribe in account A on server B + await using var subA = new NatsConnection(new NatsOpts + { + Url = $"nats://ua:p@127.0.0.1:{b.Server.Port}", + }); + await subA.ConnectAsync(); + await using var sub = await subA.SubscribeCoreAsync("isolation.test"); + await subA.PingAsync(); + + // Subscribe in account B on server B + await using var subB = new NatsConnection(new NatsOpts + { + Url = $"nats://ub:p@127.0.0.1:{b.Server.Port}", + }); + await subB.ConnectAsync(); + await using var subBSub = await subB.SubscribeCoreAsync("isolation.test"); + await subB.PingAsync(); + + await WaitForCondition(() => a.Server.HasRemoteInterest("A", "isolation.test")); + + // Publish in account A from server A + await using var pub = new NatsConnection(new NatsOpts + { + Url = $"nats://ua:p@127.0.0.1:{a.Server.Port}", + }); + await pub.ConnectAsync(); + await pub.PublishAsync("isolation.test", "for-account-a"); + + // Account A subscriber should receive the message + using var receiveTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(receiveTimeout.Token); + msg.Data.ShouldBe("for-account-a"); + + // Account B subscriber should NOT receive it + using var leakTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(500)); + await Should.ThrowAsync(async () => + await subBSub.Msgs.ReadAsync(leakTimeout.Token)); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: Subscriber disconnect removes interest + [Fact] + public async Task Client_disconnect_removes_remote_interest() + { + var cluster = Guid.NewGuid().ToString("N"); + var optsA = MakeClusterOpts(cluster); + optsA.Cluster!.PoolSize = 1; + var a = await StartServerAsync(optsA); + var optsB = MakeClusterOpts(cluster, a.Server.ClusterListen!); + optsB.Cluster!.PoolSize = 1; + var b = await StartServerAsync(optsB); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + var sub = await nc.SubscribeCoreAsync("disconnect.test"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("disconnect.test")); + b.Server.HasRemoteInterest("disconnect.test").ShouldBeTrue(); + + // Unsubscribe and disconnect the client + await sub.DisposeAsync(); + await nc.PingAsync(); + await nc.DisposeAsync(); + + // Interest should be removed (give extra time for propagation) + await WaitForCondition(() => !b.Server.HasRemoteInterest("disconnect.test"), 15000); + b.Server.HasRemoteInterest("disconnect.test").ShouldBeFalse(); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: Interest idempotency + [Fact] + public async Task Duplicate_subscription_on_same_subject_does_not_double_count() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc1 = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc1.ConnectAsync(); + + await using var nc2 = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc2.ConnectAsync(); + + await using var sub1 = await nc1.SubscribeCoreAsync("dup.test"); + await using var sub2 = await nc2.SubscribeCoreAsync("dup.test"); + await nc1.PingAsync(); + await nc2.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("dup.test")); + + // Publish from B; should be delivered to both local subscribers on A + await using var pub = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await pub.ConnectAsync(); + await pub.PublishAsync("dup.test", "to-both"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg1 = await sub1.Msgs.ReadAsync(timeout.Token); + var msg2 = await sub2.Msgs.ReadAsync(timeout.Token); + msg1.Data.ShouldBe("to-both"); + msg2.Data.ShouldBe("to-both"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: Wildcard delivery + [Fact] + public async Task Wildcard_subscription_delivers_matching_messages_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + await using var sub = await nc.SubscribeCoreAsync("data.>"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("data.sensor.1")); + + await using var pub = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await pub.ConnectAsync(); + await pub.PublishAsync("data.sensor.1", "reading"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Subject.ShouldBe("data.sensor.1"); + msg.Data.ShouldBe("reading"); + } + finally + { + await DisposeServers(a, b); + } + } + + // Go: No messages for non-matching subjects + [Fact] + public async Task Non_matching_subject_not_forwarded_across_route() + { + var cluster = Guid.NewGuid().ToString("N"); + var a = await StartServerAsync(MakeClusterOpts(cluster)); + var b = await StartServerAsync(MakeClusterOpts(cluster, a.Server.ClusterListen!)); + + try + { + await WaitForRouteFormation(a.Server, b.Server); + + await using var nc = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{a.Server.Port}", + }); + await nc.ConnectAsync(); + await using var sub = await nc.SubscribeCoreAsync("specific.topic"); + await nc.PingAsync(); + + await WaitForCondition(() => b.Server.HasRemoteInterest("specific.topic")); + + await using var pub = new NatsConnection(new NatsOpts + { + Url = $"nats://127.0.0.1:{b.Server.Port}", + }); + await pub.ConnectAsync(); + + // Publish to a non-matching subject + await pub.PublishAsync("other.topic", "should-not-arrive"); + // Publish to the matching subject + await pub.PublishAsync("specific.topic", "should-arrive"); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var msg = await sub.Msgs.ReadAsync(timeout.Token); + msg.Data.ShouldBe("should-arrive"); + } + finally + { + await DisposeServers(a, b); + } + } + + // -- Wire-level helpers -- + + private static async Task ReadLineAsync(Socket socket, CancellationToken ct) + { + var bytes = new List(64); + var single = new byte[1]; + using var cts = ct.CanBeNone() ? new CancellationTokenSource(TimeSpan.FromSeconds(5)) : null; + var effectiveCt = cts?.Token ?? ct; + while (true) + { + var read = await socket.ReceiveAsync(single, SocketFlags.None, effectiveCt); + if (read == 0) break; + if (single[0] == (byte)'\n') break; + if (single[0] != (byte)'\r') + bytes.Add(single[0]); + } + + return Encoding.ASCII.GetString([.. bytes]); + } + + private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct) + => socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask(); + + private static async Task ReadUntilAsync(Socket sock, string expected) + { + var sb = new StringBuilder(); + var buf = new byte[4096]; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (!sb.ToString().Contains(expected, StringComparison.Ordinal)) + { + var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); + if (n == 0) break; + sb.Append(Encoding.ASCII.GetString(buf, 0, n)); + } + + return sb.ToString(); + } +} + +file static class CancellationTokenExtensions +{ + public static bool CanBeNone(this CancellationToken ct) => ct == default; +}