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); } } }