Add comprehensive Go-parity test coverage across 5 subsystems: - Accounts/Auth: isolation, import/export, auth mechanisms, permissions (82 tests) - Gateways: connection, forwarding, interest mode, config (106 tests) - Routes: connection, subscription, forwarding, config validation (78 tests) - JetStream API: stream/consumer CRUD, pub/sub, features, admin (234 tests) - JetStream Cluster: streams, consumers, failover, meta (108 tests) Total: ~608 new test annotations across 22 files (+13,844 lines) All tests pass individually; suite total: 2,283 passing, 3 skipped
565 lines
17 KiB
C#
565 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tests for route configuration validation, compression options, topology gossip,
|
|
/// connect info JSON, and route manager behavior.
|
|
/// Ported from Go: server/routes_test.go.
|
|
/// </summary>
|
|
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<bool> 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<RouteCompression>("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<byte>();
|
|
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<RouteManager>.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<RouteManager>.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<string>("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);
|
|
}
|
|
}
|
|
}
|