using System.Net; using System.Net.Sockets; using System.Text; using Microsoft.Extensions.Logging.Abstractions; using NATS.Server; namespace NATS.Server.Tests; public class ServerTests : IAsyncLifetime { private readonly NatsServer _server; private readonly int _port; private readonly CancellationTokenSource _cts = new(); public ServerTests() { // Use random port _port = GetFreePort(); _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance); } public async Task InitializeAsync() { _ = _server.StartAsync(_cts.Token); await _server.WaitForReadyAsync(); } public async Task DisposeAsync() { await _cts.CancelAsync(); _server.Dispose(); } 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 async Task ConnectClientAsync() { var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(IPAddress.Loopback, _port); return sock; } private static async Task ReadLineAsync(Socket sock, int bufSize = 4096) { var buf = new byte[bufSize]; var n = await sock.ReceiveAsync(buf, SocketFlags.None); return Encoding.ASCII.GetString(buf, 0, n); } /// /// Reads from a socket until the accumulated data contains the expected substring. /// 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(); } [Fact] public async Task Server_accepts_connection_and_sends_INFO() { using var client = await ConnectClientAsync(); var response = await ReadLineAsync(client); response.ShouldStartWith("INFO "); } [Fact] public async Task Server_basic_pubsub() { using var pub = await ConnectClientAsync(); using var sub = await ConnectClientAsync(); // Read INFO from both await ReadLineAsync(pub); await ReadLineAsync(sub); // CONNECT + SUB on subscriber, then PING to flush await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo 1\r\nPING\r\n")); var pong = await ReadLineAsync(sub); pong.ShouldContain("PONG"); // CONNECT + PUB on publisher await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo 5\r\nHello\r\n")); // Read MSG from subscriber (may arrive across multiple TCP segments) var msg = await ReadUntilAsync(sub, "Hello\r\n"); msg.ShouldContain("MSG foo 1 5\r\nHello\r\n"); } [Fact] public async Task Server_wildcard_matching() { using var pub = await ConnectClientAsync(); using var sub = await ConnectClientAsync(); await ReadLineAsync(pub); await ReadLineAsync(sub); // CONNECT + SUB on subscriber, then PING to flush await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n")); var pong = await ReadLineAsync(sub); pong.ShouldContain("PONG"); await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo.bar 5\r\nHello\r\n")); var buf = new byte[4096]; var n = await sub.ReceiveAsync(buf, SocketFlags.None); var msg = Encoding.ASCII.GetString(buf, 0, n); msg.ShouldContain("MSG foo.bar 1 5\r\n"); } [Fact] public async Task Server_pedantic_rejects_invalid_publish_subject() { using var pub = await ConnectClientAsync(); using var sub = await ConnectClientAsync(); // Read INFO from both await ReadLineAsync(pub); await ReadLineAsync(sub); // Connect with pedantic mode ON await pub.SendAsync(Encoding.ASCII.GetBytes( "CONNECT {\"pedantic\":true}\r\nPING\r\n")); var pong = await ReadUntilAsync(pub, "PONG"); // Subscribe on sub await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n")); await ReadUntilAsync(sub, "PONG"); // PUB with wildcard subject (invalid for publish) await pub.SendAsync(Encoding.ASCII.GetBytes("PUB foo.* 5\r\nHello\r\n")); // Publisher should get -ERR var errResponse = await ReadUntilAsync(pub, "-ERR", timeoutMs: 3000); errResponse.ShouldContain("-ERR 'Invalid Publish Subject'"); } [Fact] public async Task Server_nonpedantic_allows_wildcard_publish_subject() { using var pub = await ConnectClientAsync(); using var sub = await ConnectClientAsync(); await ReadLineAsync(pub); await ReadLineAsync(sub); // Connect without pedantic mode (default) await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n")); await ReadUntilAsync(sub, "PONG"); await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo.* 5\r\nHello\r\n")); // Sub should still receive the message (no validation in non-pedantic mode) var msg = await ReadUntilAsync(sub, "Hello\r\n"); msg.ShouldContain("MSG foo.* 1 5\r\nHello\r\n"); } [Fact] public async Task Server_rejects_max_payload_violation() { // Create server with tiny max payload var port = GetFreePort(); using var cts = new CancellationTokenSource(); var server = new NatsServer(new NatsOptions { Port = port, MaxPayload = 10 }, NullLoggerFactory.Instance); _ = server.StartAsync(cts.Token); await server.WaitForReadyAsync(); try { var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(IPAddress.Loopback, port); var buf = new byte[4096]; await client.ReceiveAsync(buf, SocketFlags.None); // INFO await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n")); // Send PUB with payload larger than MaxPayload (10 bytes) await client.SendAsync(Encoding.ASCII.GetBytes("PUB foo 20\r\n12345678901234567890\r\n")); using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); var n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token); var response = Encoding.ASCII.GetString(buf, 0, n); response.ShouldContain("-ERR 'Maximum Payload Violation'"); // Connection should be closed n = await client.ReceiveAsync(buf, SocketFlags.None, readCts.Token); n.ShouldBe(0); client.Dispose(); } finally { await cts.CancelAsync(); server.Dispose(); } } } public class MaxConnectionsTests : IAsyncLifetime { private readonly NatsServer _server; private readonly int _port; private readonly CancellationTokenSource _cts = new(); public MaxConnectionsTests() { _port = GetFreePort(); _server = new NatsServer(new NatsOptions { Port = _port, MaxConnections = 2 }, NullLoggerFactory.Instance); } public async Task InitializeAsync() { _ = _server.StartAsync(_cts.Token); await _server.WaitForReadyAsync(); } public async Task DisposeAsync() { await _cts.CancelAsync(); _server.Dispose(); } 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; } [Fact] public async Task Server_rejects_connection_when_max_reached() { using var readCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); // Connect two clients (at limit) var client1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client1.ConnectAsync(IPAddress.Loopback, _port); var buf = new byte[4096]; var n = await client1.ReceiveAsync(buf, SocketFlags.None, readCts.Token); Encoding.ASCII.GetString(buf, 0, n).ShouldStartWith("INFO "); var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client2.ConnectAsync(IPAddress.Loopback, _port); n = await client2.ReceiveAsync(buf, SocketFlags.None, readCts.Token); Encoding.ASCII.GetString(buf, 0, n).ShouldStartWith("INFO "); // Third client should be rejected var client3 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client3.ConnectAsync(IPAddress.Loopback, _port); n = await client3.ReceiveAsync(buf, SocketFlags.None, readCts.Token); var response = Encoding.ASCII.GetString(buf, 0, n); response.ShouldContain("-ERR 'maximum connections exceeded'"); // Connection should be closed n = await client3.ReceiveAsync(buf, SocketFlags.None, readCts.Token); n.ShouldBe(0); client1.Dispose(); client2.Dispose(); client3.Dispose(); } } public class PingKeepaliveTests : IAsyncLifetime { private readonly NatsServer _server; private readonly int _port; private readonly CancellationTokenSource _cts = new(); public PingKeepaliveTests() { _port = GetFreePort(); // Short intervals for testing: 500ms ping interval, 2 max pings out _server = new NatsServer( new NatsOptions { Port = _port, PingInterval = TimeSpan.FromMilliseconds(500), MaxPingsOut = 2, }, NullLoggerFactory.Instance); } public async Task InitializeAsync() { _ = _server.StartAsync(_cts.Token); await _server.WaitForReadyAsync(); } public async Task DisposeAsync() { await _cts.CancelAsync(); _server.Dispose(); } 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(); } [Fact] public async Task Server_sends_PING_after_inactivity() { 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 to start keepalive await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n")); // Wait for server to send PING (should come within ~500ms) var response = await ReadUntilAsync(client, "PING", timeoutMs: 3000); response.ShouldContain("PING"); client.Dispose(); } [Fact] public async Task Server_pong_resets_ping_counter() { var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(IPAddress.Loopback, _port); var buf = new byte[4096]; await client.ReceiveAsync(buf, SocketFlags.None); // INFO await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n")); // Wait for first PING var response = await ReadUntilAsync(client, "PING", timeoutMs: 3000); response.ShouldContain("PING"); // Respond with PONG — this resets the counter await client.SendAsync(Encoding.ASCII.GetBytes("PONG\r\n")); // Wait for next PING (counter reset, so we should get another one) response = await ReadUntilAsync(client, "PING", timeoutMs: 3000); response.ShouldContain("PING"); // Respond again to keep alive await client.SendAsync(Encoding.ASCII.GetBytes("PONG\r\n")); // Client should still be alive — send a PING and expect PONG back await client.SendAsync(Encoding.ASCII.GetBytes("PING\r\n")); response = await ReadUntilAsync(client, "PONG", timeoutMs: 3000); response.ShouldContain("PONG"); client.Dispose(); } [Fact] public async Task Server_disconnects_stale_client() { var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await client.ConnectAsync(IPAddress.Loopback, _port); var buf = new byte[4096]; await client.ReceiveAsync(buf, SocketFlags.None); // INFO await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n")); // Don't respond to PINGs — wait for stale disconnect // With 500ms interval and MaxPingsOut=2: // t=500ms: PING #1, pingsOut=1 // t=1000ms: PING #2, pingsOut=2 // t=1500ms: pingsOut+1 > MaxPingsOut → -ERR 'Stale Connection' + close var sb = new StringBuilder(); try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); while (true) { var n = await client.ReceiveAsync(buf, SocketFlags.None, timeout.Token); if (n == 0) break; sb.Append(Encoding.ASCII.GetString(buf, 0, n)); } } catch (OperationCanceledException) { // Timeout is acceptable — check what we got } var allData = sb.ToString(); allData.ShouldContain("-ERR 'Stale Connection'"); client.Dispose(); } }