// Port of Go client_test.go: TestClientConnect, TestClientConnectProto, TestAuthorizationTimeout // Reference: golang/nats-server/server/client_test.go lines 475, 537, 1260 using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Server; namespace NATS.Server.Tests; /// /// Tests for client lifecycle: connection handshake, CONNECT proto parsing, /// subscription limits, and auth timeout enforcement. /// Reference: Go TestClientConnect, TestClientConnectProto, TestAuthorizationTimeout /// public class ClientLifecycleTests { 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 ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000) { using var cts = new CancellationTokenSource(timeoutMs); var sb = new StringBuilder(); var buf = new byte[4096]; while (!sb.ToString().Contains(expected)) { 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(); } /// /// TestClientConnectProto: Sends CONNECT with verbose:false, pedantic:false, name:"test-client" /// and verifies the server responds with PONG, confirming the connection is accepted. /// Reference: Go client_test.go TestClientConnectProto (line 537) /// [Fact] public async Task Connect_proto_accepted() { var port = GetFreePort(); using var cts = new CancellationTokenSource(); var server = new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(IPAddress.Loopback, port); // Read INFO var buf = new byte[4096]; var n = await client.ReceiveAsync(buf, SocketFlags.None); var info = Encoding.ASCII.GetString(buf, 0, n); info.ShouldStartWith("INFO "); // Send CONNECT with client name, then PING to flush var connectMsg = """CONNECT {"verbose":false,"pedantic":false,"name":"test-client"}""" + "\r\nPING\r\n"; await client.SendAsync(Encoding.ASCII.GetBytes(connectMsg)); // Should receive PONG confirming connection is accepted var response = await ReadUntilAsync(client, "PONG"); response.ShouldContain("PONG\r\n"); } finally { await cts.CancelAsync(); server.Dispose(); } } /// /// Max_subscriptions_enforced: Creates a server with MaxSubs=10, subscribes 10 times, /// then verifies that the 11th SUB triggers a -ERR 'Maximum Subscriptions Exceeded' /// and the connection is closed. /// Reference: Go client_test.go — MaxSubs enforcement in NatsClient.cs line 527 /// [Fact] public async Task Max_subscriptions_enforced() { const int maxSubs = 10; var port = GetFreePort(); using var cts = new CancellationTokenSource(); var server = new NatsServer( new NatsOptions { Port = port, MaxSubs = maxSubs }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(IPAddress.Loopback, port); // Read INFO var buf = new byte[4096]; await client.ReceiveAsync(buf, SocketFlags.None); // Send CONNECT await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n")); // Subscribe up to the limit var subsBuilder = new StringBuilder(); for (int i = 1; i <= maxSubs; i++) { subsBuilder.Append($"SUB foo.{i} {i}\r\n"); } // Send the 11th subscription (one over the limit) subsBuilder.Append($"SUB foo.overflow {maxSubs + 1}\r\n"); await client.SendAsync(Encoding.ASCII.GetBytes(subsBuilder.ToString())); // Server should send -ERR 'Maximum Subscriptions Exceeded' and close var response = await ReadUntilAsync(client, "-ERR", timeoutMs: 5000); response.ShouldContain("-ERR 'Maximum Subscriptions Exceeded'"); // Connection should be closed after the error using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); var n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token); n.ShouldBe(0); } finally { await cts.CancelAsync(); server.Dispose(); } } /// /// Auth_timeout_closes_connection_if_no_connect: Creates a server with auth /// (token-based) and a short AuthTimeout of 500ms. Connects a raw socket, /// reads INFO, but does NOT send CONNECT. Verifies the server closes the /// connection with -ERR 'Authentication Timeout' after the timeout expires. /// Reference: Go client_test.go TestAuthorizationTimeout (line 1260) /// [Fact] public async Task Auth_timeout_closes_connection_if_no_connect() { var port = GetFreePort(); using var cts = new CancellationTokenSource(); var server = new NatsServer( new NatsOptions { Port = port, Authorization = "my_secret_token", AuthTimeout = TimeSpan.FromMilliseconds(500), }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(IPAddress.Loopback, port); // Read INFO — server requires auth so INFO will have auth_required:true var buf = new byte[4096]; var n = await client.ReceiveAsync(buf, SocketFlags.None); var info = Encoding.ASCII.GetString(buf, 0, n); info.ShouldStartWith("INFO "); // Do NOT send CONNECT — wait for auth timeout to fire // AuthTimeout is 500ms; wait up to 3x that for the error var response = await ReadUntilAsync(client, "Authentication Timeout", timeoutMs: 3000); response.ShouldContain("-ERR 'Authentication Timeout'"); // Connection should be closed after the auth timeout error using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token); n.ShouldBe(0); } finally { await cts.CancelAsync(); server.Dispose(); } } }